La lección anterior terminó con una herencia funcionando, pero también con una lista de incomodidades: tres líneas de ritual (Padre.call(this, …), Object.create(Padre.prototype), restaurar constructor) por cada relación entre constructores, métodos declarados lejos del constructor con un Constructor.prototype.metodo = function … por línea, y ningún mecanismo que impidiera olvidar el new. Desde ES2015 el lenguaje ofrece la palabra clave class, que resuelve todo eso sin cambiar el mecanismo: por debajo siguen siendo funciones constructoras y objetos prototipo, exactamente los del 05-01. En esta lección aprenderás la sintaxis completa —constructor, métodos, campos, static, extends, super—, reescribirás el modelo de Nómada Tareas como class Tarea, class TareaRecurrente y class Tablero, verás los cuatro pilares de la programación orientada a objetos aterrizados en ese código, entenderás por qué casi siempre conviene componer antes que heredar, y por fin comprenderás del todo aquel class ErrorDeValidacion extends Error que arrastras como receta desde 02-05.
Contenido
- La misma clase, en las dos sintaxis
constructory métodos de instancia- Campos de clase
- Métodos y campos
static - La clase
Tareadel proyecto extendsysuper: herencia sin ceremonia- Sobrescritura y polimorfismo
- La clase
Tablero - Los cuatro pilares de la POO
- Composición frente a herencia
class ErrorDeValidacion extends Error, explicado del todo- Detalles del lenguaje que las clases cambian
- Errores Comunes y Consejos
- Ejercicios
- Conclusión
- La misma clase, en las dos sintaxis
La mejor forma de ver qué aporta class es poner las dos versiones lado a lado. A la izquierda, el constructor de la lección anterior; a la derecha, su equivalente exacto.
// ── SINTAXIS DE CONSTRUCTOR (05-01) ──────────────────────────────
function Tarea(datos) {
this.id = datos.id;
this.titulo = datos.titulo;
this.estado = datos.estado ?? 'pendiente';
}
Tarea.prototype.descripcion = function () {
return `[${this.id}] ${this.titulo}`;
};
Tarea.prototype.estaVencida = function (hoy) {
return this.fechaLimite < hoy && this.estado !== 'hecha';
};// ── SINTAXIS DE CLASE (ES2015) ───────────────────────────────────
class Tarea {
constructor(datos) {
this.id = datos.id;
this.titulo = datos.titulo;
this.estado = datos.estado ?? 'pendiente';
}
descripcion() {
return `[${this.id}] ${this.titulo}`;
}
estaVencida(hoy) {
return this.fechaLimite < hoy && this.estado !== 'hecha';
}
}Y esto es lo importante: el resultado es el mismo mecanismo.
console.log(typeof Tarea); // 'function' ← sigue siendo una función
const t = new Tarea({ id: 1, titulo: 'Rediseñar la sala polivalente' });
console.log(Object.getPrototypeOf(t) === Tarea.prototype); // true
console.log(Object.hasOwn(t, 'descripcion')); // false ← vive en el prototipo
console.log(Object.hasOwn(Tarea.prototype, 'descripcion')); // trueUna
classes azúcar sintáctico sobre las funciones constructoras y los prototipos. No es un sistema de objetos distinto: es la misma cadena de prototipos escrita de forma legible, con varias correcciones aplicadas de serie.
Esas correcciones, que iremos viendo, son las de esta tabla:
| Aspecto | Constructor | class |
|---|---|---|
| Dónde se declaran los métodos | Sueltos, uno por línea | Dentro del bloque, junto al constructor |
| ¿Los métodos son enumerables? | Sí (contaminan for...in) |
No, nunca |
Llamar sin new |
Silencioso o TypeError confuso |
TypeError explícito e inevitable |
| Herencia | 3 líneas de ritual | extends |
| Llamar al padre | Padre.prototype.m.call(this) |
super.m() |
constructor correcto |
Hay que restaurarlo a mano | Automático |
| Modo estricto | Hay que pedirlo | Siempre activo dentro de la clase |
| Hoisting | Se puede usar antes de declararla | No: hay TDZ, como con let |
Las clases también existen como expresión, igual que las funciones de 03-02:
const Tarea = class { /* … */ }; // expresión de clase anónima
const Tarea = class TareaInterna { /* … */ }; // con nombre, visible solo dentroEn la práctica casi siempre se usa la declaración.
constructor y métodos de instancia
constructor y métodos de instanciaEl bloque constructor es la función que se ejecuta al hacer new. Es el paso 3 de los cuatro que desmontaste en 05-01: recibe los argumentos y rellena el objeto nuevo a través de this.
'use strict';
class Persona {
constructor(nombre, rol) {
this.nombre = nombre;
this.rol = rol;
}
presentarse() { // método de instancia → va al prototipo
return `${this.nombre} (${this.rol})`;
}
}
const marta = new Persona('Marta', 'coordinadora');
console.log(marta.presentarse()); // 'Marta (coordinadora)'Reglas del constructor:
- Solo puede haber uno por clase. JavaScript no tiene sobrecarga de constructores; si necesitas varias formas de construir, se usan métodos
staticde fábrica (apartado 4). - Es opcional. Si no lo escribes, se usa uno vacío por defecto (o uno que llama a
super(...args)si la clase hereda). - No lleva la palabra
functionni coma detrás. Dentro del cuerpo de una clase, los miembros se separan por saltos de línea, no por comas: es un error muy típico al venir de los objetos literales del Módulo 4.
Los métodos de instancia se escriben con la sintaxis abreviada que ya conoces de 04-02, y —esto es lo esencial— se colocan automáticamente en el prototipo, así que todas las instancias comparten una sola copia:
const ivan = new Persona('Iván', 'diseñador');
console.log(marta.presentarse === ivan.presentarse); // true
- Campos de clase
Además del constructor, una clase puede declarar campos: propiedades de instancia con un valor inicial, escritas directamente en el cuerpo.
'use strict';
class Contador {
valor = 0; // campo de instancia con valor inicial
historial = []; // ¡cuidado! ver la nota de abajo
incrementar() {
this.valor++;
this.historial.push(this.valor);
return this.valor;
}
}
const c1 = new Contador();
const c2 = new Contador();
c1.incrementar();
c1.incrementar();
console.log(c1.valor, c2.valor); // 2 0
console.log(c1.historial, c2.historial); // [1, 2] []Un campo se ejecuta una vez por instancia, justo al principio del constructor. Por eso c1.historial y c2.historial son arrays distintos: no se comparte nada. Esto contrasta con el aviso de 05-01 sobre no poner arrays en el prototipo, donde sí se compartirían. Los campos son propiedades propias:
console.log(Object.hasOwn(c1, 'valor')); // true ← campo: propiedad propia
console.log(Object.hasOwn(c1, 'incrementar')); // false ← método: en el prototipoLos campos son especialmente útiles para valores por defecto que no dependen de los argumentos:
class Tarea {
etiquetas = [];
revisor = null;
estado = 'pendiente'; // R5: toda tarea nace pendiente
constructor(id, titulo) {
this.id = id;
this.titulo = titulo;
}
}| Dónde declarar la propiedad | Cuándo |
|---|---|
Campo (estado = 'pendiente';) |
Valor inicial fijo, independiente de los argumentos |
Constructor (this.id = datos.id;) |
El valor viene de los argumentos o requiere cálculo |
Los campos se inicializan antes del cuerpo del constructor, así que este puede sobrescribirlos sin problema.
- Métodos y campos
static
staticUn miembro static pertenece a la clase, no a las instancias. Se accede como Clase.miembro, y dentro de un método estático this es la propia clase.
'use strict';
class Tarea {
static ESTADOS = ['pendiente', 'en-curso', 'hecha']; // campo estático (constante compartida)
static creadas = 0; // campo estático (contador)
constructor(id, titulo) {
this.id = id;
this.titulo = titulo;
Tarea.creadas++; // se actualiza el contador de la clase
}
static esEstadoValido(estado) { // método estático
return Tarea.ESTADOS.includes(estado);
}
}
new Tarea(1, 'Rediseñar la sala polivalente');
new Tarea(2, 'Cartelería del taller de serigrafía');
console.log(Tarea.creadas); // 2
console.log(Tarea.esEstadoValido('en-curso')); // true
console.log(Tarea.esEstadoValido('cancelada')); // false
const t = new Tarea(3, 'Actualizar la web de reservas');
// console.log(t.esEstadoValido('hecha')); // ✗ TypeError: no es un método de instanciaEsa última línea comentada es la diferencia clave: los estáticos no se heredan por las instancias, viven en el objeto función.
El uso más valioso de los estáticos son las fábricas: métodos que construyen instancias de formas alternativas, supliendo la falta de sobrecarga de constructores. Retomando el JSON de 04-08:
'use strict';
class Tarea {
constructor(datos) {
this.id = datos.id;
this.titulo = datos.titulo;
this.responsable = datos.responsable ?? null;
this.prioridad = datos.prioridad ?? 'media';
this.estado = datos.estado ?? 'pendiente';
this.etiquetas = datos.etiquetas ?? [];
this.horasEstimadas = datos.horasEstimadas;
this.fechaLimite = datos.fechaLimite;
this.revisor = datos.revisor ?? null;
}
/** Construye una Tarea a partir de un texto JSON. */
static desdeJSON(texto) {
let datos;
try {
datos = JSON.parse(texto);
} catch (error) {
throw new Error(`JSON inválido: ${error.message}`);
}
return new Tarea(datos);
}
/** Construye una tarea rápida con los mínimos imprescindibles. */
static rapida(titulo, responsable) {
return new Tarea({ id: Tarea.siguienteId(), titulo, responsable, horasEstimadas: 1, fechaLimite: '2026-12-31' });
}
static ultimoId = 6; // el backlog canónico llega hasta el 6
static siguienteId() {
Tarea.ultimoId += 1;
return Tarea.ultimoId;
}
}
const desdeTexto = Tarea.desdeJSON('{"id":6,"titulo":"Presupuesto de la carpintería","horasEstimadas":5,"fechaLimite":"2026-09-05"}');
console.log(desdeTexto instanceof Tarea); // true
console.log(desdeTexto.estado); // 'pendiente' ← el defecto se aplica igual
const rapida = Tarea.rapida('Comprar tinta negra', 'Marta');
console.log(rapida.id); // 7Ese Tarea.siguienteId() es el mismo generador de identificadores que en 03-04 resolviste con un closure. Aquí el estado vive en un campo estático en lugar de en una variable capturada; en 05-03 verás cómo protegerlo para que nadie pueda escribir Tarea.ultimoId = 0 desde fuera.
- La clase
Tarea del proyecto
Tarea del proyectoCon la sintaxis cubierta, esta es la versión definitiva del modelo, con las reglas de negocio del proyecto dentro. Presta especial atención a cambiarEstado, que aplica la R6 (solo las transiciones permitidas).
'use strict';
const PESOS = { alta: 3, media: 2, baja: 1 };
const MARCAS = { pendiente: '○', 'en-curso': '▸', hecha: '✓' };
class Tarea {
// ── Constantes de la clase ──────────────────────────────────────
static ESTADOS = ['pendiente', 'en-curso', 'hecha'];
static TRANSICIONES = { // R6: el diagrama de estados del 01-08
pendiente: ['en-curso'],
'en-curso': ['pendiente', 'hecha'],
hecha: []
};
static MAX_HORAS = 40; // R3 y R7
// ── Campos con valor por defecto ────────────────────────────────
estado = 'pendiente'; // R5
etiquetas = [];
revisor = null;
constructor(datos) {
if (typeof datos.titulo !== 'string' || datos.titulo.trim() === '') {
throw new ErrorDeValidacion('El título no puede estar vacío.', 'titulo', datos.titulo); // R2
}
if (!(datos.horasEstimadas > 0 && datos.horasEstimadas <= Tarea.MAX_HORAS)) {
throw new ErrorDeValidacion(`Las horas deben estar entre 1 y ${Tarea.MAX_HORAS}.`, 'horasEstimadas', datos.horasEstimadas); // R3
}
this.id = datos.id;
this.titulo = datos.titulo.trim();
this.responsable = datos.responsable ?? null; // R8
this.prioridad = datos.prioridad ?? 'media';
this.horasEstimadas = datos.horasEstimadas;
this.fechaLimite = datos.fechaLimite;
if (datos.estado !== undefined) this.estado = datos.estado;
if (datos.etiquetas !== undefined) {
this.etiquetas = [...new Set(datos.etiquetas.map((e) => e.trim().toLowerCase()))]; // R9
}
if (datos.revisor !== undefined) this.revisor = datos.revisor;
}
// ── Consultas ───────────────────────────────────────────────────
estaVencida(hoy) {
return this.fechaLimite < hoy && this.estado !== 'hecha'; // R10
}
estaAbierta() {
return this.estado !== 'hecha';
}
esfuerzo() {
return (PESOS[this.prioridad] ?? 0) * this.horasEstimadas;
}
descripcion(hoy) {
const marca = MARCAS[this.estado] ?? '?';
const aviso = hoy !== undefined && this.estaVencida(hoy) ? ' ⚠ VENCIDA' : '';
return `${marca} [${this.id}] ${this.titulo} · ${this.responsable ?? 'sin asignar'} · ${this.horasEstimadas} h${aviso}`;
}
// ── Modificación con reglas ─────────────────────────────────────
cambiarEstado(nuevo) {
if (!Tarea.ESTADOS.includes(nuevo)) {
throw new ErrorDeValidacion(`Estado desconocido: "${nuevo}".`, 'estado', nuevo);
}
const permitidos = Tarea.TRANSICIONES[this.estado];
if (!permitidos.includes(nuevo)) { // R6
throw new ErrorDeValidacion(
`Transición no permitida: "${this.estado}" → "${nuevo}". Permitidas: ${permitidos.join(', ') || 'ninguna'}.`,
'estado', nuevo
);
}
this.estado = nuevo;
return this; // interfaz fluida (04-02)
}
toJSON() { // retomando 04-08
return { ...this };
}
toString() { // retomando 05-01
return `Tarea#${this.id} «${this.titulo}»`;
}
}Y en uso:
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.etiquetas); // [ 'carpintería', 'compras' ] ← R9 aplicada
console.log(carpinteria.descripcion('2026-09-20')); // ○ [6] Presupuesto de la carpintería · Iván · 5 h ⚠ VENCIDA
console.log(carpinteria.esfuerzo()); // 15
carpinteria.cambiarEstado('en-curso'); // ✓ pendiente → en-curso
console.log(carpinteria.estado); // 'en-curso'
try {
const otra = new Tarea({ id: 9, titulo: 'Nueva', horasEstimadas: 4, fechaLimite: '2026-10-01' });
otra.cambiarEstado('hecha'); // ✗ pendiente → hecha no existe (R6)
} catch (error) {
console.error(`${error.name} en «${error.campo}»: ${error.message}`);
// ErrorDeValidacion en «estado»: Transición no permitida: "pendiente" → "hecha". Permitidas: en-curso.
}Lo que has conseguido con esto es enorme y conviene decirlo explícitamente: ya no es posible tener una tarea en un estado inválido. En el Módulo 4, cualquier línea del programa podía escribir tarea.estado = 'hecha' y saltarse la R6. Ahora existe un método que es el camino oficial. Todavía se puede rodear escribiendo directamente en la propiedad —ese agujero lo cierras en 05-03—, pero la intención del diseño ya está expresada en el código.
extends y super: herencia sin ceremonia
extends y super: herencia sin ceremoniaToda la ceremonia del apartado 12 de la lección anterior se reduce a una palabra.
'use strict';
class TareaRecurrente extends Tarea {
static PERIODOS = { semanal: 7, quincenal: 14, mensual: 30 };
constructor(datos) {
super(datos); // ← equivale a Tarea.call(this, datos)
this.periodicidad = datos.periodicidad ?? 'semanal';
this.repeticiones = 0;
}
proximaFecha() {
const dias = TareaRecurrente.PERIODOS[this.periodicidad] ?? 7;
const base = new Date(this.fechaLimite);
base.setDate(base.getDate() + dias);
return base.toISOString().slice(0, 10);
}
descripcion(hoy) {
return `${super.descripcion(hoy)} · se repite ${this.periodicidad}`; // ← llama a la versión del padre
}
cambiarEstado(nuevo) {
super.cambiarEstado(nuevo); // valida con las reglas del padre
if (nuevo === 'hecha') { // …y añade su propio comportamiento
this.repeticiones += 1;
this.fechaLimite = this.proximaFecha();
this.estado = 'pendiente'; // una recurrente vuelve a empezar
}
return this;
}
}
const recogida = new TareaRecurrente({
id: 8, titulo: 'Recoger el material de serigrafía', responsable: 'Marta',
prioridad: 'baja', horasEstimadas: 1, fechaLimite: '2026-09-25', periodicidad: 'semanal'
});
console.log(recogida.descripcion());
// ○ [8] Recoger el material de serigrafía · Marta · 1 h · se repite semanal
recogida.cambiarEstado('en-curso').cambiarEstado('hecha');
console.log(recogida.fechaLimite, recogida.estado, recogida.repeticiones);
// '2026-10-02' 'pendiente' 1
console.log(recogida instanceof TareaRecurrente); // true
console.log(recogida instanceof Tarea); // true
console.log(TareaRecurrente.MAX_HORAS); // 40 ← los estáticos SÍ se heredan entre clasessuper tiene dos usos, y conviene no mezclarlos:
| Forma | Dónde se usa | Qué hace |
|---|---|---|
super(args) |
Solo en el constructor |
Ejecuta el constructor de la clase padre sobre this |
super.metodo(args) |
En cualquier método | Llama a la versión del padre del método, con this intacto |
Y una regla que el lenguaje impone con dureza: en una clase con extends, hay que llamar a super(...) antes de usar this.
class Rota extends Tarea {
constructor(datos) {
this.extra = 1; // ✗ ReferenceError: Must call super constructor… before accessing 'this'
super(datos);
}
}El motivo es coherente con lo que sabes de 05-01: es la llamada a super la que crea e inicializa el objeto de la parte padre. Antes de ella, this literalmente no existe todavía. Si no escribes ningún constructor en la clase hija, JavaScript genera uno implícito equivalente a constructor(...args) { super(...args); }, que es justo lo que quieres el 80 % de las veces.
La cadena resultante es idéntica a la que dibujaste a mano en 05-01, pero montada por el lenguaje:
flowchart TD
R["recogida<br/>{ id: 8, periodicidad, repeticiones… }"] -->|"[[Prototype]]"| TRP["TareaRecurrente.prototype<br/>{ proximaFecha, descripcion, cambiarEstado }"]
TRP -->|"[[Prototype]]"| TP["Tarea.prototype<br/>{ estaVencida, esfuerzo, descripcion, cambiarEstado… }"]
TP -->|"[[Prototype]]"| OP["Object.prototype"]
OP -->|"[[Prototype]]"| N["null"]
TRC["TareaRecurrente<br/>(la clase)"] -.->|"[[Prototype]] · así se heredan<br/>los miembros static"| TC["Tarea<br/>(la clase)"]
Fíjate en la flecha punteada: extends enlaza dos cadenas, la de los prototipos (para los métodos de instancia) y la de las propias clases (para los miembros static). Por eso TareaRecurrente.MAX_HORAS funciona sin haberlo declarado.
- Sobrescritura y polimorfismo
Cuando la clase hija define un método con el mismo nombre que el padre, lo sobrescribe: al buscar en la cadena se encuentra antes el del hijo. Eso ya lo viste. Lo nuevo es la consecuencia de diseño: el polimorfismo.
Polimorfismo: código que llama al mismo método sobre objetos de clases distintas y obtiene el comportamiento adecuado a cada uno, sin
ifque pregunten de qué clase se trata.
'use strict';
class TareaBloqueada extends Tarea {
constructor(datos) {
super(datos);
this.motivo = datos.motivo ?? 'sin especificar';
}
descripcion(hoy) {
return `⛔ ${super.descripcion(hoy)} · BLOQUEADA (${this.motivo})`;
}
cambiarEstado() {
throw new ErrorDeValidacion('Una tarea bloqueada no puede cambiar de estado.', 'estado', this.estado);
}
}
const mezcla = [
new Tarea({ id: 1, titulo: 'Rediseñar la sala polivalente', responsable: 'Iván', prioridad: 'alta', estado: 'en-curso', horasEstimadas: 12, fechaLimite: '2026-09-30' }),
new TareaRecurrente({ id: 8, titulo: 'Recoger el material de serigrafía', responsable: 'Marta', prioridad: 'baja', horasEstimadas: 1, fechaLimite: '2026-09-25' }),
new TareaBloqueada({ id: 9, titulo: 'Instalar el torno nuevo', responsable: 'Lucía', prioridad: 'media', horasEstimadas: 6, fechaLimite: '2026-10-10', motivo: 'falta el permiso del propietario' })
];
for (const tarea of mezcla) {
console.log(tarea.descripcion('2026-09-20')); // ← la MISMA llamada
}
// ▸ [1] Rediseñar la sala polivalente · Iván · 12 h
// ○ [8] Recoger el material de serigrafía · Marta · 1 h · se repite semanal
// ⛔ ○ [9] Instalar el torno nuevo · Lucía · 6 h · BLOQUEADA (falta el permiso del propietario)Ese bucle no contiene un solo if sobre el tipo. Compáralo con la alternativa que habrías escrito en el Módulo 2:
// ✗ Sin polimorfismo: cada clase nueva obliga a tocar este if
for (const tarea of mezcla) {
if (tarea.tipo === 'recurrente') console.log(descripcionRecurrente(tarea));
else if (tarea.tipo === 'bloqueada') console.log(descripcionBloqueada(tarea));
else console.log(descripcionNormal(tarea));
}La versión polimórfica no se toca nunca al añadir clases. Esa es toda la ganancia, y es la razón principal por la que existe la herencia.
- La clase
Tablero
TableroAhora el otro objeto grande del proyecto: el tablero que en 04-02 era un objeto literal, convertido en clase. Reúne el backlog y las operaciones de 04-05 en una sola pieza.
'use strict';
const HOY = '2026-09-20';
class Tablero {
constructor(nombre, tareas = []) {
this.nombre = nombre;
this.tareas = [...tareas]; // copia: nadie de fuera muta nuestro array (04-08)
}
// ── Consultas ───────────────────────────────────────────────────
buscarPorId(id) {
return this.tareas.find((t) => t.id === id) ?? null;
}
filtrar(predicado) {
return this.tareas.filter(predicado);
}
abiertas() {
return this.tareas.filter((t) => t.estaAbierta());
}
horasPorResponsable() {
return this.abiertas().reduce((acc, t) => {
const clave = t.responsable ?? 'sin asignar';
acc[clave] = (acc[clave] ?? 0) + t.horasEstimadas;
return acc;
}, {});
}
// ── Modificación ────────────────────────────────────────────────
agregar(tarea) {
if (!(tarea instanceof Tarea)) {
throw new ErrorDeValidacion('Solo se pueden agregar instancias de Tarea.', 'tarea', tarea);
}
if (this.buscarPorId(tarea.id) !== null) {
throw new ErrorDeValidacion(`Ya existe una tarea con id ${tarea.id}.`, 'id', tarea.id); // R1
}
const carga = this.horasPorResponsable()[tarea.responsable] ?? 0;
if (tarea.estaAbierta() && carga + tarea.horasEstimadas > Tarea.MAX_HORAS) { // R7
throw new ErrorDeValidacion(
`${tarea.responsable} llegaría a ${carga + tarea.horasEstimadas} h, por encima del máximo de ${Tarea.MAX_HORAS}.`,
'horasEstimadas', tarea.horasEstimadas
);
}
this.tareas.push(tarea);
return this;
}
cambiarEstado(id, nuevo) {
const tarea = this.buscarPorId(id);
if (tarea === null) throw new ErrorDeValidacion(`No hay ninguna tarea con id ${id}.`, 'id', id);
tarea.cambiarEstado(nuevo); // delega la R6 en la propia tarea (polimorfismo incluido)
return this;
}
// ── Agregación ──────────────────────────────────────────────────
resumen(hoy = HOY) {
const abiertas = this.abiertas();
return {
total: this.tareas.length,
abiertas: abiertas.length,
horasTotales: this.tareas.reduce((s, t) => s + t.horasEstimadas, 0),
horasAbiertas: abiertas.reduce((s, t) => s + t.horasEstimadas, 0),
vencidas: abiertas.filter((t) => t.estaVencida(hoy)).length,
esfuerzo: this.tareas.reduce((s, t) => s + t.esfuerzo(), 0)
};
}
pintar(hoy = HOY) {
return [`— ${this.nombre} —`, ...this.tareas.map((t) => t.descripcion(hoy))].join('\n');
}
}Con el backlog canónico:
const datosBacklog = [
{ 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' },
{ id: 2, titulo: 'Cartelería del taller de serigrafía', responsable: 'Marta', prioridad: 'media', estado: 'pendiente', etiquetas: ['serigrafía', 'comunicación'], horasEstimadas: 6, fechaLimite: '2026-10-15', revisor: null },
{ id: 3, titulo: 'Actualizar la web de reservas', responsable: 'Lucía', prioridad: 'alta', estado: 'pendiente', etiquetas: ['web', 'reservas'], horasEstimadas: 14, fechaLimite: '2026-10-02', revisor: 'Iván' },
{ id: 4, titulo: 'Inventario de tintas de serigrafía', responsable: 'Marta', prioridad: 'baja', estado: 'hecha', etiquetas: ['serigrafía', 'almacén'], horasEstimadas: 3, fechaLimite: '2026-09-12', revisor: null },
{ id: 5, titulo: 'Guía de encuadernación para residentes', responsable: 'Iván', prioridad: 'media', estado: 'en-curso', etiquetas: ['encuadernación', 'documentación'], horasEstimadas: 8, fechaLimite: '2026-11-05', revisor: 'Lucía' },
{ id: 6, titulo: 'Presupuesto de la carpintería', responsable: 'Iván', prioridad: 'alta', estado: 'pendiente', etiquetas: ['carpintería', 'compras'], horasEstimadas: 5, fechaLimite: '2026-09-05', revisor: 'Marta' }
];
const tablero = new Tablero('Taller Nómada', datosBacklog.map((d) => new Tarea(d)));
console.log(tablero.resumen());
// { 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 cinco números canónicos del proyecto —48, 45, 1 vencida, 124 de esfuerzo, y el reparto Iván 25 / Lucía 14 / Marta 6— salen ahora de una sola llamada a un objeto que sabe responder por sí mismo. Y la R7 funciona de verdad:
try {
tablero.agregar(new Tarea({ id: 10, titulo: 'Montar la exposición de otoño', responsable: 'Iván',
prioridad: 'alta', horasEstimadas: 20, fechaLimite: '2026-10-30' }));
} catch (error) {
console.error(error.message);
// Iván llegaría a 45 h, por encima del máximo de 40 h.
}
- Los cuatro pilares de la POO
Con Tarea, TareaRecurrente, TareaBloqueada y Tablero delante, los conceptos clásicos de la programación orientada a objetos dejan de ser abstractos.
| Pilar | Qué significa | Dónde está en Nómada Tareas |
|---|---|---|
| Abstracción | Exponer qué hace algo y esconder cómo | Quien usa tablero.resumen() no sabe si por dentro hay reduce, un bucle o una caché |
| Encapsulación | Los datos y las operaciones que los gobiernan viajan juntos, y el estado solo se cambia por caminos controlados | cambiarEstado aplica la R6; agregar aplica R1 y R7. Se completará en 05-03 con #estado |
| Herencia | Una clase reutiliza y especializa el comportamiento de otra | TareaRecurrente extends Tarea reaprovecha validación, esfuerzo y vencimiento |
| Polimorfismo | La misma llamada produce el comportamiento propio de cada clase | El bucle del apartado 7: un solo tarea.descripcion(hoy) para tres clases |
Merece la pena insistir en la diferencia entre abstracción y encapsulación, porque se confunden constantemente. La abstracción es una decisión de diseño de la interfaz: qué operaciones ofrezco. La encapsulación es un mecanismo de protección: qué impido que se toque. Puedes tener una interfaz bien abstraída y encapsulación nula —es exactamente el estado actual de Tarea, donde cambiarEstado existe pero nadie impide tarea.estado = 'hecha'—, y de ahí que la lección siguiente esté dedicada por entero a cerrar ese hueco.
- Composición frente a herencia
La herencia es potente y por eso se abusa de ella. Imagina que Marta pide tres cosas más: que una tarea pueda ser exportable a CSV, que pueda notificar por correo y que pueda archivarse. Con herencia acabas en un árbol imposible: TareaRecurrenteExportableNotificable. Y si mañana una tarea bloqueada también tiene que ser exportable, no hay forma de expresarlo, porque una clase solo puede tener un padre.
La alternativa es la composición: en lugar de ser un, tener un.
'use strict';
// ── Piezas independientes, reutilizables por cualquier clase ──────
class ExportadorCSV {
constructor(columnas) {
this.columnas = columnas;
}
linea(objeto) {
return this.columnas.map((c) => String(objeto[c] ?? '')).join(';');
}
cabecera() {
return this.columnas.join(';');
}
}
class RegistroDeCambios {
constructor() { this.eventos = []; }
anotar(texto, momento) { this.eventos.push(`${momento} · ${texto}`); }
ultimo() { return this.eventos.at(-1) ?? null; }
}
// ── El tablero USA esas piezas, no las hereda ─────────────────────
class TableroAuditado extends Tablero {
constructor(nombre, tareas) {
super(nombre, tareas);
this.registro = new RegistroDeCambios(); // composición
this.exportador = new ExportadorCSV(['id', 'titulo', 'responsable', 'estado', 'horasEstimadas']); // composición
}
cambiarEstado(id, nuevo) {
super.cambiarEstado(id, nuevo);
this.registro.anotar(`Tarea ${id} → ${nuevo}`, HOY);
return this;
}
aCSV() {
return [this.exportador.cabecera(), ...this.tareas.map((t) => this.exportador.linea(t))].join('\n');
}
}
const auditado = new TableroAuditado('Taller Nómada', tablero.tareas);
auditado.cambiarEstado(2, 'en-curso');
console.log(auditado.registro.ultimo()); // '2026-09-20 · Tarea 2 → en-curso'
console.log(auditado.aCSV().split('\n')[1]); // '1;Rediseñar la sala polivalente;Iván;en-curso;12'ExportadorCSV y RegistroDeCambios no saben nada de tareas: sirven igual para un tablero, para un listado de residentes o para las reservas de la sala. Esa es la diferencia:
Herencia (extends) |
Composición (propiedades) | |
|---|---|---|
| Relación | «es un» | «tiene un» |
| Acoplamiento | Fuerte: el hijo depende de la implementación del padre | Débil: solo depende de una interfaz pequeña |
| Cuántas se pueden combinar | Una sola clase padre | Tantas piezas como quieras |
| Cambiar en tiempo de ejecución | Imposible | Trivial: sustituyes la pieza |
| Riesgo típico | Jerarquías profundas y frágiles | Algo más de código para delegar |
La regla que sigue casi todo el mundo:
Usa herencia cuando el hijo es realmente un caso especial del padre y comparte su contrato completo. Para todo lo demás, compón.
TareaRecurrente es herencia legítima: es una tarea en todos los sentidos, y donde encaje una Tarea encajará ella. ExportadorCSV no es una tarea ni un tablero: es una herramienta que se usa. Y si dudas, hay una prueba práctica: si al heredar te encuentras sobrescribiendo métodos del padre para que no hagan nada o para que lancen errores —como hizo TareaBloqueada con cambiarEstado—, es una señal de que la relación «es un» no era tan limpia como parecía.
class ErrorDeValidacion extends Error, explicado del todo
class ErrorDeValidacion extends Error, explicado del todoLlevas usando esta receta desde 02-05 sin entenderla. Ahora ya tienes todas las piezas.
'use strict';
class ErrorDeValidacion extends Error {
constructor(mensaje, campo, valorRecibido) {
super(mensaje); // 1 · el constructor de Error guarda el mensaje
this.name = 'ErrorDeValidacion'; // 2 · sobrescribe el name heredado ('Error')
this.campo = campo; // 3 · datos propios de nuestro error
this.valorRecibido = valorRecibido;
}
describir() { // 4 · nada impide añadir métodos
return `[${this.name}] ${this.campo}: ${this.message} (recibido: ${JSON.stringify(this.valorRecibido)})`;
}
}Línea por línea, con lo aprendido:
extends ErrorponeError.prototypeen la cadena, de donde vienenmessage,name,toStringy (en los motores actuales)stack. Por eso el error se imprime bien en consola y produce una traza como las de 03-05.super(mensaje)ejecuta el constructor deError, que es quien asignathis.message. Sin esa llamada el error existiría pero con el mensaje vacío —y además el lenguaje ni siquiera te dejaría llegar ahí, porque en una clase conextendshay que llamar asuperantes de tocarthis.this.name = 'ErrorDeValidacion'sombrea (05-01) la propiedadnameheredada deError.prototype, que vale'Error'. Es lo que hace que la consola imprima el nombre correcto.- Los campos extra (
campo,valorRecibido) son propiedades propias normales y corrientes: la ventaja de tener una clase de error propia es precisamente poder llevar información estructurada, en lugar de meterla en el texto del mensaje.
Y ahora la parte que hace que todo esto valga la pena: instanceof permite distinguir tipos de error en el catch, algo que en 02-05 hacías comparando error.name con un string.
class ErrorDeDatos extends Error { // el de 04-08
constructor(mensaje, causa) {
super(mensaje);
this.name = 'ErrorDeDatos';
this.causa = causa;
}
}
function procesar(texto) {
try {
const tarea = Tarea.desdeJSON(texto);
tarea.cambiarEstado('hecha');
return tarea;
} catch (error) {
if (error instanceof ErrorDeValidacion) {
console.error(error.describir());
return null; // recuperable: se avisa al usuario
}
if (error instanceof ErrorDeDatos) {
console.error(`Datos corruptos: ${error.message}`);
return null;
}
throw error; // desconocido: que suba (fail-fast de 02-05)
}
}
console.log(procesar('{"id":11,"titulo":"Barnizar las estanterías","horasEstimadas":4,"fechaLimite":"2026-10-08"}'));
// ErrorDeValidacion en… → null
// (pendiente → hecha no es una transición válida: R6)error instanceof ErrorDeValidacion es más robusto que error.name === 'ErrorDeValidacion' porque comprueba la cadena real de prototipos, no una cadena de texto que cualquiera podría escribir por accidente. Y como la herencia funciona, puedes crear familias de errores:
class ErrorDeRegla extends ErrorDeValidacion {
constructor(mensaje, campo, valorRecibido, regla) {
super(mensaje, campo, valorRecibido);
this.name = 'ErrorDeRegla';
this.regla = regla; // 'R6', 'R7'…
}
}
const e = new ErrorDeRegla('Transición no permitida.', 'estado', 'hecha', 'R6');
console.log(e instanceof ErrorDeRegla); // true
console.log(e instanceof ErrorDeValidacion); // true ← un catch genérico también lo atrapa
console.log(e instanceof Error); // trueUn catch que compruebe instanceof ErrorDeValidacion capturará también los ErrorDeRegla, sin tocar una línea. Eso es polimorfismo aplicado al manejo de errores.
- Detalles del lenguaje que las clases cambian
Cuatro diferencias de comportamiento que conviene tener claras.
Las clases no se elevan (hoisting). A diferencia de las declaraciones de función de 03-05, una clase está en la zona muerta temporal hasta su declaración:
Dentro de una clase siempre rige el modo estricto, aunque el fichero no lleve 'use strict'. Es una de esas correcciones gratis.
No se puede llamar sin new, ni por accidente:
Los métodos son no enumerables, así que ya no contaminan for...in, que era el problema del apartado 11 de 05-01:
class Ejemplo {
campo = 1;
metodo() {}
}
const e = new Ejemplo();
for (const k in e) console.log(k); // solo 'campo'
console.log(Object.keys(Ejemplo.prototype)); // [] ← el método está, pero no es enumerableUn aviso final que retoma 04-08: JSON.parse no devuelve instancias. Si serializas un Tablero y lo vuelves a leer, obtienes objetos planos sin métodos.
const copiaPlana = JSON.parse(JSON.stringify(tablero.tareas[0]));
console.log(copiaPlana.titulo); // 'Rediseñar la sala polivalente' ✓ datos
console.log(copiaPlana instanceof Tarea); // false ✗ sin comportamiento
// copiaPlana.esfuerzo(); // ✗ TypeError: no es una funciónLa solución es la fábrica estática del apartado 4: Tarea.desdeJSON (o datos.map((d) => new Tarea(d))) es el paso obligatorio de rehidratación al leer datos de fuera. Lo mismo vale para structuredClone, que copia los datos pero devuelve un objeto plano.
Errores Comunes y Consejos
- Poner comas entre los miembros de la clase. Dentro de
classno se separan con comas, a diferencia de los objetos literales del Módulo 4.class A { m1() {}, m2() {} }es unSyntaxError. - Olvidar
super(...)en el constructor del hijo, o usarthisantes de llamarlo. ElReferenceErrores explícito, pero el motivo desconcierta si no recuerdas que essuperquien inicializa la parte del padre. - Escribir
functionen los métodos.class A { function m() {} }no es válido: la sintaxis es la abreviada de 04-02. - Definir un método con nombre
constructoresperando sobrecargar. Solo hay un constructor; para varias formas de crear, usa métodosstaticde fábrica. - Usar una función flecha como método de clase cuando esperas que vaya al prototipo.
descripcion = () => {…}es un campo de instancia, no un método: se crea una copia por objeto (adiós al ahorro de memoria del 05-01) y no es sobrescribible consuper. Tiene un uso legítimo —fijathispara pasarlo como callback, resolviendo el problema de 04-02—, pero es una decisión consciente, no la forma normal de escribir métodos. - Confundir
staticcon «compartido por las instancias». Un miembrostaticno es accesible desde una instancia:tarea.MAX_HORASesundefined; hay que escribirTarea.MAX_HORAS(othis.constructor.MAX_HORASsi quieres que las subclases puedan redefinirlo). - Heredar por reutilizar código. Si solo quieres aprovechar dos funciones, no crees un padre artificial: importa esas funciones o compón. La herencia expresa una relación conceptual, no un atajo.
- Jerarquías profundas. Más de dos o tres niveles hacen imposible seguir de dónde sale cada método. Cuando la profundidad crece, casi siempre había una composición esperando.
- Consejo: documenta las clases con JSDoc como venías haciendo con las funciones en 03-03. Un
@param {Object} datossobre el constructor le ahorra a tu editor —y a ti dentro de tres meses— tener que leer el cuerpo entero.
Ejercicios
Ejercicio 1 — De constructor a clase. Traduce a sintaxis class el siguiente código heredado, corrigiendo de paso los dos defectos que contiene (uno de memoria, uno de datos compartidos).
function Residente(nombre, plan) {
this.nombre = nombre;
this.plan = plan;
this.reservas = [];
this.describir = function () {
return `${this.nombre} · plan ${this.plan} · ${this.reservas.length} reservas`;
};
}
Residente.prototype.tarifas = { mensual: 120, diario: 12 };
Residente.prototype.cuota = function () {
return this.tarifas[this.plan] ?? 0;
};Ejercicio 2 — TareaConSubtareas. Escribe una clase que extienda Tarea y añada un array subtareas de instancias de Tarea. Debe ofrecer:
agregarSubtarea(tarea), que valida que sea unaTareay la añade;horasTotales(), que sume sus horas y las de todas sus subtareas a cualquier profundidad (recursividad de 03-07);horasAbiertas(), igual pero contando solo las que no están hechas;descripcion(hoy)sobrescrito que añada(+N subtareas)cuando las haya.
Compruébalo con el árbol de «Rediseñar la sala polivalente»: 12 h totales y 7 h abiertas.
Ejercicio 3 — ErrorDeRegla con catálogo. Escribe una clase ErrorDeRegla extends ErrorDeValidacion que reciba el código de regla ('R6', 'R7'…) y exponga un método estático ErrorDeRegla.explicar(codigo) que devuelva el texto de la regla a partir de un catálogo estático. Úsala en Tarea.cambiarEstado y demuestra que un catch (e) { if (e instanceof ErrorDeValidacion) … } sigue capturándola.
Soluciones
Ejercicio 1
'use strict';
class Residente {
static TARIFAS = { mensual: 120, diario: 12 }; // defecto 2 corregido: constante en la CLASE
constructor(nombre, plan) {
this.nombre = nombre;
this.plan = plan;
this.reservas = []; // un array NUEVO por instancia
}
describir() { // defecto 1 corregido: método en el prototipo
return `${this.nombre} · plan ${this.plan} · ${this.reservas.length} reservas`;
}
cuota() {
return Residente.TARIFAS[this.plan] ?? 0;
}
}
const lucia = new Residente('Lucía', 'mensual');
const ivan = new Residente('Iván', 'diario');
console.log(lucia.describir === ivan.describir); // true ← una sola función
console.log(lucia.cuota(), ivan.cuota()); // 120 12Los dos defectos eran los del apartado 9 de 05-01. El primero, this.describir = function … dentro del constructor, creaba una función por instancia; en la clase pasa a ser un método del prototipo. El segundo, Residente.prototype.tarifas = {…}, ponía un objeto mutable en el prototipo: cualquier residente.tarifas.mensual = 0 lo habría cambiado para todos. Como campo static sigue siendo compartido a propósito, pero se accede por la clase, lo que deja clara la intención (y en 05-03 verás cómo hacerlo además de solo lectura).
Ejercicio 2
'use strict';
class TareaConSubtareas extends Tarea {
constructor(datos) {
super(datos);
this.subtareas = [];
for (const sub of datos.subtareas ?? []) {
this.agregarSubtarea(sub instanceof Tarea ? sub : new TareaConSubtareas(sub));
}
}
agregarSubtarea(tarea) {
if (!(tarea instanceof Tarea)) {
throw new ErrorDeValidacion('Una subtarea debe ser una Tarea.', 'subtareas', tarea);
}
this.subtareas.push(tarea);
return this;
}
horasTotales() {
return this.subtareas.reduce(
(suma, sub) => suma + (sub instanceof TareaConSubtareas ? sub.horasTotales() : sub.horasEstimadas),
this.horasEstimadas
);
}
horasAbiertas() {
const propias = this.estaAbierta() ? this.horasEstimadas : 0;
return this.subtareas.reduce(
(suma, sub) => suma + (sub instanceof TareaConSubtareas
? sub.horasAbiertas()
: (sub.estaAbierta() ? sub.horasEstimadas : 0)),
propias
);
}
descripcion(hoy) {
const base = super.descripcion(hoy);
return this.subtareas.length > 0 ? `${base} (+${this.subtareas.length} subtareas)` : base;
}
}
const rediseno = new TareaConSubtareas({
id: 1, titulo: 'Rediseñar la sala polivalente', responsable: 'Iván',
prioridad: 'alta', estado: 'en-curso', horasEstimadas: 0.0001, fechaLimite: '2026-09-30',
subtareas: [
{ id: 11, titulo: 'Medir y levantar el plano', responsable: 'Iván', estado: 'hecha', horasEstimadas: 3, fechaLimite: '2026-09-10' },
{ id: 12, titulo: 'Elegir el mobiliario', responsable: 'Marta', estado: 'en-curso', horasEstimadas: 0.0001, fechaLimite: '2026-09-20',
subtareas: [
{ id: 121, titulo: 'Pedir presupuestos', responsable: 'Marta', estado: 'hecha', horasEstimadas: 2, fechaLimite: '2026-09-15' },
{ id: 122, titulo: 'Visitar a dos proveedores', responsable: 'Marta', estado: 'pendiente', horasEstimadas: 2, fechaLimite: '2026-09-22' }
] },
{ id: 13, titulo: 'Pintar y montar', responsable: 'Iván', estado: 'pendiente', horasEstimadas: 5, fechaLimite: '2026-09-28' }
]
});
console.log(Math.round(rediseno.horasTotales())); // 12
console.log(Math.round(rediseno.horasAbiertas())); // 7 (2 de visitar + 5 de pintar)
console.log(rediseno.descripcion('2026-09-20'));
// ▸ [1] Rediseñar la sala polivalente · Iván · 0.0001 h (+3 subtareas)Dos observaciones honestas sobre esta solución. La primera, ese horasEstimadas: 0.0001 es un apaño feo: los nodos contenedores del árbol de 03-07 tenían horasEstimadas: 0, pero la R3 que validamos en el constructor exige que sean mayores que cero. Es un choque real entre dos reglas, y la salida limpia sería permitir 0 para las tareas contenedoras —una excepción explícita en la validación— en lugar de falsear el dato. Anótalo como lo que es: una decisión de diseño pendiente. La segunda, la recursión de horasTotales es la misma de sumarHorasTotales de 03-07, ahora convertida en método; el caso base sigue siendo implícito, porque un array de subtareas vacío hace que reduce devuelva directamente el valor inicial.
Ejercicio 3
'use strict';
class ErrorDeRegla extends ErrorDeValidacion {
static CATALOGO = {
R1: 'El id es único y correlativo; lo asigna la aplicación.',
R2: 'El título no puede estar vacío.',
R3: 'Las horas estimadas deben estar entre 1 y 40.',
R6: 'Solo se permiten las transiciones pendiente → en-curso → hecha.',
R7: 'Nadie puede superar 40 horas asignadas en la misma semana.'
};
constructor(codigo, mensaje, campo, valorRecibido) {
super(mensaje, campo, valorRecibido);
this.name = 'ErrorDeRegla';
this.regla = codigo;
}
static explicar(codigo) {
return ErrorDeRegla.CATALOGO[codigo] ?? 'Regla desconocida.';
}
describir() {
return `[${this.regla}] ${this.message} — ${ErrorDeRegla.explicar(this.regla)}`;
}
}
// En Tarea.cambiarEstado, sustituyendo el throw del apartado 5:
// throw new ErrorDeRegla('R6', `Transición no permitida: "${this.estado}" → "${nuevo}".`, 'estado', nuevo);
try {
new Tarea({ id: 12, titulo: 'Limpiar la sala', horasEstimadas: 2, fechaLimite: '2026-10-01' })
.cambiarEstado('hecha');
} catch (error) {
console.log(error instanceof ErrorDeRegla); // true
console.log(error instanceof ErrorDeValidacion); // true ← el catch genérico la atrapa
console.log(error instanceof Error); // true
console.log(error.describir());
// [R6] Transición no permitida: "pendiente" → "hecha". — Solo se permiten las transiciones pendiente → en-curso → hecha.
}Fíjate en dos cosas. describir() está sobrescrito y usa el catálogo estático, mientras que la versión del padre seguía disponible con super.describir() si la hubiéramos querido. Y los tres instanceof en true son la demostración práctica de por qué se usa instanceof y no error.name: un manejador escrito hace meses para ErrorDeValidacion sigue capturando esta clase nueva sin modificar ni una línea.
Conclusión
Has cambiado de sintaxis sin cambiar de mecanismo. class no introduce un sistema de objetos nuevo: por debajo sigue habiendo una función constructora y un objeto prototype, exactamente los del 05-01, como demuestra que typeof Tarea sea 'function' y que Object.getPrototypeOf(t) === Tarea.prototype. Lo que aporta es legibilidad —constructor y métodos juntos en un bloque— y un puñado de correcciones que ya no tienes que recordar: métodos no enumerables, modo estricto automático, constructor bien apuntado, imposibilidad de olvidar el new y extends en lugar de las tres líneas de ritual.
Tienes ya el vocabulario completo. El constructor recibe los argumentos y llena el objeto; los métodos de instancia viven en el prototipo y se comparten; los campos son propiedades propias con valor inicial, ideales para defectos como estado = 'pendiente'; y los miembros static pertenecen a la clase, sirviendo para constantes (Tarea.ESTADOS, Tarea.TRANSICIONES, Tarea.MAX_HORAS), contadores (Tarea.ultimoId) y sobre todo fábricas como Tarea.desdeJSON, que suplen la falta de sobrecarga de constructores y son el paso obligatorio para rehidratar los datos planos que devuelve JSON.parse. Con extends y super —super(datos) en el constructor, super.metodo() en los métodos— has montado TareaRecurrente y TareaBloqueada, y has visto el polimorfismo funcionando: un bucle que llama a tarea.descripcion(hoy) sobre tres clases distintas y no contiene un solo if sobre el tipo.
El modelo del proyecto está ahora en su sitio. class Tarea valida en el constructor (R2, R3, R8, R9), calcula esfuerzo y vencimiento (R10) y protege las transiciones de estado con cambiarEstado (R6). class Tablero guarda el backlog, comprueba identificadores duplicados (R1) y la carga máxima por persona (R7), y produce de un tirón el resumen canónico: 48 h totales, 45 abiertas, 1 vencida, esfuerzo 124, con Iván en 25 h, Lucía en 14 y Marta en 6. Los cuatro pilares de la POO han dejado de ser una lista de palabras para señalar líneas concretas de ese código, sabes cuándo la herencia es legítima («es un») y cuándo conviene componer («tiene un»), con ExportadorCSV y RegistroDeCambios como piezas reutilizables. Y class ErrorDeValidacion extends Error ya no es una receta copiada: entiendes qué hace super(mensaje), por qué se sobrescribe name, de dónde salen message y stack, y por qué error instanceof ErrorDeValidacion es mejor que comparar cadenas de texto.
Queda, sin embargo, una promesa incumplida. Has escrito cambiarEstado para que nadie pueda saltarse la R6… y cualquier línea del programa sigue pudiendo escribir tarea.estado = 'hecha' y dejar el objeto en un estado imposible. Lo mismo con tarea.horasEstimadas = 500, que atraviesa la validación del constructor como si no existiera, o con Tarea.ultimoId = 0, que rompería la R1 para siempre. La abstracción está bien diseñada, pero la encapsulación es todavía una convención basada en la buena voluntad. Cerrar ese hueco —con get, set, campos privados #estado y propiedades de solo lectura— es el tema de Encapsulación: Getters, Setters y Campos Privados.
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
