Módulo 09: POO: prototipos y clases
Objetivos del módulo
- Entender qué es la programación orientada a objetos y por qué es útil en JavaScript
- Comprender los prototipos y la cadena de prototipos, la base de todo el modelo de objetos de JavaScript
- Conocer las funciones constructoras y
new(la forma histórica) - Escribir clases modernas con constructor, métodos y campos de clase
- Proteger el estado con campos y métodos privados
#y comprobarlos con#campo in obj - Usar getters, setters y miembros
static(incluidos los bloquesstatic {}) - Aplicar herencia con
extendsysuper, sobrescribir métodos y usarinstanceof - Simular clases abstractas con
new.target, combinar comportamiento con mixins y preferir composición cuando convenga - Evitar los problemas de
thisen callbacks - Diseñar pequeños modelos completos: cuenta bancaria, figuras e inventario
1. ¿Por qué programación orientada a objetos?
📘 Concepto: La programación orientada a objetos (POO) organiza el código en torno a objetos que agrupan datos (propiedades) y comportamiento (métodos). Una clase es el plano con el que se fabrican objetos de un mismo tipo; cada objeto fabricado es una instancia.
Piensa en el plano de una vivienda: con un único plano se construyen muchas casas. Todas tienen la misma estructura (habitaciones, puertas), pero cada una tiene sus propios valores (color de la fachada, propietario).
| Pilar | Idea | En JavaScript |
|---|---|---|
| Encapsulación | Ocultar los detalles internos y exponer solo una interfaz | Campos privados #, getters/setters |
| Abstracción | Trabajar con conceptos (una “cuenta”) sin conocer sus tripas | Clases y métodos con nombres claros |
| Herencia | Reutilizar y especializar un tipo a partir de otro | extends, super |
| Polimorfismo | Objetos distintos responden al mismo mensaje a su manera | Sobrescritura de métodos |
📘 Concepto: JavaScript no copia el modelo de clases de Java o C#. Su POO se basa en prototipos: los objetos heredan directamente de otros objetos. La sintaxis
class(ES2015) es una forma más cómoda y segura de escribir ese mismo modelo. Por eso conviene entender primero los prototipos.
2. Objetos y prototipos
2.1. La cadena de prototipos
📘 Concepto: Todo objeto tiene un enlace oculto a otro objeto, su prototipo. Cuando lees una propiedad que el objeto no tiene, JavaScript la busca en su prototipo; si tampoco está, en el prototipo del prototipo, y así hasta llegar a
null. Esto es la cadena de prototipos.
const animal = {
respirar() {
return `${this.nombre} respira`;
},
};
// Object.create crea un objeto nuevo cuyo prototipo es el que le pasas
const perro = Object.create(animal);
perro.nombre = 'Toby';
perro.ladrar = function () {
return '¡Guau!';
};
console.log(perro.ladrar()); // ¡Guau! (propiedad propia)
console.log(perro.respirar()); // Toby respira (heredada de animal)
console.log(Object.getPrototypeOf(perro) === animal); // true
console.log(Object.hasOwn(perro, 'respirar')); // false (no es propia)
flowchart LR
P["perro<br/>nombre: 'Toby'<br/>ladrar()"] -->|"[[Prototype]]"| A["animal<br/>respirar()"]
A -->|"[[Prototype]]"| O["Object.prototype<br/>toString(), hasOwnProperty()..."]
O -->|"[[Prototype]]"| N["null"]
Por eso cualquier objeto tiene toString() sin que lo hayas definido: lo hereda de Object.prototype. Y los arrays tienen map porque su prototipo es Array.prototype:
const lista = [1, 2, 3];
console.log(Object.getPrototypeOf(lista) === Array.prototype); // true
console.log(Object.getPrototypeOf(Array.prototype) === Object.prototype); // true
console.log(Object.getPrototypeOf(Object.prototype)); // null
2.2. Sombra de propiedades (shadowing)
Si el objeto define una propiedad con el mismo nombre que su prototipo, la propia tapa a la heredada:
const base = { saludo: 'Hola' };
const hijo = Object.create(base);
console.log(hijo.saludo); // Hola (heredada)
hijo.saludo = 'Buenas'; // crea una propiedad PROPIA en hijo
console.log(hijo.saludo); // Buenas
console.log(base.saludo); // Hola (el prototipo no cambia)
2.3. __proto__: el acceso heredado
Verás __proto__ en código antiguo y en tutoriales: es un getter/setter para el prototipo que se mantiene solo por compatibilidad.
| Forma | Estado | Recomendación |
|---|---|---|
Object.getPrototypeOf(obj) | Estándar | ✅ Leer el prototipo |
Object.create(proto) | Estándar | ✅ Crear un objeto con un prototipo |
Object.setPrototypeOf(obj, proto) | Estándar | ⚠️ Funciona, pero cambiar el prototipo de un objeto existente es lento |
obj.__proto__ | Heredado (legacy) | ❌ Evítalo |
{ __proto__: proto } en un literal | Estándar | Aceptable, pero Object.create es más claro |
💡 Consejo:
Object.create(null)crea un objeto sin prototipo: no heredatoStringni nada. Es útil como diccionario puro (así devuelveObject.groupBysus resultados, módulo 06), aunque normalmente unMap(módulo 11) es mejor opción.
3. Funciones constructoras y new (la forma histórica)
Antes de ES2015, la forma de fabricar objetos en serie era una función constructora llamada con new:
function Persona(nombre, edad) {
// new crea un objeto vacío, lo enlaza a Persona.prototype y lo asigna a this
this.nombre = nombre;
this.edad = edad;
// ...y lo devuelve automáticamente
}
// Los métodos se ponen en el prototipo: se comparten entre todas las instancias
Persona.prototype.saludar = function () {
return `Hola, soy ${this.nombre}`;
};
const ana = new Persona('Ana', 30);
const luis = new Persona('Luis', 25);
console.log(ana.saludar()); // Hola, soy Ana
console.log(ana.saludar === luis.saludar); // true (misma función compartida)
console.log(Object.getPrototypeOf(ana) === Persona.prototype); // true
console.log(ana instanceof Persona); // true
Lo que hace new Persona('Ana', 30) paso a paso:
- Crea un objeto vacío
{}. - Enlaza su prototipo con
Persona.prototype. - Ejecuta
Personaconthisapuntando al objeto nuevo. - Devuelve el objeto (salvo que la función devuelva otro objeto explícitamente).
⚠️ Importante: Si olvidas
new, en modo estrictothisesundefinedy obtienes unTypeError; en scripts antiguos, las propiedades se creaban en el objeto global. Es uno de los motivos por los que se crearon las clases: una clase no se puede llamar sinnew.
4. Clases (ES2015+)
4.1. Sintaxis básica
class Producto {
// Campos de clase (ES2022): propiedades de instancia con valor inicial
stock = 0;
constructor(nombre, precio) {
this.nombre = nombre;
this.precio = precio;
}
// Métodos: van al prototipo, compartidos por todas las instancias
precioConIva(iva = 0.21) {
return +(this.precio * (1 + iva)).toFixed(2);
}
reponer(unidades) {
this.stock += unidades;
return this;
}
}
const teclado = new Producto('Teclado', 30);
teclado.reponer(10).reponer(5);
console.log(teclado); // Producto { stock: 15, nombre: 'Teclado', precio: 30 }
console.log(teclado.precioConIva()); // 36.3
console.log(typeof Producto); // function (una clase es una función especial)
console.log(Object.getPrototypeOf(teclado) === Producto.prototype); // true
📘 Concepto: Los campos de clase se inicializan antes de que se ejecute el cuerpo del constructor (en las clases derivadas, justo después de
super()). Por esostockaparece primero al mostrar el objeto.
4.2. Diferencias entre clases y funciones constructoras
| Aspecto | Función constructora | Clase |
|---|---|---|
Llamar sin new | Se permite (y causa errores) | TypeError |
| Hoisting | La función se puede usar antes de declararla | No: usarla antes da ReferenceError (TDZ) |
| Modo estricto | Solo si lo activas | Siempre, dentro del cuerpo de la clase |
| Métodos | Constructor.prototype.metodo = ... | Dentro del cuerpo |
| Privacidad real | Solo con closures | Campos #privados |
| Herencia | Manual y propensa a errores | extends y super |
try {
Producto('Ratón', 10);
} catch (error) {
console.log(error.message); // Class constructor Producto cannot be invoked without 'new'
}
💡 Consejo: Convenciones: nombres de clase en PascalCase (
CuentaBancaria), una clase por concepto, y métodos con verbos (ingresar,calcularTotal).
5. Encapsulación: campos y métodos privados
5.1. Campos privados #
Un campo cuyo nombre empieza por # solo es accesible desde dentro de la clase. No es una convención (como el guion bajo _): el motor lo impide.
class Contador {
#valor = 0; // campo privado
static #instancias = 0; // campo privado estático
constructor() {
Contador.#instancias++;
}
incrementar() {
this.#valor++;
return this.#valor;
}
get valor() { // acceso de solo lectura desde fuera
return this.#valor;
}
static get instancias() {
return Contador.#instancias;
}
}
const c = new Contador();
c.incrementar();
c.incrementar();
console.log(c.valor); // 2
console.log(c['#valor']); // undefined (no es una propiedad normal)
console.log(Object.keys(c)); // [] (no aparece en el objeto)
new Contador();
console.log(Contador.instancias); // 2
// c.#valor = 100; // ❌ SyntaxError: Private field '#valor' must be declared in an enclosing class
⚠️ Importante: Acceder a
c.#valorfuera de la clase es un error de sintaxis: el fichero ni siquiera llega a ejecutarse. Los campos privados deben declararse en el cuerpo de la clase; no se pueden crear sobre la marcha en el constructor como las propiedades normales.
5.2. Métodos privados
class Pedido {
#lineas = [];
anadir(producto, precio, unidades = 1) {
this.#validar(precio, unidades);
this.#lineas.push({ producto, precio, unidades });
return this;
}
get total() {
return this.#lineas.reduce((s, l) => s + l.precio * l.unidades, 0);
}
#validar(precio, unidades) { // método privado: detalle interno
if (precio <= 0 || !Number.isInteger(unidades) || unidades < 1) {
throw new RangeError('Precio o unidades no válidos');
}
}
}
const pedido = new Pedido().anadir('Libro', 15, 2).anadir('Lápiz', 1.5);
console.log(pedido.total); // 31.5
5.3. #campo in obj: comprobar si un objeto es “de verdad” de la clase
Desde ES2022 puedes comprobar si un objeto tiene un campo privado con el operador in. Es una forma fiable de saber que un objeto fue creado por tu clase:
class Moneda {
#codigo;
constructor(codigo) {
this.#codigo = codigo;
}
static esMoneda(obj) {
return #codigo in obj;
}
equals(otra) {
return #codigo in otra && this.#codigo === otra.#codigo;
}
}
const euro = new Moneda('EUR');
const falso = { codigo: 'EUR' };
console.log(Moneda.esMoneda(euro)); // true
console.log(Moneda.esMoneda(falso)); // false
console.log(euro.equals(new Moneda('EUR'))); // true
console.log(euro.equals(falso)); // false (sin error al leer otra.#codigo)
💡 Consejo: Sin la comprobación
#codigo in otra, leerotra.#codigoen un objeto que no es unaMonedalanzaría unTypeError.
6. Getters y setters
class Temperatura {
#celsius;
constructor(celsius) {
this.celsius = celsius; // pasa por el setter: valida desde el principio
}
get celsius() {
return this.#celsius;
}
set celsius(valor) {
if (typeof valor !== 'number' || valor < -273.15) {
throw new RangeError(`Temperatura imposible: ${valor}`);
}
this.#celsius = valor;
}
get fahrenheit() { // propiedad calculada
return this.#celsius * 9 / 5 + 32;
}
set fahrenheit(valor) {
this.celsius = (valor - 32) * 5 / 9;
}
}
const t = new Temperatura(25);
console.log(t.fahrenheit); // 77
t.fahrenheit = 212;
console.log(t.celsius); // 100
try {
t.celsius = -300;
} catch (error) {
console.log(error.message); // Temperatura imposible: -300
}
💡 Consejo: Combina campo privado + getter para propiedades de solo lectura, y campo privado + getter + setter con validación para propiedades que se pueden cambiar pero con reglas. Si un setter no valida nada, probablemente no necesitas getter ni setter: usa una propiedad pública.
7. Miembros estáticos
Los miembros static pertenecen a la clase, no a las instancias. Se usan para utilidades, constantes y métodos de fábrica.
class Fecha {
static FORMATO = 'dd/mm/aaaa'; // propiedad estática
static #creadas = 0;
static diasSemana;
// Bloque estático (ES2022): inicialización compleja, se ejecuta una vez al definir la clase
static {
const formateador = new Intl.DateTimeFormat('es-ES', { weekday: 'long', timeZone: 'UTC' });
// 5 de enero de 2026 fue lunes: generamos los 7 días a partir de ahí
Fecha.diasSemana = Array.from({ length: 7 }, (_, i) => formateador.format(new Date(Date.UTC(2026, 0, 5 + i))));
}
constructor(dia, mes, anio) {
Object.assign(this, { dia, mes, anio });
Fecha.#creadas++;
}
// Método de fábrica: crea instancias a partir de otro formato
static desdeTexto(texto) {
const [dia, mes, anio] = texto.split('/').map(Number);
return new Fecha(dia, mes, anio);
}
static get creadas() {
return Fecha.#creadas;
}
toString() {
return [this.dia, this.mes].map((n) => String(n).padStart(2, '0')).concat(this.anio).join('/');
}
}
const f = Fecha.desdeTexto('3/10/2026');
console.log(`${f}`); // 03/10/2026
console.log(Fecha.FORMATO); // dd/mm/aaaa
console.log(Fecha.creadas); // 1
console.log(Fecha.diasSemana); // ['lunes', 'martes', 'miércoles', 'jueves', 'viernes', 'sábado', 'domingo']
console.log(f.FORMATO); // undefined (no existe en las instancias)
📘 Concepto: Ya conoces muchos métodos estáticos:
Array.from,Object.keys,Number.isInteger,Math.max… Se llaman sobre la clase o el objeto global, no sobre una instancia.
💡 Consejo: Para fechas reales usarás
IntlyTemporal(módulo 19). Este ejemplo solo ilustra la sintaxis destatic.
8. Herencia: extends y super
class Empleado {
constructor(nombre, salarioBase) {
this.nombre = nombre;
this.salarioBase = salarioBase;
}
salario() {
return this.salarioBase;
}
describir() {
return `${this.nombre} (${this.constructor.name}): ${this.salario()} €`;
}
}
class Gerente extends Empleado {
constructor(nombre, salarioBase, bonus) {
super(nombre, salarioBase); // llama al constructor padre. ¡Obligatorio antes de usar this!
this.bonus = bonus;
}
// Sobrescritura: misma firma, comportamiento distinto
salario() {
return super.salario() + this.bonus; // super.metodo() llama a la versión del padre
}
}
class Becario extends Empleado {
salario() {
return Math.round(super.salario() * 0.6);
}
}
const plantilla = [
new Empleado('Ana', 2000),
new Gerente('Luis', 3000, 800),
new Becario('Eva', 2000), // sin constructor: hereda el del padre
];
// Polimorfismo: el mismo mensaje, cada objeto responde a su manera
for (const persona of plantilla) {
console.log(persona.describir());
}
// Ana (Empleado): 2000 €
// Luis (Gerente): 3800 €
// Eva (Becario): 1200 €
⚠️ Importante: En una clase derivada con constructor, debes llamar a
super(...)antes de usarthis. Si no, obtienesReferenceError: Must call super constructor in derived class before accessing 'this' or returning from derived constructor.
8.1. La cadena de prototipos de una jerarquía
flowchart LR
G["luis (instancia)<br/>nombre, salarioBase, bonus"] --> GP["Gerente.prototype<br/>salario()"]
GP --> EP["Empleado.prototype<br/>salario(), describir()"]
EP --> OP["Object.prototype"]
OP --> N["null"]
8.2. instanceof
obj instanceof Clase comprueba si Clase.prototype aparece en algún punto de la cadena de prototipos de obj:
const luis = plantilla[1];
console.log(luis instanceof Gerente); // true
console.log(luis instanceof Empleado); // true (Gerente hereda de Empleado)
console.log(luis instanceof Object); // true
console.log(luis instanceof Becario); // false
// Contar por tipo
const gerentes = plantilla.filter((p) => p instanceof Gerente).length;
console.log(gerentes); // 1
💡 Consejo: No llenes tu código de
if (x instanceof A) ... else if (x instanceof B). Normalmente es mejor el polimorfismo: que cada clase implemente su propio método y llamarlo sin preguntar el tipo.
8.3. Extender clases nativas
class Pila extends Array {
get cima() {
return this.at(-1);
}
}
const pila = Pila.from([1, 2]);
pila.push(3);
console.log(pila.cima, pila.length); // 3 3
console.log(pila.map((n) => n * 2) instanceof Pila); // true (map devuelve otra Pila)
9. Clases abstractas simuladas con new.target
JavaScript no tiene la palabra abstract, pero puedes impedir que se instancie una clase base con new.target, que vale la clase con la que se llamó a new:
class Figura {
constructor(nombre) {
if (new.target === Figura) {
throw new TypeError('Figura es abstracta: usa una subclase');
}
this.nombre = nombre;
}
// "Método abstracto": las subclases deben sobrescribirlo
area() {
throw new Error(`${this.constructor.name} debe implementar area()`);
}
describir() {
return `${this.nombre} de área ${this.area().toFixed(2)}`;
}
}
try {
new Figura('genérica');
} catch (error) {
console.log(error.message); // Figura es abstracta: usa una subclase
}
class Triangulo extends Figura {} // olvida implementar area()
try {
new Triangulo('triángulo').describir();
} catch (error) {
console.log(error.message); // Triangulo debe implementar area()
}
💡 Consejo: En el módulo 21 verás que TypeScript sí ofrece
abstracteinterface, comprobados antes de ejecutar el código.
10. Mixins y composición frente a herencia
10.1. Mixins (breve)
Una clase solo puede heredar de una clase. Un mixin es una función que recibe una clase y devuelve otra que la extiende con comportamiento adicional:
const Serializable = (Base) => class extends Base {
aJSON() {
return JSON.stringify(this);
}
};
const ConMarcaTemporal = (Base) => class extends Base {
creadoEn = '2026-10-04';
};
class Nota {
constructor(texto) {
this.texto = texto;
}
}
class NotaAvanzada extends Serializable(ConMarcaTemporal(Nota)) {}
const nota = new NotaAvanzada('Repasar clases');
console.log(nota.aJSON()); // {"texto":"Repasar clases","creadoEn":"2026-10-04"}
10.2. Composición: “tiene un” frente a “es un”
La herencia modela relaciones “es un” (un Gerente es un Empleado). La composición modela “tiene un” (un Coche tiene un Motor). Las jerarquías profundas se vuelven rígidas: si un Pato hereda de Ave con volar(), ¿qué haces con un pingüino?
// Comportamientos pequeños y reutilizables
const puedeNadar = (nombre) => ({ nadar: () => `${nombre} nada` });
const puedeVolar = (nombre) => ({ volar: () => `${nombre} vuela` });
// Cada animal se compone con lo que realmente sabe hacer
function crearPato(nombre) {
return { nombre, ...puedeNadar(nombre), ...puedeVolar(nombre) };
}
function crearPinguino(nombre) {
return { nombre, ...puedeNadar(nombre) };
}
const pato = crearPato('Donald');
const pingu = crearPinguino('Pingu');
console.log(pato.volar(), '|', pingu.nadar()); // Donald vuela | Pingu nada
console.log(typeof pingu.volar); // undefined
// Composición con clases: el coche delega en su motor
class Motor {
#encendido = false;
arrancar() { this.#encendido = true; return 'Motor en marcha'; }
get encendido() { return this.#encendido; }
}
class Coche {
#motor = new Motor(); // "tiene un" motor
constructor(modelo) { this.modelo = modelo; }
arrancar() { return `${this.modelo}: ${this.#motor.arrancar()}`; }
}
console.log(new Coche('Ibiza').arrancar()); // Ibiza: Motor en marcha
💡 Consejo: Regla clásica: “favorece la composición sobre la herencia”. Usa herencia cuando la relación “es un” sea clara y estable, con jerarquías poco profundas (uno o dos niveles).
11. toString y Symbol.toPrimitive (breve)
Cuando JavaScript necesita convertir un objeto en texto o en número, llama a métodos especiales que puedes definir:
class Dinero {
constructor(cantidad, moneda = 'EUR') {
this.cantidad = cantidad;
this.moneda = moneda;
}
toString() {
return `${this.cantidad.toFixed(2)} ${this.moneda}`;
}
// Controla todas las conversiones: hint es 'string', 'number' o 'default'
[Symbol.toPrimitive](hint) {
return hint === 'number' ? this.cantidad : this.toString();
}
}
const precio = new Dinero(19.5);
console.log(`${precio}`); // 19.50 EUR (hint 'string')
console.log(+precio); // 19.5 (hint 'number')
console.log(precio * 2); // 39
console.log(precio > 10); // true
console.log(precio + ''); // 19.50 EUR (hint 'default')
📘 Concepto: Sin
toStringpropio, un objeto se convierte en'[object Object]'. Los símbolos comoSymbol.toPrimitiveoSymbol.iterator(módulo 11) son “ganchos” que permiten a tus clases integrarse con el lenguaje.
12. this en clases y callbacks
El problema de perder this (módulo 07) también afecta a las clases, y con más frecuencia, porque sus métodos se pasan a menudo como callbacks (eventos, temporizadores, map…):
class Saludador {
constructor(nombre) {
this.nombre = nombre;
}
saludar() {
return `Hola desde ${this.nombre}`;
}
}
const s = new Saludador('Ana');
const callback = s.saludar;
try {
callback(); // this es undefined (el cuerpo de una clase siempre es estricto)
} catch (error) {
console.log(error.constructor.name); // TypeError
}
Dos soluciones habituales:
class Boton {
clics = 0;
constructor(etiqueta) {
this.etiqueta = etiqueta;
// Solución 1: enlazar en el constructor
this.pulsar = this.pulsar.bind(this);
}
pulsar() {
this.clics++;
return `${this.etiqueta}: ${this.clics} clics`;
}
// Solución 2: campo de clase con arrow function (hereda el this de la instancia)
reiniciar = () => {
this.clics = 0;
return `${this.etiqueta} reiniciado`;
};
}
const boton = new Boton('Enviar');
const { pulsar, reiniciar } = boton; // métodos "sueltos"
console.log(pulsar()); // Enviar: 1 clics
console.log(pulsar()); // Enviar: 2 clics
console.log(reiniciar()); // Enviar reiniciado
// En el navegador: elemento.addEventListener('click', boton.pulsar) funcionaría bien
| Solución | Ventaja | Inconveniente |
|---|---|---|
bind en el constructor | El método sigue en el prototipo | Hay que acordarse de enlazar cada uno |
| Campo con arrow function | Conciso, imposible de olvidar | Se crea una función nueva por instancia y no está en el prototipo (no se puede usar super.metodo()) |
Arrow al pasar el callback: () => s.saludar() | No cambia la clase | Hay que hacerlo en cada uso |
13. Ejemplo completo
13.1. CuentaBancaria con campos privados
class CuentaBancaria {
static #ultimoNumero = 1000;
static COMISION_TRANSFERENCIA = 0.5;
#saldo = 0;
#movimientos = [];
#numero;
constructor(titular, saldoInicial = 0) {
if (!titular?.trim()) throw new TypeError('El titular es obligatorio');
this.titular = titular;
this.#numero = `ES-${++CuentaBancaria.#ultimoNumero}`;
if (saldoInicial > 0) this.ingresar(saldoInicial, 'Saldo inicial');
}
get numero() { return this.#numero; }
get saldo() { return this.#saldo; }
get movimientos() { return this.#movimientos.map((m) => ({ ...m })); } // copias: nadie modifica el historial
ingresar(cantidad, concepto = 'Ingreso') {
this.#validarCantidad(cantidad);
this.#registrar(cantidad, concepto);
return this;
}
retirar(cantidad, concepto = 'Retirada') {
this.#validarCantidad(cantidad);
if (cantidad > this.#saldo) {
throw new RangeError(`Saldo insuficiente en ${this.#numero}: ${this.#saldo} €`);
}
this.#registrar(-cantidad, concepto);
return this;
}
transferir(destino, cantidad) {
if (!(#saldo in destino)) throw new TypeError('El destino no es una CuentaBancaria');
this.retirar(cantidad + CuentaBancaria.COMISION_TRANSFERENCIA, `Transferencia a ${destino.numero}`);
destino.ingresar(cantidad, `Transferencia de ${this.#numero}`);
}
#validarCantidad(cantidad) {
if (!Number.isFinite(cantidad) || cantidad <= 0) {
throw new RangeError(`Cantidad no válida: ${cantidad}`);
}
}
#registrar(importe, concepto) {
this.#saldo = Math.round((this.#saldo + importe) * 100) / 100;
this.#movimientos.push({ concepto, importe, saldo: this.#saldo });
}
toString() {
return `${this.#numero} (${this.titular}): ${this.#saldo.toFixed(2)} €`;
}
}
const cuentaAna = new CuentaBancaria('Ana', 500);
const cuentaLuis = new CuentaBancaria('Luis');
cuentaAna.retirar(120, 'Supermercado');
cuentaAna.transferir(cuentaLuis, 100);
console.log(`${cuentaAna}`); // ES-1001 (Ana): 279.50 €
console.log(`${cuentaLuis}`); // ES-1002 (Luis): 100.00 €
console.log(cuentaAna.movimientos.map((m) => `${m.concepto}: ${m.importe}`));
// ['Saldo inicial: 500', 'Supermercado: -120', 'Transferencia a ES-1002: -100.5']
try {
cuentaLuis.retirar(1000);
} catch (error) {
console.log(error.message); // Saldo insuficiente en ES-1002: 100 €
}
try {
cuentaAna.transferir({ numero: 'ES-9999' }, 10);
} catch (error) {
console.log(error.message); // El destino no es una CuentaBancaria
}
13.2. Jerarquía Figura → Circulo / Rectangulo
class Figura {
constructor(nombre) {
if (new.target === Figura) throw new TypeError('Figura es abstracta');
this.nombre = nombre;
}
area() { throw new Error('area() no implementado'); }
perimetro() { throw new Error('perimetro() no implementado'); }
toString() {
return `${this.nombre}: área ${this.area().toFixed(2)}, perímetro ${this.perimetro().toFixed(2)}`;
}
static compararPorArea(a, b) {
return a.area() - b.area();
}
}
class Circulo extends Figura {
#radio;
constructor(radio) {
super('Círculo');
this.#radio = radio;
}
get radio() { return this.#radio; }
area() { return Math.PI * this.#radio ** 2; }
perimetro() { return 2 * Math.PI * this.#radio; }
}
class Rectangulo extends Figura {
constructor(ancho, alto, nombre = 'Rectángulo') {
super(nombre);
this.ancho = ancho;
this.alto = alto;
}
area() { return this.ancho * this.alto; }
perimetro() { return 2 * (this.ancho + this.alto); }
}
class Cuadrado extends Rectangulo {
constructor(lado) {
super(lado, lado, 'Cuadrado');
}
}
const figuras = [new Circulo(1.5), new Rectangulo(4, 2.5), new Cuadrado(3)];
for (const figura of figuras.toSorted(Figura.compararPorArea)) {
console.log(String(figura));
}
// Círculo: área 7.07, perímetro 9.42
// Cuadrado: área 9.00, perímetro 12.00
// Rectángulo: área 10.00, perímetro 13.00
const areaTotal = figuras.reduce((s, f) => s + f.area(), 0);
console.log(areaTotal.toFixed(2)); // 26.07
console.log(figuras[2] instanceof Rectangulo); // true
13.3. Inventario de productos
class Articulo {
#stock;
constructor({ sku, nombre, precio, stock = 0, categoria = 'general' }) {
Object.assign(this, { sku, nombre, precio, categoria });
this.#stock = stock;
}
get stock() { return this.#stock; }
get valor() { return this.precio * this.#stock; }
ajustarStock(delta) {
if (this.#stock + delta < 0) throw new RangeError(`Sin stock suficiente de ${this.nombre}`);
this.#stock += delta;
}
}
class Inventario {
#articulos = new Map(); // sku → Articulo (Map se estudia en el módulo 11)
alta(datos) {
if (this.#articulos.has(datos.sku)) throw new Error(`SKU duplicado: ${datos.sku}`);
this.#articulos.set(datos.sku, new Articulo(datos));
return this;
}
vender(sku, unidades) {
const articulo = this.#articulos.get(sku);
if (!articulo) throw new Error(`SKU desconocido: ${sku}`);
articulo.ajustarStock(-unidades);
return articulo.precio * unidades;
}
bajoStock(minimo = 5) {
return [...this.#articulos.values()].filter((a) => a.stock < minimo).map((a) => a.nombre);
}
get valorTotal() {
return [...this.#articulos.values()].reduce((s, a) => s + a.valor, 0);
}
resumenPorCategoria() {
const grupos = Object.groupBy(this.#articulos.values(), (a) => a.categoria);
return Object.fromEntries(Object.entries(grupos).map(([cat, lista]) => [cat, lista.length]));
}
}
const almacen = new Inventario()
.alta({ sku: 'TEC-01', nombre: 'Teclado', precio: 30, stock: 12, categoria: 'periféricos' })
.alta({ sku: 'RAT-01', nombre: 'Ratón', precio: 15, stock: 4, categoria: 'periféricos' })
.alta({ sku: 'MON-27', nombre: 'Monitor 27"', precio: 220, stock: 6, categoria: 'pantallas' });
console.log(almacen.vender('MON-27', 2)); // 440
console.log(almacen.bajoStock()); // ['Ratón', 'Monitor 27"']
console.log(almacen.valorTotal); // 1300
console.log(almacen.resumenPorCategoria()); // { 'periféricos': 2, pantallas: 1 }
💡 Consejo:
Object.groupByacepta cualquier iterable, no solo arrays: por eso funciona directamente conthis.#articulos.values().
14. Ejercicios
Ejercicio 1: Prototipos a mano
Sin usar class, crea con Object.create un objeto vehiculo con un método describir() y dos objetos coche y moto que lo hereden, cada uno con sus propiedades. Comprueba con Object.getPrototypeOf y Object.hasOwn qué es propio y qué heredado. Dibuja (en papel o Mermaid) la cadena de prototipos.
Ejercicio 2: De constructora a clase
Reescribe la función constructora Persona de la sección 3 como clase, añadiendo un campo privado #dni, un getter iniciales y un método estático compararPorEdad(a, b). Ordena un array de personas con toSorted(Persona.compararPorEdad).
Ejercicio 3: Lista de tareas encapsulada
Crea una clase ListaTareas con un array privado #tareas, métodos anadir(texto), completar(id), eliminar(id), un getter pendientes y un método privado #buscar(id) que lance un error si no existe. El exterior nunca debe poder modificar el array interno directamente.
Ejercicio 4: Jerarquía de vehículos
Crea una clase abstracta Vehiculo (con new.target) con marca, velocidadMaxima y un método abstracto tiempoPara(km). Implementa Coche, Bicicleta y Camion (este último con un límite de velocidad legal que reduce su velocidad efectiva). Recorre un array mixto y muestra cuánto tarda cada uno en recorrer 100 km.
Ejercicio 5: Mixins
Crea los mixins ConEventos (métodos on(evento, fn) y emitir(evento, datos)) y ConHistorial (guarda cada cambio en un array). Aplícalos a una clase Documento con un método editar(texto) que emita el evento 'cambio' y registre el historial.
Ejercicio 6: Composición
Modela un Personaje de videojuego por composición: en lugar de heredar de Guerrero o Mago, recibe en el constructor un array de habilidades (objetos con nombre y usar(objetivo)). Añade un método usarHabilidad(nombre, objetivo) y crea tres personajes con combinaciones distintas.
Ejercicio 7: Fracciones con Symbol.toPrimitive
Crea una clase Fraccion con numerador y denominador privados, simplificación automática en el constructor (máximo común divisor), métodos sumar, multiplicar (que devuelvan nuevas fracciones), toString() ('3/4') y Symbol.toPrimitive para que +new Fraccion(3, 4) dé 0.75.
Ejercicio 8: Ampliar el inventario
Amplía el inventario de la sección 13.3 con: una subclase ArticuloPerecedero con fecha de caducidad (texto '2026-12-31') y un método estaCaducado(hoy); un método retirarCaducados(hoy) en Inventario; y un método informe() que devuelva un texto con una línea por artículo ordenado por valor.
Resumen
| Concepto | Sintaxis |
|---|---|
| Crear con prototipo | Object.create(proto) |
| Leer prototipo | Object.getPrototypeOf(obj) (no __proto__) |
| Función constructora | function P() { this.x = 1; }, P.prototype.metodo = ..., new P() |
| Clase | class Nombre { constructor() {} metodo() {} } |
| Campo de clase | stock = 0; |
| Privado | #saldo = 0;, #validar() {}, this.#saldo |
| Comprobar privado | #saldo in obj |
| Getter / setter | get total() {}, set total(v) {} |
| Estático | static FORMATO = '', static crear() {}, static #contador, static { ... } |
| Herencia | class B extends A { constructor() { super(); } } |
| Llamar al padre | super(...), super.metodo() |
| Tipo | obj instanceof Clase |
| Clase abstracta | if (new.target === Base) throw ... |
| Mixin | const M = (Base) => class extends Base { ... } |
| Conversión | toString(), [Symbol.toPrimitive](hint) |
this en callbacks | this.metodo = this.metodo.bind(this) o metodo = () => { ... } |