learning-front

Nivel 2 · JavaScript: fundamentos del lenguaje

Clases y POO en JavaScript

Clases, this y herencia: la otra forma de organizar código que leerás en proyectos reales, aunque React sea funcional.

Hasta ahora, para agrupar datos relacionados usábamos objetos literales: { nombre: 'Tracer', rol: 'Daño', partidas: 120, victorias: 78 }. Funciona bien cuando solo guardas datos. Pero cuando cada héroe necesita también comportamiento —calcular su winrate, generar su resumen, compararse con otro—, empezar a pasar funciones y objetos separados se vuelve difícil de mantener.

Las clases resuelven eso: agrupan datos y el comportamiento que opera sobre ellos en un único molde reutilizable. Con new creas tantas instancias como necesites, cada una con sus propios datos pero compartiendo los mismos métodos.

Antes de entrar en la sintaxis, un encuadre honesto: React moderno es funcional. Los componentes de React son funciones, no clases. Si llegas a este curso pensando que las clases son el futuro del frontend, no lo son en React. Pero sí aparecen en:

  • Librerías y frameworks (algunas usan clases internamente)
  • Error Boundaries en React (el único sitio donde React aún exige una clase)
  • Backends en TypeScript (NestJS, por ejemplo, es muy orientado a clases)
  • Código legacy que tendrás que leer y mantener

Aprender clases te hace más completo, no porque sean “mejores” que las funciones, sino porque están ahí en el código real.

El problema que resuelve una clase#

Imagina que tienes que crear cincuenta héroes. Con objetos literales:

javascript
// un héroe como objeto literal
const tracer = { nombre: 'Tracer', rol: 'Daño', partidas: 120, victorias: 78 };
// otro, repitiendo la misma forma a mano
const mercy  = { nombre: 'Mercy',  rol: 'Apoyo', partidas: 200, victorias: 130 };
// ... y si quieres calcular el winrate de cada uno...
// la lógica vive aparte, suelta de los datos
function winrate(heroe) { return heroe.victorias / heroe.partidas; }

Funciona, pero el objeto y la función que lo procesa viven separados. Una clase los une:

javascript
class Heroe {
  constructor(nombre, rol, partidas, victorias) {
    /* ... */
  }
  winrate() {
    /* usa this.victorias y this.partidas */
  }
}

Ahora el molde y el comportamiento son inseparables.

class, constructor y this#

javascript
// declara el molde con nombre Heroe
class Heroe {
  // se ejecuta al hacer new Heroe(...)
  constructor(nombre, rol, partidas, victorias) {
    // this es el objeto que se está creando
    this.nombre    = nombre;
    // cada propiedad queda guardada en ese objeto
    this.rol       = rol;
    this.partidas  = partidas;
    this.victorias = victorias;
  }
}
  • class Heroe define el molde.
  • constructor es la función que se ejecuta cuando haces new Heroe(...). Recibe los argumentos y los guarda.
  • this dentro del constructor es el objeto que se está creando. Cuando escribes this.nombre = nombre, estás diciendo “guarda este dato en el objeto actual”.

Crear instancias con new#

javascript
// crea un objeto nuevo con esos datos
const tracer = new Heroe('Tracer', 'Daño', 120, 78);
// otro objeto independiente, mismo molde
const mercy  = new Heroe('Mercy', 'Apoyo', 200, 130);

new hace tres cosas: crea un objeto vacío, ejecuta el constructor con ese objeto como this, y devuelve el objeto ya inicializado. Ahora tracer y mercy son dos objetos distintos, cada uno con sus propios datos, pero construidos con el mismo molde.

Métodos de instancia#

Un método de instancia es una función definida dentro de la clase que opera sobre los datos de esa instancia concreta, a través de this:

javascript
class Heroe {
  // igual que antes: guarda los datos
  constructor(nombre, rol, partidas, victorias) {
    this.nombre    = nombre;
    this.rol       = rol;
    this.partidas  = partidas;
    this.victorias = victorias;
  }

