Skip to content

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:

  1. Encapsulamiento: cada objeto guarda sus datos y protege los internos.
  2. Abstracción: expones lo necesario (cuenta.depositar(100)) y ocultas los detalles.
  3. Herencia: una clase puede extender a otra y reutilizar su comportamiento.
  4. 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; // true

La 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); // true

Error 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 privado
Cuenta bancaria con clases

Getters 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 $6000

Error 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 this

Las reglas, en orden:

  1. Llamada con new → this es el objeto nuevo.
  2. Llamada como método obj.metodo() → this es obj.
  3. Con call, apply o bind → this es el que indiques.
  4. Llamada suelta funcion() → undefined en modo estricto (clases y módulos).
  5. Funciones flecha: no tienen this propio; 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 ​

  1. Crea una clase Rectangulo con ancho y alto, un getter area y un método escalar(factor).
  2. Crea Tarea con titulo, completada = false y los métodos completar() y toString() ("[x] Comprar pan" o "[ ] Comprar pan").
  3. Crea Producto y una clase hija ProductoDigital que añada urlDescarga y sobrescriba calcularEnvio() para que devuelva 0 (el producto físico cuesta $99 de envío).
  4. Crea una clase Inventario con un Map privado y los métodos agregar(producto, cantidad), retirar(id, cantidad) (con error si no alcanza) y un getter total.
  5. 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; new crea instancias; constructor las inicializa.
  • Los campos #privados y los getters/setters dan encapsulamiento real y validación.
  • static pertenece a la clase (utilidades, constructores alternativos).
  • extends + super permiten heredar y sobrescribir (polimorfismo).
  • this depende de cómo se llama la función; las flechas heredan el this exterior. Usa flechas o bind en callbacks.
  • Prefiere composición sobre herencia profunda.

Siguiente: Errores y depuración

Academia Web — aprende, copia y construye.