Tema
POO y clases
Cuando tu app maneja muchos "objetos del mismo tipo" (usuarios, productos, cuentas, pedidos) con datos y comportamientos propios, la Programación Orientada a Objetos (POO) te ayuda a organizarlos. En esta lección aprenderás a crear clases, usar herencia, proteger datos con campos privados y, sobre todo, a entender this, una de las partes más confusas de JavaScript.
Concepto
Una clase es un molde (o plano de construcción) y los objetos son las galletas que salen de él. El molde de "Producto" define que todo producto tiene nombre, precio y un método para aplicar descuento; cada producto concreto ("Mouse", "Teclado") es una instancia con sus propios valores.
Los cuatro pilares de la POO:
- Encapsulamiento: cada objeto guarda sus datos y protege los internos.
- Abstracción: expones lo necesario (
cuenta.depositar(100)) y ocultas los detalles. - Herencia: una clase puede extender a otra y reutilizar su comportamiento.
- Polimorfismo: distintas clases responden al mismo método a su manera.
Prototipos en 1 minuto
Por debajo, JavaScript no tiene clases "de verdad" como Java: usa prototipos. Cada objeto tiene un enlace oculto a otro objeto (su prototipo) del que hereda propiedades. Cuando haces [1, 2].map(...), map no está en tu array: está en Array.prototype.
js
const arr = [1, 2, 3];
Object.getPrototypeOf(arr) === Array.prototype; // trueLa sintaxis class (ES2015) es una forma más clara de trabajar con prototipos. Es la que usarás en la práctica.
Tu primera clase
js
class Producto {
// constructor: se ejecuta al hacer "new Producto(...)"
constructor(nombre, precio, stock = 0) {
this.nombre = nombre; // this = el objeto que se está creando
this.precio = precio;
this.stock = stock;
}
// Métodos: los comparten todas las instancias (viven en el prototipo)
hayStock() {
return this.stock > 0;
}
aplicarDescuento(porcentaje) {
this.precio = Math.round(this.precio * (1 - porcentaje / 100));
return this; // devolver this permite encadenar
}
toString() {
return `${this.nombre} - $${this.precio}`;
}
}
// Crear instancias con new
const mouse = new Producto('Mouse', 349, 25);
const teclado = new Producto('Teclado', 1299);
console.log(mouse.hayStock()); // true
console.log(teclado.hayStock()); // false
mouse.aplicarDescuento(10);
console.log(`${mouse}`); // 'Mouse - $314'
console.log(mouse instanceof Producto); // trueError común
Olvidar new: const p = Producto('Mouse', 349) lanza TypeError: Class constructor Producto cannot be invoked without 'new'. Y por convención las clases se nombran en PascalCase (CarritoDeCompras), las instancias en camelCase.
Campos de clase y campos privados
Puedes declarar propiedades directamente en el cuerpo de la clase. Con # se vuelven privadas: sólo accesibles desde dentro de la clase.
js
class CuentaBancaria {
// Campos públicos con valor inicial
moneda = 'MXN';
// Campos privados (encapsulamiento real)
#saldo = 0;
#movimientos = [];
constructor(titular, saldoInicial = 0) {
this.titular = titular;
if (saldoInicial > 0) this.depositar(saldoInicial);
}
depositar(monto) {
this.#validarMonto(monto);
this.#saldo += monto;
this.#registrar('depósito', monto);
return this;
}
retirar(monto) {
this.#validarMonto(monto);
if (monto > this.#saldo) {
throw new Error(`Saldo insuficiente. Disponible: $${this.#saldo}`);
}
this.#saldo -= monto;
this.#registrar('retiro', monto);
return this;
}
// Getter: se lee como propiedad (cuenta.saldo), pero no se puede asignar
get saldo() {
return this.#saldo;
}
get historial() {
return [...this.#movimientos]; // copia: nadie puede modificar el original
}
// Métodos privados
#validarMonto(monto) {
if (typeof monto !== 'number' || !Number.isFinite(monto) || monto <= 0) {
throw new Error('El monto debe ser un número mayor a 0');
}
}
#registrar(tipo, monto) {
this.#movimientos.push({ tipo, monto, fecha: new Date(), saldo: this.#saldo });
}
}
const cuenta = new CuentaBancaria('Ana', 1000);
cuenta.depositar(500).retirar(200);
console.log(cuenta.saldo); // 1300
cuenta.saldo = 1000000; // se ignora (sólo tiene getter; en modo estricto lanza error)
// cuenta.#saldo // SyntaxError: campo privadoGetters y setters
Los setters te permiten validar al asignar:
js
class Usuario {
#email = '';
constructor(nombre, email) {
this.nombre = nombre;
this.email = email; // pasa por el setter
}
get email() {
return this.#email;
}
set email(valor) {
const limpio = String(valor).trim().toLowerCase();
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(limpio)) {
throw new Error(`Email inválido: ${valor}`);
}
this.#email = limpio;
}
get iniciales() { // propiedad calculada
return this.nombre.split(' ').map((p) => p[0]).join('').toUpperCase();
}
}
const u = new Usuario('Ana López', ' ANA@Correo.com ');
u.email; // 'ana@correo.com'
u.iniciales; // 'AL'Miembros estáticos
static define propiedades y métodos que pertenecen a la clase, no a cada instancia. Útiles para utilidades y "constructores alternativos":
js
class Producto {
static #contador = 0;
static IVA = 0.16;
constructor(nombre, precio) {
this.id = ++Producto.#contador;
this.nombre = nombre;
this.precio = precio;
}
get precioConIva() {
return this.precio * (1 + Producto.IVA);
}
// Constructor alternativo: crear desde la respuesta de una API
static desdeApi(datos) {
return new Producto(datos.title, Number(datos.price));
}
static compararPorPrecio(a, b) {
return a.precio - b.precio;
}
}
const p = Producto.desdeApi({ title: 'Silla', price: '2499' });
const ordenados = [p, new Producto('Mesa', 1800)].sort(Producto.compararPorPrecio);Herencia: extends y super
Una clase puede extender a otra: hereda sus propiedades y métodos y puede añadir o sobrescribir.
js
class Empleado {
constructor(nombre, salario) {
this.nombre = nombre;
this.salario = salario;
}
calcularPago() {
return this.salario;
}
describir() {
return `${this.nombre} cobra $${this.calcularPago()}`;
}
}
class Gerente extends Empleado {
constructor(nombre, salario, bono) {
super(nombre, salario); // llama al constructor del padre (obligatorio antes de usar this)
this.bono = bono;
}
// Sobrescribir (polimorfismo)
calcularPago() {
return super.calcularPago() + this.bono; // reutiliza la lógica del padre
}
}
class Becario extends Empleado {
calcularPago() {
return this.salario * 0.5;
}
}
const equipo = [
new Empleado('Luis', 20000),
new Gerente('María', 35000, 8000),
new Becario('Pedro', 12000)
];
// Polimorfismo: el mismo método, distinto comportamiento
equipo.forEach((e) => console.log(e.describir()));
// Luis cobra $20000
// María cobra $43000
// Pedro cobra $6000Error común
En una clase hija, si defines constructor, debes llamar a super(...) antes de usar this. Si no, obtienes ReferenceError: Must call super constructor.
Errores personalizados con herencia
Un uso muy práctico de extends:
js
class ValidacionError extends Error {
constructor(campo, mensaje) {
super(mensaje);
this.name = 'ValidacionError';
this.campo = campo;
}
}
try {
throw new ValidacionError('email', 'El correo es obligatorio');
} catch (error) {
if (error instanceof ValidacionError) {
console.log(`Error en ${error.campo}: ${error.message}`);
}
}this: el gran malentendido
this no depende de dónde se escribió la función, sino de cómo se llama (excepto en las funciones flecha).
js
const usuario = {
nombre: 'Ana',
saludar() {
return `Hola, soy ${this.nombre}`;
}
};
usuario.saludar(); // 'Hola, soy Ana' → this = usuario (lo que está antes del punto)
const suelta = usuario.saludar;
suelta(); // TypeError o 'Hola, soy undefined' → se perdió el thisLas reglas, en orden:
- Llamada con
new→thises el objeto nuevo. - Llamada como método
obj.metodo()→thisesobj. - Con
call,applyobind→thises el que indiques. - Llamada suelta
funcion()→undefineden modo estricto (clases y módulos). - Funciones flecha: no tienen
thispropio; usan el del lugar donde se escribieron.
La trampa clásica: callbacks y eventos
js
class Temporizador {
segundos = 0;
iniciar() {
// ❌ function normal: this NO es el temporizador
// setInterval(function () { this.segundos++; }, 1000);
// ✅ flecha: hereda el this de iniciar()
setInterval(() => {
this.segundos++;
}, 1000);
}
}
class Contador {
valor = 0;
constructor(boton) {
// ❌ al pasar el método suelto se pierde el this
// boton.addEventListener('click', this.incrementar);
// ✅ Opción 1: bind
boton.addEventListener('click', this.incrementar.bind(this));
// ✅ Opción 2: flecha que envuelve
// boton.addEventListener('click', () => this.incrementar());
}
incrementar() {
this.valor++;
}
}
// ✅ Opción 3: definir el método como campo con flecha (queda "atado" a la instancia)
class Contador2 {
valor = 0;
incrementar = () => {
this.valor++;
};
}Composición vs. herencia
La herencia crea relaciones "es un" (un Gerente es un Empleado). Pero las jerarquías profundas se vuelven rígidas: ¿qué pasa con un "Gerente becario remoto"? La composición crea relaciones "tiene un" o "puede": combinas piezas pequeñas.
js
// Comportamientos como funciones reutilizables
const puedeVolar = (obj) => ({
volar: () => `${obj.nombre} está volando`
});
const puedeNadar = (obj) => ({
nadar: () => `${obj.nombre} está nadando`
});
function crearPato(nombre) {
const pato = { nombre };
return { ...pato, ...puedeVolar(pato), ...puedeNadar(pato) };
}
const pato = crearPato('Donald');
pato.volar(); // 'Donald está volando'
pato.nadar();
// Composición con clases: un objeto que contiene otros
class Pedido {
constructor(cliente, notificador) {
this.cliente = cliente;
this.notificador = notificador; // "tiene un" notificador (email, SMS, WhatsApp...)
this.items = [];
}
confirmar() {
this.notificador.enviar(this.cliente, 'Tu pedido fue confirmado');
}
}
const notificadorEmail = { enviar: (cliente, msg) => console.log(`Email a ${cliente}: ${msg}`) };
new Pedido('ana@correo.com', notificadorEmail).confirmar();Buena práctica
"Prefiere composición sobre herencia". Usa herencia para jerarquías cortas y claras (1 o 2 niveles, como errores personalizados) y composición para combinar comportamientos. Y recuerda que no todo necesita ser una clase: muchas veces un objeto literal o un módulo con funciones es más simple.
Ejercicios
- Crea una clase
Rectanguloconanchoyalto, un getterareay un métodoescalar(factor). - Crea
Tareacontitulo,completada = falsey los métodoscompletar()ytoString()("[x] Comprar pan"o"[ ] Comprar pan"). - Crea
Productoy una clase hijaProductoDigitalque añadaurlDescargay sobrescribacalcularEnvio()para que devuelva 0 (el producto físico cuesta $99 de envío). - Crea una clase
Inventariocon unMapprivado y los métodosagregar(producto, cantidad),retirar(id, cantidad)(con error si no alcanza) y un gettertotal. - Explica y arregla:
const btn = document.querySelector('button'); btn.addEventListener('click', miContador.incrementar);no funciona.
Ver solución
js
// 1
class Rectangulo {
constructor(ancho, alto) { this.ancho = ancho; this.alto = alto; }
get area() { return this.ancho * this.alto; }
escalar(factor) { this.ancho *= factor; this.alto *= factor; return this; }
}
// 2
class Tarea {
completada = false;
constructor(titulo) { this.titulo = titulo; }
completar() { this.completada = true; return this; }
toString() { return `[${this.completada ? 'x' : ' '}] ${this.titulo}`; }
}
// 3
class Producto {
constructor(nombre, precio) { this.nombre = nombre; this.precio = precio; }
calcularEnvio() { return 99; }
total() { return this.precio + this.calcularEnvio(); }
}
class ProductoDigital extends Producto {
constructor(nombre, precio, urlDescarga) {
super(nombre, precio);
this.urlDescarga = urlDescarga;
}
calcularEnvio() { return 0; }
}
// 4
class Inventario {
#items = new Map(); // id → { producto, cantidad }
agregar(producto, cantidad = 1) {
const actual = this.#items.get(producto.id)?.cantidad ?? 0;
this.#items.set(producto.id, { producto, cantidad: actual + cantidad });
}
retirar(id, cantidad = 1) {
const item = this.#items.get(id);
if (!item || item.cantidad < cantidad) throw new Error('Stock insuficiente');
item.cantidad -= cantidad;
}
get total() {
return [...this.#items.values()].reduce((s, i) => s + i.producto.precio * i.cantidad, 0);
}
}
// 5: el método se pasa "suelto", así que al ejecutarse this ya no es miContador.
btn.addEventListener('click', () => miContador.incrementar());
// o: btn.addEventListener('click', miContador.incrementar.bind(miContador));Resumen
- Una clase es un molde;
newcrea instancias;constructorlas inicializa. - Los campos
#privadosy los getters/setters dan encapsulamiento real y validación. staticpertenece a la clase (utilidades, constructores alternativos).extends+superpermiten heredar y sobrescribir (polimorfismo).thisdepende de cómo se llama la función; las flechas heredan elthisexterior. Usa flechas obinden callbacks.- Prefiere composición sobre herencia profunda.
Siguiente: Errores y depuración