  // método de instancia
  winrate() {
    // this.victorias = los datos de ESTA instancia
    return (this.victorias / this.partidas * 100).toFixed(1);
  }

  resumen() {
    // delega en this.winrate(): no duplica la fórmula
    return `${this.nombre} (${this.rol}) — ${this.partidas} partidas — Winrate: ${this.winrate()}%`;
  }
}

const tracer = new Heroe('Tracer', 'Daño', 120, 78);
// "65.0"
console.log(tracer.winrate());
// "Tracer (Daño) — 120 partidas — Winrate: 65.0%"
console.log(tracer.resumen());

Fíjate en que resumen() llama a this.winrate(): no duplica la fórmula, delega. Si mañana decides que el winrate se calcula de otra forma, lo cambias en un único lugar.

El lío de this#

El valor de this lo decide el sitio donde se llama la función, no donde está definida. La regla es concreta: si hay un objeto a la izquierda del punto en la llamada, ese objeto es this. Si no hay objeto a la izquierda, this no es la instancia.

javascript
// hay un objeto a la izquierda del punto → this = tracer ✓
tracer.winrate();

El problema aparece cuando separas el método de su instancia:

javascript
// Guardamos la función en una variable, sin llamarla todavía
// fn es la función en sí, desconectada de tracer
const fn = tracer.winrate;

// Al llamarla suelta, no hay objeto a la izquierda del punto
// En módulos y modo estricto, this es undefined aquí
// → al ejecutar this.victorias, el motor lanza:
//   TypeError: Cannot read properties of undefined (reading 'victorias')
fn();

No hace falta inventarse el escenario: pasa en cuanto pasas un método como callback. El caso más típico es un manejador de evento del DOM, que ya viste en el capítulo anterior:

javascript
// un botón cualquiera de la página
const boton = document.querySelector('button');
// pasamos la función suelta, sin tracer delante
boton.addEventListener('click', tracer.winrate);
// Cuando llega el clic, el navegador la llama sin tracer a la izquierda → this no es la
// instancia → al ejecutar this.victorias, el mismo TypeError de antes.

Lo que ocurre en los dos casos es idéntico: la función pierde la referencia a su objeto porque quien la llama no lo pone a la izquierda del punto. No es un bug de tu código ni del motor; es cómo funciona this en JavaScript.

Regla práctica: llama siempre los métodos desde la instancia (instancia.metodo()). Mientras hagas eso, this será siempre la instancia y no habrá sorpresas.

Cuando necesitas pasar un método como callback —a un addEventListener, a un setTimeout, a un forEach— y no puedes llamarlo desde la instancia, tienes dos salidas limpias. La opción A usa una arrow function: a diferencia de las funciones normales, una arrow no tiene su propio this; hereda el this del scope donde se define. Eso significa que si defines la arrow en un sitio donde this ya es lo que necesitas, la arrow lo conserva aunque quien la llame sea un callback externo.

javascript
// Opción A — arrow function: envuelve la llamada en una arrow.
// La arrow no tiene this propio: hereda el this del scope donde se DEFINE.
// Como se define aquí, en el scope donde tracer existe y la llamada
// tracer.winrate() tiene el objeto a la izquierda, this es correcto.
boton.addEventListener('click', () => tracer.winrate());

// Opción B — bind: devuelve una copia del método con this fijado para siempre.
// this = tracer, permanente
boton.addEventListener('click', tracer.winrate.bind(tracer));

Ambas resuelven el problema. La elección entre una y otra, y los seis casos completos de this (incluyendo call, apply y arrows como métodos), están en el capítulo bonus «this a fondo» de este nivel.

Todos los detalles de this —funciones sueltas, callbacks, diferencias entre function y arrow function como método, y las herramientas bind, call y apply (que sirven para fijar tú mismo qué objeto es this en una llamada, en vez de depender de quién la invoca)— se ven a fondo en el capítulo bonus «this a fondo» de este nivel.

Herencia: extends y super#

La herencia permite crear una clase que extiende otra: hereda todo lo del padre y puede añadir o sobreescribir cosas. La relación que modela es “es-un”: un CapitanHeroe es un Heroe con algo más.

