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:
// 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:
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#
// 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 Heroedefine el molde.constructores la función que se ejecuta cuando hacesnew Heroe(...). Recibe los argumentos y los guarda.thisdentro del constructor es el objeto que se está creando. Cuando escribesthis.nombre = nombre, estás diciendo “guarda este dato en el objeto actual”.
Crear instancias con new#
// 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:
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.
// hay un objeto a la izquierda del punto → this = tracer ✓
tracer.winrate();El problema aparece cuando separas el método de su instancia:
// 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:
// 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.
// 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.
// 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:
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.
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ón | Herramienta |
|---|---|
| Agrupar datos sin comportamiento, una sola instancia | Objeto literal {} |
| Una función con estado interno, una sola instancia | Cierre (closure) |
| Múltiples instancias del mismo molde con comportamiento | Clase |
| Jerarquía de tipos genuina (es-un) | Clase con herencia |
| Componentes en React moderno | Funció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.
Paso 2: Que esté pulido
- El constructor solo almacena los parámetros recibidos, sin precalcular nada.
- resumen() llama a this.winrate() en lugar de recalcular el porcentaje.
- Nombres claros y this usado con coherencia en todos los métodos.
Paso 3: Que sea excelente
- Los datos de partidas/victorias son campos privados (#) o getters: no se pueden pisar desde fuera.
- Si usas herencia, modela una relación real "es-un", no reutilización de código sin más.
- El código incluye un comentario que explica cuándo una clase NO es la herramienta adecuada.
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.
// ════════════════════════════════════════════════════════════════════════════
// NIVEL MEJOR — "pulido"
//
// El constructor solo almacena lo que recibe: nada precalculado.
// `winrate()` es la fuente de verdad del porcentaje; `resumen()` la llama.
// Una sola responsabilidad por método, `this` usado con coherencia.
//
// Todavía se puede mejorar: nada impide que alguien haga
// `tracer.victorias = 9999` desde fuera y los datos pierdan sentido.
// El nivel Excelente aborda eso.
// ════════════════════════════════════════════════════════════════════════════
class Heroe {
constructor(nombre, rol, partidas, victorias) {
this.nombre = nombre;
this.rol = rol;
this.partidas = partidas;
this.victorias = victorias;
}
// Una sola fuente de verdad para el winrate.
winrate() {
return (this.victorias / this.partidas * 100).toFixed(1);
}
// resumen() delega en winrate(): sin duplicación.
resumen() {
return `${this.nombre} (${this.rol}) — ${this.partidas} partidas — Winrate: ${this.winrate()}%`;
}
}
const tracer = new Heroe('Tracer', 'Daño', 120, 78);
const mercy = new Heroe('Mercy', 'Apoyo', 200, 130);
const reinhardt = new Heroe('Reinhardt', 'Tanque', 90, 51);
console.log(tracer.resumen());
console.log(mercy.resumen());
console.log(reinhardt.resumen()); Por qué es mejor que el anterior
- El constructor solo guarda lo que recibe: una sola responsabilidad.
- winrate() es la única fuente de verdad; resumen() la llama. Si cambia la fórmula, se cambia en un solo lugar.
- this coherente en todos los métodos: predecible, sin sorpresas.
// ════════════════════════════════════════════════════════════════════════════
// NIVEL EXCELENTE — "óptimo"
//
// Los datos de partidas/victorias son privados (#): nadie puede pisarlos
// desde fuera accidentalmente. Se exponen solo como lectura mediante getters.
// La herencia se usa donde de verdad añade algo: CapitanHeroe extiende Heroe
// porque modela un "es-un-héroe-con-algo-extra", no para reutilizar código.
//
// Nota honesta al final: una clase NO siempre es la respuesta.
// ════════════════════════════════════════════════════════════════════════════
class Heroe {
// Campos privados: solo accesibles desde dentro de la clase.
#partidas;
#victorias;
constructor(nombre, rol, partidas, victorias) {
this.nombre = nombre;
this.rol = rol;
this.#partidas = partidas;
this.#victorias = victorias;
}
// Getter: expone el dato como propiedad de solo lectura.
get partidas() {
return this.#partidas;
}
get victorias() {
return this.#victorias;
}
winrate() {
return (this.#victorias / this.#partidas * 100).toFixed(1);
}
resumen() {
return `${this.nombre} (${this.rol}) — ${this.#partidas} partidas — Winrate: ${this.winrate()}%`;
}
}
// La herencia tiene sentido cuando hay una relación genuina "es-un".
// Un CapitanHeroe es un Heroe con un rol de liderazgo adicional.
class CapitanHeroe extends Heroe {
constructor(nombre, rol, partidas, victorias, lema) {
super(nombre, rol, partidas, victorias);
this.lema = lema;
}
// Extiende resumen() sin duplicar la lógica del padre.
resumen() {
return `${super.resumen()} — Capitán: "${this.lema}"`;
}
}
const tracer = new Heroe('Tracer', 'Daño', 120, 78);
const mercy = new Heroe('Mercy', 'Apoyo', 200, 130);
const reinhardt = new CapitanHeroe('Reinhardt', 'Tanque', 90, 51, 'La barrera aguanta');
console.log(tracer.resumen());
console.log(mercy.resumen());
console.log(reinhardt.resumen());
// Los campos privados impiden modificaciones accidentales desde fuera.
// tracer.#victorias = 9999; // SyntaxError: campo privado inaccesible.
// ─── Cuándo NO usar una clase ────────────────────────────────────────────────
// Si solo necesitas agrupar datos sin comportamiento, un objeto literal basta:
// const tracer = { nombre: 'Tracer', rol: 'Daño', partidas: 120, victorias: 78 };
// Si tienes comportamiento pero solo una instancia, una función con cierre basta:
// function crearHeroe(...) { return { winrate() {...}, resumen() {...} }; }
// Una clase aporta cuando necesitas MÚLTIPLES instancias del mismo molde
// Y quieres encapsular estado mutable o una jerarquía real de tipos.
// En React moderno, los componentes son funciones: las clases son legacy allí. Por qué es mejor que el anterior
- Los campos privados (#) garantizan que nadie pisa los datos desde fuera sin querer.
- La herencia en CapitanHeroe modela un "es-un" genuino y extiende resumen() sin copiar código del padre.
- El comentario final es parte del nivel: saber cuándo NO usar una clase es tan valioso como saber cómo escribirla.