La lección anterior terminó con una promesa incumplida. Escribiste cambiarEstado para que las transiciones respetaran la regla R6, y sin embargo cualquier línea del programa sigue pudiendo escribir tarea.estado = 'hecha' y dejar el objeto en un estado imposible. El método existe, pero no es obligatorio: es una recomendación educada. Esta lección va de convertir esa recomendación en una barrera real. Aprenderás a exponer propiedades calculadas con get, a interceptar las escrituras con set, a esconder el estado de verdad con campos privados #campo —una característica del lenguaje, no una convención—, a declarar propiedades de solo lectura con Object.defineProperty, y a diseñar una API pública mínima para Tablero. Y como lo privado interfiere con la serialización que aprendiste en 04-08, cerrarás con toJSON para que exportar el backlog siga funcionando.
Contenido
- Qué puede romper un consumidor sin encapsulación
get: propiedades calculadasset: validar en la asignación- Cuándo un getter y cuándo un método
- Campos privados
#campo - Métodos privados y estáticos privados
- El operador
#campo in objeto - Las alternativas históricas:
_campo, closures yWeakMap - Propiedades de solo lectura y
Object.defineProperty - Diseñar la API pública de
Tablero - Lo privado y
JSON.stringify: el rescate detoJSON - Errores Comunes y Consejos
- Ejercicios
- Conclusión
- Qué puede romper un consumidor sin encapsulación
Empecemos midiendo el daño. Esta es la Tarea del 05-02, con toda su validación, y estas son cuatro líneas que la destruyen sin que el objeto proteste.
'use strict';
const tarea = new Tarea({
id: 6, titulo: 'Presupuesto de la carpintería', responsable: 'Iván',
prioridad: 'alta', horasEstimadas: 5, fechaLimite: '2026-09-05'
});
// ✗ 1 · Salta la R6: de 'pendiente' a 'hecha' sin pasar por 'en-curso'
tarea.estado = 'hecha';
// ✗ 2 · Salta la R3: horas fuera del rango 1-40
tarea.horasEstimadas = 500;
// ✗ 3 · Estado inexistente: ni siquiera está en Tarea.ESTADOS
tarea.estado = 'archivada';
// ✗ 4 · Salta la R8: cadena vacía en lugar de null
tarea.responsable = '';
console.log(tarea.descripcion('2026-09-20'));
// ? [6] Presupuesto de la carpintería · · 500 hEse ? de la marca y ese hueco donde debería ir el responsable son las consecuencias visibles. Las invisibles son peores: el resumen del tablero ahora dice que hay 543 horas abiertas, el informe de Marta cuadra mal, y cuando alguien depure el problema dentro de tres semanas no habrá forma de saber qué línea hizo la asignación ilegal, porque una asignación no deja rastro en ninguna traza.
El diagnóstico técnico es simple: estado, horasEstimadas y responsable son propiedades públicas normales, y en JavaScript una propiedad pública es un contrato abierto. La encapsulación consiste en reducir la superficie de contacto: dejar visible solo lo que el consumidor necesita, y obligar a que todo cambio pase por un método que sepa validarlo.
flowchart LR
subgraph Sin["Sin encapsulación"]
C1["Código externo"] -->|"tarea.estado = 'hecha'"| E1["estado<br/>(público)"]
C1 -->|"tarea.cambiarEstado('hecha')"| M1["cambiarEstado()"]
M1 --> E1
end
subgraph Con["Con encapsulación"]
C2["Código externo"] -.->|"✗ inaccesible"| E2["#estado<br/>(privado)"]
C2 -->|"tarea.cambiarEstado('hecha')"| M2["cambiarEstado()<br/>valida R6"]
M2 --> E2
end
get: propiedades calculadas
get: propiedades calculadasEl primer paso es más suave que los campos privados y ya resuelve muchos casos: una propiedad que no se guarda, se calcula.
'use strict';
class Tarea {
constructor(datos) {
this.id = datos.id;
this.titulo = datos.titulo;
this.estado = datos.estado ?? 'pendiente';
this.horasEstimadas = datos.horasEstimadas;
this.fechaLimite = datos.fechaLimite;
}
get abierta() {
return this.estado !== 'hecha';
}
get diasRestantes() {
const limite = new Date(this.fechaLimite);
const hoy = new Date('2026-09-20');
return Math.round((limite - hoy) / (1000 * 60 * 60 * 24));
}
}
const carpinteria = new Tarea({ id: 6, titulo: 'Presupuesto de la carpintería', horasEstimadas: 5, fechaLimite: '2026-09-05' });
console.log(carpinteria.abierta); // true ← sin paréntesis
console.log(carpinteria.diasRestantes); // -15 ← vencida hace 15 díasLas dos características que definen un getter:
- Se lee como una propiedad, sin paréntesis.
carpinteria.abierta, nocarpinteria.abierta(). - Se ejecuta cada vez que se lee, así que el valor siempre está al día. Si cambias
estado,abiertacambia sola: no hay ningún dato duplicado que mantener sincronizado.
Ese segundo punto es el argumento fuerte. La alternativa —guardar this.abierta = true en el constructor— crea dos fuentes de verdad que tarde o temprano se desincronizan.
// ✗ Dato derivado guardado: hay que acordarse de actualizarlo SIEMPRE
this.abierta = datos.estado !== 'hecha';
tarea.estado = 'hecha';
console.log(tarea.abierta); // true ← mentiraUn getter también funciona en objetos literales, no solo en clases, con la misma sintaxis:
const equipo = {
miembros: ['Marta', 'Iván', 'Lucía'],
get tamano() { return this.miembros.length; }
};
console.log(equipo.tamano); // 3Y en el Tablero los getters brillan especialmente, porque casi todo lo que se consulta es derivado:
class Tablero {
constructor(nombre, tareas = []) {
this.nombre = nombre;
this.tareas = [...tareas];
}
get total() { return this.tareas.length; }
get abiertas() { return this.tareas.filter((t) => t.abierta); }
get horasTotales() { return this.tareas.reduce((s, t) => s + t.horasEstimadas, 0); }
get horasAbiertas() { return this.abiertas.reduce((s, t) => s + t.horasEstimadas, 0); }
get esfuerzo() { return this.tareas.reduce((s, t) => s + t.esfuerzo(), 0); }
}
console.log(tablero.total); // 6
console.log(tablero.horasTotales); // 48
console.log(tablero.horasAbiertas); // 45
console.log(tablero.esfuerzo); // 124Fíjate en horasAbiertas: usa el getter abiertas, que a su vez recorre tareas. Los getters se pueden encadenar entre sí con toda naturalidad, y el resultado se lee como una descripción de los datos, no como una secuencia de llamadas.
set: validar en la asignación
set: validar en la asignaciónUn setter es la otra mitad: una función que se ejecuta cuando alguien asigna a la propiedad. Recibe exactamente un parámetro, el valor asignado.
'use strict';
class Tarea {
constructor(datos) {
this.id = datos.id;
this._horas = 0;
this.horasEstimadas = datos.horasEstimadas; // ← pasa por el setter, se valida
}
get horasEstimadas() {
return this._horas;
}
set horasEstimadas(valor) {
if (typeof valor !== 'number' || Number.isNaN(valor)) {
throw new ErrorDeValidacion('Las horas deben ser un número.', 'horasEstimadas', valor);
}
if (valor <= 0 || valor > 40) { // R3
throw new ErrorDeValidacion('Las horas deben estar entre 1 y 40.', 'horasEstimadas', valor);
}
this._horas = valor;
}
}
const t = new Tarea({ id: 6, horasEstimadas: 5 });
console.log(t.horasEstimadas); // 5
t.horasEstimadas = 8; // ✓ válido
console.log(t.horasEstimadas); // 8
try {
t.horasEstimadas = 500; // ✗ ahora SÍ salta
} catch (error) {
console.error(error.message); // 'Las horas deben estar entre 1 y 40.'
}
console.log(t.horasEstimadas); // 8 ← el objeto quedó intactoYa no hay forma de meter 500 horas en una tarea, ni siquiera por asignación directa. Cuatro detalles que hay que tener claros:
- El getter y el setter comparten nombre, y ese nombre no puede ser el del campo donde se guarda el dato. Si escribieras
set horasEstimadas(v) { this.horasEstimadas = v; }provocarías una recursión infinita: la asignación vuelve a llamar al setter. Por eso el almacén interno se llama distinto (_horasaquí, y#horasen cuanto lleguemos al apartado 5). - Un setter sin getter hace que leer la propiedad devuelva
undefined. Casi siempre se declaran los dos. - Un getter sin setter convierte la propiedad en solo lectura: en modo estricto, asignarle algo lanza
TypeError. Es un uso perfectamente legítimo. - Validar en el constructor no basta. Fíjate en que el constructor de arriba asigna
this.horasEstimadas = datos.horasEstimadasen lugar de tocar_horasdirectamente: así la validación del setter se aplica también en la creación, y la regla vive escrita una sola vez.
class SoloLectura {
#valor = 42;
get valor() { return this.#valor; }
}
const sl = new SoloLectura();
// sl.valor = 7; // ✗ TypeError: Cannot set property valor… which has only a getter
- Cuándo un getter y cuándo un método
Getters y métodos hacen cosas parecidas, y elegir mal produce APIs incómodas. La regla práctica:
| Usa un getter cuando… | Usa un método cuando… |
|---|---|
| Devuelve un dato conceptualmente propio del objeto | Realiza una acción o un cálculo con nombre de verbo |
| Es barato (recorrer un array corto, una resta) | Es caro (recorrer miles de elementos, ordenar, clonar) |
| No recibe argumentos | Recibe argumentos |
| No tiene efectos secundarios ni lanza errores en condiciones normales | Puede modificar el objeto o fallar |
| Leerlo dos veces seguidas da lo mismo | El resultado puede cambiar entre llamadas |
Aplicado al proyecto:
class Tarea {
get abierta() { … } // ✓ dato propio, barato, sin argumentos
get diasRestantes() { … } // ✓ derivado de fechaLimite
estaVencida(hoy) { … } // ✓ método: recibe un argumento
cambiarEstado(nuevo) { … } // ✓ método: es una ACCIÓN, y puede lanzar
esfuerzo() { … } // ✓ método: podría ser getter; se mantiene por coherencia
}El criterio del coste merece un aviso, porque es el que más sorprende. Quien lee tablero.horasAbiertas da por hecho que es una lectura barata, y no se lo piensa dos veces antes de ponerlo dentro de un bucle:
// Si horasAbiertas fuera costoso, esto sería un desastre invisible
for (const tarea of tablero.tareas) {
console.log(tarea.horasEstimadas / tablero.horasAbiertas); // ← recalcula en cada vuelta
}Con seis tareas da igual. Con cinco mil, ese bucle pasa a ser cuadrático y nadie sospecharía de una simple lectura de propiedad. La regla es honesta: si el cálculo es caro, dale forma de método (calcularHorasAbiertas()), porque los paréntesis avisan de que ahí pasa algo. Y si necesitas un getter caro, cachea el resultado internamente e invalídalo cuando el estado cambie —una técnica que es la memoización de 03-06 aplicada a objetos, y que se estudia con calma en el Módulo 9.
- Campos privados
#campo
#campoLos getters y setters controlan el acceso, pero en el apartado 3 el almacén real seguía siendo _horas: una propiedad pública con un nombre que pide que no la toques. Desde ES2022 el lenguaje ofrece privacidad de verdad.
'use strict';
class Tarea {
#estado = 'pendiente'; // campo privado, con valor inicial (R5)
constructor(datos) {
this.id = datos.id;
this.titulo = datos.titulo;
if (datos.estado !== undefined) this.#validarYAsignarEstado(datos.estado, true);
}
get estado() {
return this.#estado; // lectura pública, escritura imposible
}
cambiarEstado(nuevo) {
this.#validarYAsignarEstado(nuevo, false);
return this;
}
#validarYAsignarEstado(nuevo, esInicial) { /* apartado 6 */ }
}
const t = new Tarea({ id: 6, titulo: 'Presupuesto de la carpintería' });
console.log(t.estado); // 'pendiente' ← se lee por el getter
// t.#estado; // ✗ SyntaxError: Private field '#estado' must be declared in an enclosing class
// t.estado = 'hecha'; // ✗ TypeError: solo tiene getterLos campos privados tienen unas propiedades muy concretas que conviene enumerar:
- La almohadilla forma parte del nombre.
#estadoyestadoson propiedades distintas que pueden coexistir, que es justo lo que hacemos:#estadoguarda,estado(getter) expone. - Deben declararse en el cuerpo de la clase. No puedes crear uno al vuelo con
this.#nuevo = 1si no está declarado: es unSyntaxError, detectado antes de ejecutar. - Solo son accesibles desde dentro de la clase que los declara. Ni siquiera desde una subclase: si
TareaRecurrente extends Tareanecesita el estado, tiene que usar el getter público. - El error es sintáctico, no de ejecución. Acceder desde fuera ni siquiera compila, lo que significa que el fallo aparece de inmediato y no en producción.
- No aparecen en
Object.keys,for...in,JSON.stringify,Object.entriesni en el spread{...obj}. Esto último tiene consecuencias que resolveremos en el apartado 11.
Esta es la privacidad real, y es lo que faltaba para cumplir la promesa del 05-02:
const tarea = new Tarea({ id: 6, titulo: 'Presupuesto de la carpintería' });
tarea.estado = 'hecha'; // en modo estricto → TypeError
console.log(tarea.estado); // 'pendiente' ← intacta
tarea.cambiarEstado('en-curso'); // ✓ único camino
console.log(tarea.estado); // 'en-curso'
- Métodos privados y estáticos privados
La almohadilla también funciona en métodos, campos estáticos y métodos estáticos. Sirve para el mismo propósito: separar el mecanismo interno de la interfaz.
Esta es la versión completa de la Tarea encapsulada del proyecto, el ejemplo central de la lección:
'use strict';
class Tarea {
// ── Configuración privada de la clase ───────────────────────────
static #TRANSICIONES = { // R6
pendiente: ['en-curso'],
'en-curso': ['pendiente', 'hecha'],
hecha: []
};
static #MARCAS = { pendiente: '○', 'en-curso': '▸', hecha: '✓' };
static #PESOS = { alta: 3, media: 2, baja: 1 };
static #ultimoId = 6; // R1: el backlog llega hasta el 6
// ── Estado privado de la instancia ──────────────────────────────
#estado = 'pendiente'; // R5
#horas;
#etiquetas = [];
constructor(datos) {
this.id = datos.id ?? Tarea.siguienteId();
this.titulo = Tarea.#validarTitulo(datos.titulo);
this.responsable = datos.responsable || null; // R8
this.prioridad = datos.prioridad ?? 'media';
this.fechaLimite = datos.fechaLimite;
this.revisor = datos.revisor ?? null;
this.horasEstimadas = datos.horasEstimadas; // pasa por el setter (R3)
this.etiquetas = datos.etiquetas ?? []; // pasa por el setter (R9)
if (datos.estado !== undefined) this.#asignarEstadoInicial(datos.estado);
}
// ── Estáticos ───────────────────────────────────────────────────
static siguienteId() {
Tarea.#ultimoId += 1;
return Tarea.#ultimoId;
}
static #validarTitulo(titulo) { // método estático PRIVADO
if (typeof titulo !== 'string' || titulo.trim() === '') {
throw new ErrorDeValidacion('El título no puede estar vacío.', 'titulo', titulo); // R2
}
return titulo.trim();
}
// ── Getters ─────────────────────────────────────────────────────
get estado() { return this.#estado; }
get horasEstimadas() { return this.#horas; }
get etiquetas() { return [...this.#etiquetas]; } // copia defensiva
get abierta() { return this.#estado !== 'hecha'; }
get esfuerzo() { return (Tarea.#PESOS[this.prioridad] ?? 0) * this.#horas; }
get marca() { return Tarea.#MARCAS[this.#estado]; }
// ── Setters ─────────────────────────────────────────────────────
set horasEstimadas(valor) {
if (typeof valor !== 'number' || !(valor > 0 && valor <= 40)) {
throw new ErrorDeValidacion('Las horas deben ser un número entre 1 y 40.', 'horasEstimadas', valor);
}
this.#horas = valor;
}
set etiquetas(lista) {
if (!Array.isArray(lista)) {
throw new ErrorDeValidacion('Las etiquetas deben ser un array.', 'etiquetas', lista);
}
this.#etiquetas = [...new Set(lista.map((e) => String(e).trim().toLowerCase()))].filter(Boolean); // R9
}
// ── Métodos privados ────────────────────────────────────────────
#asignarEstadoInicial(estado) {
if (!Object.hasOwn(Tarea.#TRANSICIONES, estado)) {
throw new ErrorDeValidacion(`Estado desconocido: "${estado}".`, 'estado', estado);
}
this.#estado = estado;
}
#puedeIrA(nuevo) {
return (Tarea.#TRANSICIONES[this.#estado] ?? []).includes(nuevo);
}
// ── Interfaz pública ────────────────────────────────────────────
cambiarEstado(nuevo) {
if (!Object.hasOwn(Tarea.#TRANSICIONES, nuevo)) {
throw new ErrorDeValidacion(`Estado desconocido: "${nuevo}".`, 'estado', nuevo);
}
if (!this.#puedeIrA(nuevo)) {
throw new ErrorDeValidacion(
`Transición no permitida: "${this.#estado}" → "${nuevo}".`, 'estado', nuevo); // R6
}
this.#estado = nuevo;
return this;
}
estaVencida(hoy) {
return this.fechaLimite < hoy && this.abierta; // R10
}
descripcion(hoy) {
const aviso = hoy !== undefined && this.estaVencida(hoy) ? ' ⚠ VENCIDA' : '';
return `${this.marca} [${this.id}] ${this.titulo} · ${this.responsable ?? 'sin asignar'} · ${this.#horas} h${aviso}`;
}
}Y la comprobación de que el objeto ya no se puede corromper:
const carpinteria = new Tarea({
id: 6, titulo: ' Presupuesto de la carpintería ', responsable: 'Iván',
prioridad: 'alta', etiquetas: ['Carpintería', 'compras', 'CARPINTERÍA', ' '],
horasEstimadas: 5, fechaLimite: '2026-09-05', revisor: 'Marta'
});
console.log(carpinteria.titulo); // 'Presupuesto de la carpintería' ← trim, R2
console.log(carpinteria.etiquetas); // [ 'carpintería', 'compras' ] ← R9
console.log(carpinteria.descripcion('2026-09-20')); // ○ [6] Presupuesto de la carpintería · Iván · 5 h ⚠ VENCIDA
console.log(carpinteria.esfuerzo); // 15
// Los cuatro ataques del apartado 1, ahora:
try { carpinteria.estado = 'hecha'; } catch (e) { console.log('✓ bloqueado:', e.constructor.name); }
try { carpinteria.horasEstimadas = 500; } catch (e) { console.log('✓ bloqueado:', e.message); }
try { carpinteria.cambiarEstado('archivada'); }catch (e) { console.log('✓ bloqueado:', e.message); }
try { carpinteria.cambiarEstado('hecha'); } catch (e) { console.log('✓ bloqueado:', e.message); }
console.log(carpinteria.estado, carpinteria.horasEstimadas); // 'pendiente' 5 ← intactoMerece la pena señalar dos decisiones de diseño de ese código:
La copia defensiva del getter etiquetas. Devolver [...this.#etiquetas] en lugar del array interno impide que alguien haga tarea.etiquetas.push('URGENTE') y se salte la R9 mutando desde fuera. Es exactamente el peligro de la copia superficial que estudiaste en 04-08, aplicado aquí como defensa. El precio es que crea un array nuevo en cada lectura, así que se justifica solo cuando el array es pequeño y el riesgo real.
Los setters se usan también desde el constructor. this.horasEstimadas = datos.horasEstimadas dentro del constructor invoca el setter y aplica la R3. Si el constructor escribiera this.#horas = datos.horasEstimadas, la validación existiría solo para las modificaciones posteriores, y la regla acabaría duplicada. Una regla, un sitio.
- El operador
#campo in objeto
#campo in objetoComo acceder a un campo privado desde fuera es un SyntaxError, no se puede comprobar su existencia con un try/catch. Para eso existe una forma especial del operador in, llamada brand check («comprobación de marca»):
'use strict';
class Tarea {
#estado = 'pendiente';
/** ¿Es este objeto una Tarea auténtica, creada por esta clase? */
static esTarea(objeto) {
return #estado in objeto;
}
}
const real = new Tarea({ id: 1, titulo: 'Rediseñar la sala polivalente', horasEstimadas: 12, fechaLimite: '2026-09-30' });
const impostor = { id: 1, titulo: 'Rediseñar la sala polivalente', estado: 'pendiente' };
console.log(Tarea.esTarea(real)); // true
console.log(Tarea.esTarea(impostor)); // false
console.log(Tarea.esTarea(null)); // false (in devuelve false, no lanza, para objetos)La expresión #estado in objeto solo puede escribirse dentro de la clase que declara #estado, y devuelve true o false sin lanzar nunca. ¿Por qué no usar instanceof, que ya conoces de 05-01? Porque instanceof mira la cadena de prototipos, y esa se puede falsificar:
const falsificado = Object.create(Tarea.prototype);
console.log(falsificado instanceof Tarea); // true ← ¡miente!
console.log(Tarea.esTarea(falsificado)); // false ← la marca no se puede falsificar
// falsificado.descripcion(); // ✗ TypeError: no tiene los campos privadosEse objeto tiene los métodos pero ninguno de los campos privados, porque nunca pasó por el constructor. Un instanceof lo daría por bueno y el programa reventaría más adelante, en un sitio confuso. El brand check es la comprobación honesta: solo los objetos que realmente pasaron por el constructor de esta clase tienen sus campos privados. Es la técnica adecuada cuando un método necesita garantizar que puede tocar el estado interno de su argumento.
- Las alternativas históricas:
_campo, closures y WeakMap
_campo, closures y WeakMapLos campos # llegaron en 2022. Antes se usaban tres técnicas, y las encontrarás en cualquier código con unos años, así que conviene reconocerlas.
La convención del guion bajo. Un _ delante del nombre significa «esto es interno, no lo toques». No hay ningún mecanismo detrás: es un acuerdo entre personas.
class TareaAntigua {
constructor(estado) { this._estado = estado; }
get estado() { return this._estado; }
}
const t = new TareaAntigua('pendiente');
t._estado = 'archivada'; // funciona perfectamente: nada lo impideClosures (el patrón módulo de 03-04). El estado vive en variables capturadas por las funciones devueltas. Es privacidad real, la misma de crearAlmacenDeTareas.
function crearTarea(datosIniciales) {
let estado = 'pendiente'; // ← inaccesible desde fuera, de verdad
const transiciones = { pendiente: ['en-curso'], 'en-curso': ['pendiente', 'hecha'], hecha: [] };
return {
id: datosIniciales.id,
titulo: datosIniciales.titulo,
get estado() { return estado; },
cambiarEstado(nuevo) {
if (!transiciones[estado].includes(nuevo)) throw new ErrorDeValidacion('Transición no permitida.', 'estado', nuevo);
estado = nuevo;
return this;
}
};
}Funciona, pero tiene el defecto que motivó todo el módulo: los métodos se crean de nuevo en cada llamada, porque viven en el objeto devuelto y no en un prototipo. Es el problema del apartado 1 de 05-01.
WeakMap con la instancia como clave. El truco que usaban las librerías serias antes de ES2022: una tabla externa que asocia cada instancia con sus datos privados.
const privados = new WeakMap();
class TareaWeak {
constructor(datos) {
privados.set(this, { estado: 'pendiente' });
this.id = datos.id;
}
get estado() { return privados.get(this).estado; }
cambiarEstado(nuevo) { privados.get(this).estado = nuevo; return this; }
}Es privacidad real y compatible con prototipos, pero verboso. El detalle de la Weak: mantiene referencias débiles, de modo que cuando la instancia deja de usarse el recolector de basura puede llevarse también su entrada. Un Map normal aquí provocaría una fuga de memoria, tema del Módulo 9.
La comparativa completa:
| Técnica | ¿Privacidad real? | Métodos en el prototipo | Verbosidad | Cuándo la verás |
|---|---|---|---|---|
_campo (convención) |
No | Sí | Mínima | Código anterior a 2022, y en equipos que prefieren no bloquear las pruebas |
| Closure (patrón módulo) | Sí | No | Media | Funciones fábrica, módulos de configuración |
WeakMap externo |
Sí | Sí | Alta | Librerías anteriores a ES2022 |
#campo |
Sí | Sí | Mínima | La opción por defecto hoy |
Con una salvedad honesta sobre #: como es privacidad real, tus pruebas tampoco pueden mirar dentro (Módulo 8). Eso obliga a probar el objeto a través de su interfaz pública, que es lo correcto, pero exige diseñar bien esa interfaz. Algunos equipos siguen prefiriendo _ por esa razón; es una decisión de equipo defendible, no un error.
- Propiedades de solo lectura y
Object.defineProperty
Object.definePropertyQueda un agujero abierto en el modelo: el id. Es una propiedad pública normal, y la R1 dice que lo asigna la aplicación y nadie lo cambia.
Un getter sin setter sobre un campo privado ya lo resolvería, pero hay una herramienta más precisa y de propósito general: Object.defineProperty, que permite crear una propiedad especificando sus atributos en lugar de solo su valor.
'use strict';
class Tarea {
constructor(datos) {
Object.defineProperty(this, 'id', {
value: datos.id,
writable: false, // no se puede reasignar
enumerable: true, // sí aparece en Object.keys y JSON.stringify
configurable: false // no se puede borrar ni redefinir
});
this.titulo = datos.titulo;
}
}
const t = new Tarea({ id: 6, titulo: 'Presupuesto de la carpintería' });
console.log(t.id); // 6
// t.id = 999; // ✗ TypeError: Cannot assign to read only property 'id'
// delete t.id; // ✗ TypeError: Cannot delete property 'id'
console.log(Object.keys(t)); // [ 'id', 'titulo' ]
console.log(JSON.stringify(t)); // {"id":6,"titulo":"Presupuesto de la carpintería"}Los tres atributos, con su efecto práctico:
| Atributo | Si es true |
Si es false |
Por defecto con defineProperty |
|---|---|---|---|
writable |
Se puede reasignar con = |
Asignar lanza TypeError en modo estricto (y se ignora sin él) |
false |
enumerable |
Aparece en Object.keys, for...in, spread, JSON.stringify |
Existe pero permanece invisible a esas operaciones | false |
configurable |
Se puede borrar con delete o redefinir |
delete y redefinir lanzan TypeError |
false |
Trampa clásica: una propiedad creada con asignación normal (
this.id = 6) tiene los tres atributos entrue; una creada conObject.definePropertylos tiene enfalsesi no los indicas. Si defines una propiedad y luego «desaparece» deJSON.stringify, es que olvidasteenumerable: true.
Puedes inspeccionar los atributos de cualquier propiedad:
console.log(Object.getOwnPropertyDescriptor(t, 'id'));
// { value: 6, writable: false, enumerable: true, configurable: false }
console.log(Object.getOwnPropertyDescriptor(t, 'titulo'));
// { value: '…', writable: true, enumerable: true, configurable: true }Y esto explica por fin dos cosas que quedaron sueltas en lecciones anteriores. La primera, por qué los métodos de Object.prototype no salían en for...in en 05-01: son no enumerables. La segunda, cómo hacer que un método sea invisible a la enumeración sin usar class:
Una comparación con Object.freeze, que conociste en 04-08:
| Herramienta | Alcance | Uso típico |
|---|---|---|
writable: false en una propiedad |
Una propiedad | El id de una tarea (R1) |
Object.freeze(obj) |
Todas las propiedades del objeto, superficialmente | Las constantes PESOS, MARCAS |
#campo privado |
Invisible desde fuera, escribible desde dentro | El #estado, que sí cambia por métodos |
Las tres se combinan según lo que quieras: invisible (privado), inmutable (writable: false) o ambas cosas.
- Diseñar la API pública de
Tablero
TableroCon todas las piezas, el ejercicio de diseño más valioso: decidir qué se ve y qué no en la pieza central del proyecto. La pregunta que guía la decisión es siempre la misma: ¿qué necesita de verdad quien usa este objeto?
En la clase Tablero del 05-02, this.tareas era público. Eso significa que cualquiera puede hacer tablero.tareas.push(cosaCualquiera), saltándose la validación de agregar, la R1 de identificadores únicos y la R7 de carga máxima. La lista de tareas es precisamente el invariante que el tablero debe proteger.
'use strict';
class Tablero {
#tareas = [];
#nombre;
constructor(nombre, tareas = []) {
this.#nombre = nombre;
for (const tarea of tareas) this.agregar(tarea); // toda entrada pasa por la puerta oficial
}
// ── Lecturas ────────────────────────────────────────────────────
get nombre() { return this.#nombre; }
get total() { return this.#tareas.length; }
get tareas() { return [...this.#tareas]; } // copia: mutarla no afecta al tablero
get abiertas() { return this.#tareas.filter((t) => t.abierta); }
get horasTotales() { return this.#tareas.reduce((s, t) => s + t.horasEstimadas, 0); }
get horasAbiertas() { return this.abiertas.reduce((s, t) => s + t.horasEstimadas, 0); }
get esfuerzo() { return this.#tareas.reduce((s, t) => s + t.esfuerzo, 0); }
buscarPorId(id) { return this.#tareas.find((t) => t.id === id) ?? null; }
filtrar(predicado) { return this.#tareas.filter(predicado); }
vencidas(hoy) { return this.abiertas.filter((t) => t.estaVencida(hoy)); }
horasPorResponsable() {
return this.abiertas.reduce((acc, t) => {
const clave = t.responsable ?? 'sin asignar';
acc[clave] = (acc[clave] ?? 0) + t.horasEstimadas;
return acc;
}, {});
}
resumen(hoy) {
return {
total: this.total, abiertas: this.abiertas.length,
horasTotales: this.horasTotales, horasAbiertas: this.horasAbiertas,
vencidas: this.vencidas(hoy).length, esfuerzo: this.esfuerzo
};
}
// ── Escrituras (las únicas puertas) ─────────────────────────────
agregar(tarea) {
if (!(tarea instanceof Tarea)) throw new ErrorDeValidacion('Solo se admiten instancias de Tarea.', 'tarea', tarea);
if (this.buscarPorId(tarea.id)) throw new ErrorDeValidacion(`Id duplicado: ${tarea.id}.`, 'id', tarea.id); // R1
this.#comprobarCarga(tarea); // R7
this.#tareas.push(tarea);
return this;
}
eliminar(id) {
const i = this.#tareas.findIndex((t) => t.id === id);
if (i === -1) throw new ErrorDeValidacion(`No existe la tarea ${id}.`, 'id', id);
return this.#tareas.splice(i, 1)[0];
}
cambiarEstado(id, nuevo) {
const tarea = this.buscarPorId(id);
if (tarea === null) throw new ErrorDeValidacion(`No existe la tarea ${id}.`, 'id', id);
tarea.cambiarEstado(nuevo);
return this;
}
// ── Mecanismo interno ───────────────────────────────────────────
#comprobarCarga(tarea) {
if (!tarea.abierta || tarea.responsable === null) return;
const actual = this.horasPorResponsable()[tarea.responsable] ?? 0;
if (actual + tarea.horasEstimadas > 40) {
throw new ErrorDeValidacion(
`${tarea.responsable} llegaría a ${actual + tarea.horasEstimadas} h (máximo 40).`, 'horasEstimadas', tarea.horasEstimadas);
}
}
}En uso, con el backlog canónico:
const tablero = new Tablero('Taller Nómada', datosBacklog.map((d) => new Tarea(d)));
console.log(tablero.resumen('2026-09-20'));
// { total: 6, abiertas: 5, horasTotales: 48, horasAbiertas: 45, vencidas: 1, esfuerzo: 124 }
console.log(tablero.horasPorResponsable()); // { 'Iván': 25, 'Marta': 6, 'Lucía': 14 }
// Los intentos de rodear la API:
tablero.tareas.push('esto no es una tarea');
console.log(tablero.total); // 6 ← se mutó una copia, el tablero no se enteró
try { tablero.agregar('esto tampoco'); }
catch (e) { console.log('✓ bloqueado:', e.message); } // Solo se admiten instancias de Tarea.El criterio de diseño, resumido en una tabla:
| Se expone | No se expone | Motivo |
|---|---|---|
nombre, total, resumen() |
El array #tareas real |
Es el invariante que hay que proteger |
tareas como copia |
— | Permite recorrer sin permitir mutar |
agregar, eliminar, cambiarEstado |
#comprobarCarga |
Es mecanismo, no interfaz |
filtrar(predicado) |
— | Da flexibilidad total sin abrir el array |
Dos matices honestos. El getter tareas copia el array, pero las tareas de dentro son las mismas instancias: es una copia superficial (04-08), así que quien la reciba sí puede llamar a tarea.cambiarEstado(...). Eso es intencionado —la tarea ya protege su propio estado—, pero conviene saberlo. Y filtrar(predicado) es una puerta deliberadamente abierta: en lugar de escribir veinte métodos de consulta, se deja que el consumidor traiga su propio predicado, que es exactamente la función de orden superior de 03-06.
- Lo privado y
JSON.stringify: el rescate de toJSON
JSON.stringify: el rescate de toJSONHay un precio que pagar por toda esta protección, y aparece en cuanto intentas guardar los datos.
const tarea = new Tarea({
id: 6, titulo: 'Presupuesto de la carpintería', responsable: 'Iván',
prioridad: 'alta', etiquetas: ['carpintería', 'compras'],
horasEstimadas: 5, fechaLimite: '2026-09-05', revisor: 'Marta'
});
console.log(JSON.stringify(tarea));
// {"id":6,"titulo":"Presupuesto de la carpintería","responsable":"Iván","prioridad":"alta","fechaLimite":"2026-09-05","revisor":"Marta"}Faltan estado, horasEstimadas y etiquetas. El motivo lo sabes desde 04-08: JSON.stringify recorre las propiedades propias enumerables, y los campos privados no lo son. Los getters tampoco cuentan: viven en el prototipo, y stringify no mira la cadena. El resultado es un JSON silenciosamente incompleto, que es el peor tipo de fallo posible: no lanza ningún error y solo se descubre al importar los datos.
La solución es el método toJSON que conociste en 04-08: si un objeto lo tiene, JSON.stringify usa su valor de retorno en lugar del objeto.
class Tarea {
// …todo lo anterior…
/** Representación serializable: incluye lo privado como campos públicos. */
toJSON() {
return {
id: this.id,
titulo: this.titulo,
responsable: this.responsable,
prioridad: this.prioridad,
estado: this.#estado,
etiquetas: [...this.#etiquetas],
horasEstimadas: this.#horas,
fechaLimite: this.fechaLimite,
revisor: this.revisor
};
}
/** Camino inverso: reconstruye una instancia desde datos planos. */
static desdeJSON(datos) {
return new Tarea(typeof datos === 'string' ? JSON.parse(datos) : datos);
}
}
console.log(JSON.stringify(tarea));
// {"id":6,…,"estado":"pendiente","etiquetas":["carpintería","compras"],"horasEstimadas":5,…}Y en el tablero, el ciclo completo de exportación e importación de 04-08, ahora con clases:
class Tablero {
// …todo lo anterior…
toJSON() {
return { version: 1, nombre: this.#nombre, tareas: this.#tareas }; // stringify llama al toJSON de cada tarea
}
static importar(texto) {
let datos;
try {
datos = JSON.parse(texto);
} catch (error) {
throw new ErrorDeDatos('El backlog no es JSON válido.', error);
}
if (datos.version !== 1) throw new ErrorDeDatos(`Versión no soportada: ${datos.version}.`);
return new Tablero(datos.nombre, datos.tareas.map((d) => Tarea.desdeJSON(d)));
}
}
const texto = JSON.stringify(tablero);
const recuperado = Tablero.importar(texto);
console.log(recuperado.resumen('2026-09-20'));
// { total: 6, abiertas: 5, horasTotales: 48, horasAbiertas: 45, vencidas: 1, esfuerzo: 124 }
console.log(recuperado.buscarPorId(6) instanceof Tarea); // true ← instancias de verdadEl par toJSON / desdeJSON es el patrón que debes recordar: una salida y una entrada explícitas, controladas por la clase, que además revalidan al importar. Sin él, todo lo privado se pierde al guardar.
Un aviso final sobre
structuredClone, que estudiaste en 04-08: no funciona con campos privados. Lanza unDataCloneErrorporque no puede reconstruir la clase. La copia profunda de una instancia se hace conTarea.desdeJSON(tarea.toJSON()), o con un métodoclonar()que haga exactamente eso.
Errores Comunes y Consejos
- Recursión infinita en el setter.
set estado(v) { this.estado = v; }se llama a sí mismo hasta agotar la pila (elRangeErrorde 03-05). El almacén interno debe tener otro nombre:#estado. - Validar solo en el setter y saltárselo en el constructor. Si el constructor escribe
this.#horas = datos.horasen lugar dethis.horasEstimadas = datos.horas, la validación no se aplica en la creación. Asigna siempre a través de la propiedad pública. - Usar
#campodesde una subclase. Los privados no se heredan:TareaRecurrenteno puede leerthis.#estado. Si una subclase necesita acceso, expón un getter (oprotectedpor convención con_, que JavaScript no tiene como mecanismo). - Olvidar
enumerable: trueenObject.defineProperty. Por defecto esfalse, y la propiedad desaparece deObject.keysy deJSON.stringifysin avisar. - Getters caros. Un getter que ordena o recorre miles de elementos engaña a quien lo lee. Si cuesta, que sea un método con paréntesis.
- Getters con efectos secundarios. Un getter que modifica el objeto, incrementa un contador o lanza errores en condiciones normales rompe la expectativa de que leer una propiedad es inocuo, y hace la depuración muy difícil (la propia consola del navegador ejecuta los getters al inspeccionar un objeto).
- Devolver el array interno desde un getter.
get etiquetas() { return this.#etiquetas; }deja la puerta abierta atarea.etiquetas.push(…). Devuelve una copia si el array debe mantener invariantes. - Encapsular por encapsular. Un objeto de datos puros —una fila leída de una API, un punto
{x, y}— no gana nada con quince getters triviales. La encapsulación es para objetos que tienen reglas que proteger. - Consejo: para decidir qué hacer privado, escribe primero cómo quieres que se use el objeto desde fuera. Todo lo que no aparezca en esas líneas de uso es candidato a ser privado.
Ejercicios
Ejercicio 1 — Residente con cupo de horas. Escribe una clase Residente para el coworking de Taller Nómada con:
- campos privados
#horasReservadas(inicial 0) y#plan; nombrede solo lectura medianteObject.defineProperty(R1 aplicada a personas);- getters
plan,horasReservadas,horasDisponiblesyalDia(truesi le quedan horas); - un setter
planque solo acepte'mensual'(40 h) o'diario'(8 h) y que rechace un cambio de plan si las horas ya reservadas superarían el cupo del plan nuevo; - un método
reservar(horas)que valide y sume, lanzandoErrorDeValidacionsi excede el cupo.
Ejercicio 2 — De _ a #. Convierte esta clase heredada a campos privados, getters y setters, y localiza el fallo de encapsulación que el guion bajo dejaba pasar.
class Presupuesto {
constructor(limite) {
this._limite = limite;
this._lineas = [];
}
get total() { return this._lineas.reduce((s, l) => s + l.importe, 0); }
get lineas() { return this._lineas; }
anadir(concepto, importe) {
if (this.total + importe > this._limite) throw new Error('Se supera el límite.');
this._lineas.push({ concepto, importe });
}
}Ejercicio 3 — Serialización con privados. Partiendo de la clase Tarea del apartado 6, añade toJSON(), static desdeJSON(datos) y un método clonar(). Demuestra con código que: (a) sin toJSON se pierden tres campos; (b) con toJSON el viaje de ida y vuelta conserva todo; (c) el clon es independiente del original —cambiar el estado de uno no afecta al otro— y (d) structuredClone falla.
Soluciones
Ejercicio 1
'use strict';
class Residente {
static #CUPOS = { mensual: 40, diario: 8 };
#plan;
#horasReservadas = 0;
constructor(nombre, plan) {
Object.defineProperty(this, 'nombre', { value: nombre, writable: false, enumerable: true, configurable: false });
this.plan = plan; // pasa por el setter: valida
}
get plan() { return this.#plan; }
get horasReservadas() { return this.#horasReservadas; }
get cupo() { return Residente.#CUPOS[this.#plan]; }
get horasDisponibles() { return this.cupo - this.#horasReservadas; }
get alDia() { return this.horasDisponibles > 0; }
set plan(nuevo) {
if (!Object.hasOwn(Residente.#CUPOS, nuevo)) {
throw new ErrorDeValidacion(`Plan desconocido: "${nuevo}".`, 'plan', nuevo);
}
if (this.#horasReservadas > Residente.#CUPOS[nuevo]) {
throw new ErrorDeValidacion(
`No se puede pasar a "${nuevo}": ya hay ${this.#horasReservadas} h reservadas y el cupo es ${Residente.#CUPOS[nuevo]}.`,
'plan', nuevo);
}
this.#plan = nuevo;
}
reservar(horas) {
if (typeof horas !== 'number' || horas <= 0) {
throw new ErrorDeValidacion('Las horas deben ser un número positivo.', 'horas', horas);
}
if (this.#horasReservadas + horas > this.cupo) {
throw new ErrorDeValidacion(
`${this.nombre} solo tiene ${this.horasDisponibles} h disponibles.`, 'horas', horas);
}
this.#horasReservadas += horas;
return this;
}
}
const lucia = new Residente('Lucía', 'mensual');
lucia.reservar(14).reservar(12);
console.log(lucia.horasReservadas, lucia.horasDisponibles, lucia.alDia); // 26 14 true
try { lucia.plan = 'diario'; }
catch (e) { console.error(e.message); } // No se puede pasar a "diario": ya hay 26 h reservadas y el cupo es 8.
try { lucia.nombre = 'Lucia'; }
catch (e) { console.error(e.constructor.name); } // TypeError
try { lucia.reservar(20); }
catch (e) { console.error(e.message); } // Lucía solo tiene 14 h disponibles.El detalle interesante es el setter de plan: no valida solo el valor entrante, sino la coherencia con el estado actual. Esa es la clase de invariante que solo se puede proteger desde dentro del objeto, porque solo él conoce las dos piezas del dato. Y observa que el constructor asigna this.plan = plan en lugar de this.#plan = plan: una regla, un sitio.
Ejercicio 2
El fallo estaba en el getter lineas: devolvía el array interno, así que cualquiera podía saltarse por completo la comprobación del límite.
const p = new Presupuesto(500);
p.anadir('Tinta serigráfica', 120);
p.lineas.push({ concepto: 'Torno nuevo', importe: 9000 }); // ✗ nadie comprueba nada
console.log(p.total); // 9120, con límite 500Versión corregida:
'use strict';
class Presupuesto {
#limite;
#lineas = [];
constructor(limite) {
if (typeof limite !== 'number' || limite <= 0) {
throw new ErrorDeValidacion('El límite debe ser un número positivo.', 'limite', limite);
}
this.#limite = limite;
}
get limite() { return this.#limite; }
get total() { return this.#lineas.reduce((s, l) => s + l.importe, 0); }
get disponible(){ return this.#limite - this.total; }
get lineas() { return this.#lineas.map((l) => ({ ...l })); } // copia de 2 niveles
anadir(concepto, importe) {
if (this.total + importe > this.#limite) {
throw new ErrorDeValidacion(
`Se supera el límite: quedan ${this.disponible} € y se piden ${importe} €.`, 'importe', importe);
}
this.#lineas.push({ concepto, importe });
return this;
}
}
const p = new Presupuesto(500);
p.anadir('Tinta serigráfica', 120).anadir('Papel de encuadernación', 80);
p.lineas.push({ concepto: 'Torno nuevo', importe: 9000 }); // muta una copia
console.log(p.total, p.disponible); // 200 300 ← el presupuesto no se enteró
try { p.anadir('Torno nuevo', 9000); }
catch (e) { console.error(e.message); } // Se supera el límite: quedan 300 € y se piden 9000 €.Fíjate en get lineas(): no basta con [...this.#lineas], porque eso copiaría el array pero compartiría los objetos de dentro —la copia superficial de 04-08—, y alguien podría hacer p.lineas[0].importe = 9000. Por eso se usa map((l) => ({ ...l })), que copia también cada línea. Es la profundidad justa: un nivel más porque las líneas son objetos planos.
Ejercicio 3
'use strict';
class Tarea {
// …campos privados y métodos del apartado 6…
toJSON() {
return {
id: this.id, titulo: this.titulo, responsable: this.responsable,
prioridad: this.prioridad, estado: this.#estado,
etiquetas: [...this.#etiquetas], horasEstimadas: this.#horas,
fechaLimite: this.fechaLimite, revisor: this.revisor
};
}
static desdeJSON(datos) {
return new Tarea(typeof datos === 'string' ? JSON.parse(datos) : datos);
}
clonar() {
return Tarea.desdeJSON(this.toJSON());
}
}
const original = new Tarea({
id: 1, titulo: 'Rediseñar la sala polivalente', responsable: 'Iván',
prioridad: 'alta', estado: 'en-curso', etiquetas: ['espacio', 'diseño'],
horasEstimadas: 12, fechaLimite: '2026-09-30', revisor: 'Marta'
});
// (a) Sin toJSON se perderían tres campos. Simulándolo con el spread:
console.log(Object.keys({ ...original }));
// [ 'id', 'titulo', 'responsable', 'prioridad', 'fechaLimite', 'revisor' ]
// faltan: estado, etiquetas, horasEstimadas
// (b) Con toJSON, ida y vuelta completo
const texto = JSON.stringify(original);
const vuelta = Tarea.desdeJSON(texto);
console.log(vuelta.estado, vuelta.horasEstimadas, vuelta.etiquetas);
// 'en-curso' 12 [ 'espacio', 'diseño' ]
console.log(vuelta instanceof Tarea); // true
// (c) El clon es independiente
const copia = original.clonar();
copia.cambiarEstado('hecha');
console.log(original.estado, copia.estado); // 'en-curso' 'hecha'
copia.etiquetas = ['espacio'];
console.log(original.etiquetas); // [ 'espacio', 'diseño' ] ← intactas
// (d) structuredClone no puede con los privados
try { structuredClone(original); }
catch (e) { console.error(e.name); } // 'DataCloneError'Tres conclusiones de este ejercicio. La primera, que el spread {...tarea} de 04-07 no ve lo privado, exactamente igual que JSON.stringify: la razón es la misma, ambos operan sobre propiedades propias enumerables. La segunda, que clonar() implementado como desdeJSON(toJSON()) es una copia profunda que además revalida: si el original tuviera datos corruptos, el clon fallaría al construirse, lo cual es una ventaja, no un inconveniente. Y la tercera, que structuredClone deja de ser una opción en cuanto hay clases con privados, lo que coloca al par toJSON/desdeJSON como el mecanismo de copia y persistencia oficial del proyecto.
Conclusión
Has convertido una recomendación en una barrera. Al empezar la lección, tarea.estado = 'hecha' y tarea.horasEstimadas = 500 atravesaban toda la validación del 05-02 sin resistencia; ahora una lanza TypeError porque la propiedad solo tiene getter, y la otra lanza ErrorDeValidacion desde el setter. El estado real vive en #estado, #horas y #etiquetas, campos que ni siquiera se pueden nombrar desde fuera de la clase —el error es sintáctico, no de ejecución—, y las reglas R2, R3, R5, R6, R8 y R9 están escritas cada una en un solo sitio, en el camino por el que obligatoriamente pasa cualquier valor.
Tienes las cuatro herramientas y sabes cuándo usar cada una. get para propiedades calculadas que nunca se desincronizan (abierta, diasRestantes, horasAbiertas, esfuerzo), con el criterio de reservarlo para lo barato y sin argumentos y dejar los métodos para las acciones y los cálculos caros. set para interceptar las asignaciones y validar antes de guardar, recordando siempre que el almacén interno debe llamarse distinto para no caer en la recursión infinita, y que el constructor debe asignar a través del setter para no duplicar la regla. #campo, #metodo() y static #campo para la privacidad real, con el brand check #estado in objeto como comprobación de autenticidad que instanceof no puede ofrecer. Y Object.defineProperty con writable, enumerable y configurable para las propiedades de solo lectura como el id, sabiendo que los tres atributos valen false por defecto y que ahí está el origen de la mitad de las sorpresas.
Sabes además de dónde viene todo esto: la convención _campo que no protegía nada, los closures del patrón módulo de 03-04 que sí protegían pero renunciaban al prototipo, y el WeakMap externo de las librerías anteriores a 2022. Has diseñado la API pública de Tablero decidiendo conscientemente qué se ve —resumen, horasPorResponsable, filtrar, agregar, cambiarEstado— y qué se esconde —el array #tareas y el método #comprobarCarga—, con copias defensivas en los getters para que ni siquiera un push sobre lo devuelto pueda romper los invariantes. Y has pagado y resuelto el precio de la privacidad: como los campos # son invisibles a JSON.stringify, al spread y a structuredClone, el par toJSON / desdeJSON se convierte en la puerta oficial de entrada y salida de datos, con revalidación incluida y con el resumen canónico intacto tras el viaje: 48 h, 45 abiertas, 1 vencida, esfuerzo 124.
El modelo de Nómada Tareas está ya completo y protegido. Pero sigue viviendo entero en un solo fichero: Tarea, TareaRecurrente, Tablero, ErrorDeValidacion, las constantes, el backlog de ejemplo y el código que lo ejecuta, todo mezclado y creciendo sin freno. Con <script> sueltos, además, cada nombre que declaras compite con todos los demás en el mismo espacio global, y el orden de carga se convierte en una dependencia implícita que nadie ha escrito en ninguna parte. Ordenar eso —separar el proyecto en ficheros con fronteras explícitas, que declaran qué exponen y qué necesitan— es el tema de Módulos e Importación/Exportación.
Curso de JavaScript: De Principiante a Avanzado
Módulo 1: Introducción a JavaScript
- ¿Qué es JavaScript?
- Configuración de tu Entorno de Desarrollo
- Tu Primer Programa en JavaScript
- Sintaxis y Conceptos Básicos de JavaScript
- Variables y Tipos de Datos
- Operadores Básicos
- Conversión de Tipos y Comparaciones
- El Proyecto del Curso: Nómada Tareas
Módulo 2: Estructuras de Control
- Sentencias Condicionales
- Bucles: for, while, do-while
- Sentencias Switch
- Control del Flujo: break, continue y Bucles Anidados
- Manejo de Errores con try-catch
Módulo 3: Funciones
- Definición y Llamada de Funciones
- Expresiones de Función y Funciones Flecha
- Parámetros y Valores de Retorno
- Ámbito y Closures
- Hoisting y el Contexto de Ejecución
- Funciones de Orden Superior
- Recursividad
Módulo 4: Objetos y Arrays
- Introducción a los Objetos
- Métodos de Objeto y la Palabra Clave
this - Arrays: Conceptos Básicos y Métodos
- Iteración sobre Arrays
- Buscar, Ordenar y Agregar Datos: find, sort y reduce
- Desestructuración de Arrays
- Desestructuración de Objetos, Spread y Rest
- JSON y Copias de Objetos
Módulo 5: Objetos y Funciones Avanzadas
- Prototipos y Herencia
- Clases y Programación Orientada a Objetos
- Encapsulación: Getters, Setters y Campos Privados
- Módulos e Importación/Exportación
- JavaScript Asíncrono: Callbacks
- Promesas y Async/Await
- El Bucle de Eventos y la Cola de Microtareas
- Iteradores y Generadores
Módulo 6: El Modelo de Objetos del Documento (DOM)
- Introducción al DOM
- Selección y Manipulación de Elementos del DOM
- Manejo de Eventos
- Propagación, Delegación y Eventos Personalizados
- Creación y Eliminación de Elementos del DOM
- Renderizado de Listas y Plantillas HTML
- Manejo y Validación de Formularios
Módulo 7: APIs del Navegador y Temas Avanzados
- Almacenamiento Local y de Sesión
- Fetch API y AJAX
- Peticiones Robustas: Errores, Timeouts y AbortController
- WebSockets
- Service Workers y Aplicaciones Web Progresivas (PWAs)
- APIs del Navegador Esenciales
- Introducción a WebAssembly
Módulo 8: Pruebas y Depuración
- Depuración de JavaScript
- Calidad de Código: ESLint, Prettier y Convenciones
- Pruebas Unitarias con Jest
- Dobles de Prueba: Mocks, Stubs y Spies
- Pruebas de Integración
- Pruebas de Extremo a Extremo con Cypress
Módulo 9: Rendimiento y Optimización
- Medir Antes de Optimizar: DevTools y Web Vitals
- Optimización del Rendimiento de JavaScript
- Gestión de Memoria
- Manipulación Eficiente del DOM
- Carga Perezosa y División de Código
Módulo 10: Frameworks y Librerías de JavaScript
- Por Qué Existen los Frameworks
- Introducción a React
- Gestión de Estado con Redux
- Conceptos Básicos de Vue.js
- Conceptos Básicos de Angular
- Elegir el Framework Adecuado