javascript
// extends: hereda todo lo de Heroe
class CapitanHeroe extends Heroe {
  constructor(nombre, rol, partidas, victorias, lema) {
    // obligatorio: inicializa la parte Heroe
    super(nombre, rol, partidas, victorias);
    // luego añade la propiedad propia
    this.lema = lema;
  }

  // sobreescribe resumen() del padre
  resumen() {
    // super.resumen() llama al método del padre; no copia su código, lo reutiliza
    return `${super.resumen()} — Capitán: "${this.lema}"`;
  }
}

const reinhardt = new CapitanHeroe('Reinhardt', 'Tanque', 90, 51, 'La barrera aguanta');
console.log(reinhardt.resumen());
// "Reinhardt (Tanque) — 90 partidas — Winrate: 56.7% — Capitán: "La barrera aguanta""

super(...) en el constructor es obligatorio antes de usar this: inicializa la parte “héroe” del objeto. super.resumen() en el método llama a la versión del padre y la extiende.

Aviso sobre la herencia: es una herramienta potente pero que se abusa fácilmente. Úsala solo cuando la relación “es-un” sea genuina. Si lo que quieres es reutilizar código, considera en cambio composición: que un objeto contenga otro en lugar de extenderlo. La regla que escucharás en cualquier equipo senior: composición antes que herencia.

Campos privados con ##

Por defecto, todas las propiedades de una instancia son públicas: cualquier código puede leerlas y modificarlas. A veces eso es un problema: si alguien escribe tracer.victorias = 9999, los datos pierden su integridad.

Los campos privados (con #) resuelven eso: el motor prohíbe el acceso desde fuera de la clase:

javascript
class Heroe {
  // campo privado: solo accesible desde dentro de la clase
  #partidas;
  // la # es parte del nombre; no es una convención, es sintaxis del lenguaje
  #victorias;

  constructor(nombre, rol, partidas, victorias) {
    // público: cualquiera puede leerlo y modificarlo
    this.nombre    = nombre;
    this.rol       = rol;
    // privado: se guarda pero no se puede tocar desde fuera
    this.#partidas  = partidas;
    this.#victorias = victorias;
  }

  winrate() {
    // accede a los privados desde dentro: correcto
    return (this.#victorias / this.#partidas * 100).toFixed(1);
  }
}

const tracer = new Heroe('Tracer', 'Daño', 120, 78);
// SyntaxError: campo privado inaccesible desde fuera
// tracer.#victorias = 9999;

Si quieres exponer un campo privado como solo lectura, puedes usar un getter.

Un getter es un método especial que se lee como si fuera una propiedad, sin paréntesis. Lo defines con la palabra clave get seguida del nombre, igual que un método, pero quien lo llama no lo ve como función: lo ve como una propiedad normal.

javascript
class Heroe {
  #partidas;
  #victorias;

  constructor(nombre, rol, partidas, victorias) {
    this.nombre    = nombre;
    this.rol       = rol;
    this.#partidas  = partidas;
    this.#victorias = victorias;
  }

  // getter: se define con 'get' + nombre, igual que un método
  get partidas() {
    // devuelve el campo privado; quien lo lea recibe el valor
    return this.#partidas;
  }
}

const tracer = new Heroe('Tracer', 'Daño', 120, 78);
// Se llama SIN paréntesis, como si fuera una propiedad:
// 120 — el getter devuelve this.#partidas
console.log(tracer.partidas);
// No hay setter definido → intentar escribir lanza un TypeError
// tracer.partidas = 0;

En el contexto de este curso los ficheros son módulos ES (type="module" o extensión .mjs), y los módulos siempre se ejecutan en modo estricto: el TypeError es lo que ocurrirá, sin excepción. El dato interno permanece intacto.

Clase vs objeto literal: ¿cuándo usar cada uno?#

Las clases no son siempre la respuesta. Elige según la situación:

SituaciónHerramienta
Agrupar datos sin comportamiento, una sola instanciaObjeto literal {}
Una función con estado interno, una sola instanciaCierre (closure)
Múltiples instancias del mismo molde con comportamientoClase
Jerarquía de tipos genuina (es-un)Clase con herencia
Componentes en React modernoFunción (no clase)

La clave es que una clase añade complejidad. Esa complejidad vale la pena cuando hay muchas instancias del mismo molde o cuando la encapsulación del estado importa. Si no es el caso, un objeto literal o una función hacen el trabajo con menos ruido.

Pruébalo tú#

El playground muestra la clase completa con herencia. Prueba a cambiar el lema de Reinhardt, añadir un héroe nuevo, o modificar la fórmula del winrate y observa cómo todas las instancias se actualizan desde un único método.

Comprueba lo que sabes#

Pregunta 1 de 5

¿Qué hace `new Heroe('Tracer', 'Daño', 120, 78)` exactamente?

Tu turno#

Completa la clase Heroe en el playground: constructor, winrate() y resumen(). Crea dos instancias e imprime su resumen. Cuando lo tengas, despliega las soluciones y fíjate en qué separa cada nivel del anterior: el salto de OK a Mejor es sobre responsabilidad única; el de Mejor a Excelente es sobre encapsulación y criterio.

Ejercicio · en esta página

Modela el roster con una clase

Completa la clase Heroe con constructor, winrate() y resumen(). Crea instancias de varios héroes e imprime su resumen en la consola. Cuando funcione, lee los comentarios de cada tier para entender qué separa un nivel del siguiente.

Paso 1: Que funcione

  • La clase tiene constructor, winrate() y resumen().
  • Se crean al menos dos instancias.
  • La consola muestra el resumen correcto de cada héroe.
Ver soluciones
// ════════════════════════════════════════════════════════════════════════════
// NIVEL OK — "funciona"
//
// La clase existe, el constructor guarda los datos y los métodos devuelven
// resultados correctos. Pero hay problemas que no se ven a primera vista:
//
//   - `this` se usa bien dentro de los métodos, pero el constructor
//     también recalcula el winrate y lo guarda como propiedad. Si alguien
//     cambia `this.victorias` después, el `this.winrate` almacenado queda
//     desfasado. Dos fuentes de verdad para un mismo dato.
//   - `resumen()` vuelve a calcular el winrate en vez de llamar a
//     `this.winrate()`: código duplicado.
//   - Nombres un poco imprecisos (`w` como parámetro interno no ayuda).
//
// Aun así: las instancias se crean, los métodos devuelven lo correcto y
// la consola muestra el resumen esperado. Eso es un OK.
// ════════════════════════════════════════════════════════════════════════════

class Heroe {
  constructor(nombre, rol, partidas, victorias) {
    this.nombre = nombre;
    this.rol = rol;
    this.partidas = partidas;
    this.victorias = victorias;
    // Precalculado en el constructor: si victorias cambia, esto queda obsoleto.
    this.wr = (victorias / partidas * 100).toFixed(1);
  }

  winrate() {
    // El método recalcula, ignorando el this.wr del constructor.
    return (this.victorias / this.partidas * 100).toFixed(1);
  }

  resumen() {
    // Calcula el winrate aquí en vez de llamar a this.winrate(): duplicación.
    const w = (this.victorias / this.partidas * 100).toFixed(1);
    return `${this.nombre} (${this.rol}) — ${this.partidas} partidas — Winrate: ${w}%`;
  }
}

const tracer = new Heroe('Tracer', 'Daño', 120, 78);
const mercy = new Heroe('Mercy', 'Apoyo', 200, 130);

console.log(tracer.resumen());
console.log(mercy.resumen());

Por qué este nivel

  • La clase funciona: constructor, métodos y consola correcta. Punto de partida sólido.
  • Pero hay dos fuentes de verdad para el winrate: this.wr en el constructor y el recálculo en resumen(). Si un dato cambia, las dos pueden desincronizarse.
  • resumen() ignora el método winrate() y vuelve a calcular: código duplicado que mantener en dos sitios.