Clases en TypeScript
En esta lección
- Propiedades tipadas
- Modificadores de acceso: public, private y protected
- readonly: propiedades que no cambian
- Parameter properties: el atajo del constructor
- private de TypeScript frente a #privado de JavaScript
- Getters y setters
- Miembros static
- implements: cumplir una interfaz
- Clases abstractas
- Herencia y override
- Errores frecuentes
- Resumen
Ya sabes crear clases en JavaScript con constructor, métodos, #privados, getters, static y extends. TypeScript no inventa otra forma de hacer clases: usa exactamente la misma sintaxis y le añade tipos y algunas palabras clave para controlar quién puede tocar cada dato. En esta lección verás cómo tipar propiedades y métodos, los modificadores public, private, protected y readonly, el atajo de las parameter properties, cómo una clase cumple una interfaz con implements, las clases abstractas y la palabra override.
Propiedades tipadas
En JavaScript las propiedades aparecen en cuanto haces this.algo = ... en el constructor. En TypeScript tienes que declararlas en el cuerpo de la clase, con su tipo:
class Socio {
nombre: string;
cuota: number;
activo = true; // el tipo boolean se infiere del valor inicial
constructor(nombre: string, cuota: number) {
this.nombre = nombre;
this.cuota = cuota;
}
cuotaAnual(): number {
return this.cuota * 12;
}
}
const lucia = new Socio("Lucía", 35);
console.log(lucia.cuotaAnual()); // 420
Los parámetros del constructor y de los métodos se tipan igual que en cualquier función. El tipo de retorno (: number) es opcional porque TypeScript lo infiere, pero ponerlo en los métodos públicos documenta la clase y evita que cambie sin querer.
Toda propiedad debe quedar inicializada
Con strict activado, TypeScript comprueba que cada propiedad recibe un valor, ya sea al declararla o en el constructor. Si se te olvida, avisa:
class Ruta {
nombre: string;
kilometros: number;
// error TS2564: Property 'kilometros' has no initializer and is not definitely assigned in the constructor.
constructor(nombre: string) {
this.nombre = nombre;
}
}
Tienes tres soluciones según el caso: darle un valor inicial (kilometros = 0;), asignarlo en el constructor, o declararla opcional (kilometros?: number;) si de verdad puede faltar. Verás en código ajeno un cuarto truco, kilometros!: number;, que le dice a TypeScript “confía en mí, ya la asignaré”. Úsalo solo cuando otra cosa (una librería, un método de inicialización) rellena el dato y TypeScript no puede saberlo.
Modificadores de acceso: public, private y protected
TypeScript añade tres palabras que indican desde dónde se puede usar cada propiedad o método:
| Modificador | Accesible desde |
|---|---|
public (por defecto) | Cualquier sitio |
protected | La propia clase y sus clases hijas |
private | Solo la propia clase |
class Taquilla {
public numero: number;
private codigo: string;
constructor(numero: number, codigo: string) {
this.numero = numero;
this.codigo = codigo;
}
abrir(intento: string): boolean {
return intento === this.codigo; // dentro de la clase sí se puede
}
}
const t = new Taquilla(14, "4821");
console.log(t.numero); // 14
console.log(t.abrir("0000")); // false
console.log(t.codigo);
// error TS2341: Property 'codigo' is private and only accessible within class 'Taquilla'.
public casi nunca se escribe porque es lo que hay por defecto, pero algunos equipos lo ponen para dejarlo explícito. La idea es la misma que la encapsulación en Java: ocultas los datos internos y obligas a usarlos a través de métodos que no dejan el objeto en un estado imposible.
readonly: propiedades que no cambian
Una propiedad readonly solo se puede asignar al declararla o dentro del constructor. Después queda fija:
class Pedido {
readonly id: string;
readonly fecha = new Date(2026, 0, 15);
total = 0;
constructor(id: string) {
this.id = id;
}
}
const p = new Pedido("P-001");
p.total = 49.9; // bien
p.id = "P-002";
// error TS2540: Cannot assign to 'id' because it is a read-only property.
Úsalo para identificadores, fechas de creación y cualquier dato que, una vez creado el objeto, no debería cambiar. Ojo: readonly impide reasignar la propiedad, pero si guarda un array u objeto, su contenido sí se puede modificar (p.lineas.push(...) funcionaría). Puedes combinarlo con los modificadores de acceso: private readonly codigo: string.
Parameter properties: el atajo del constructor
Fíjate en que en Taquilla el nombre numero aparece tres veces: en la declaración, en el parámetro y en la asignación. TypeScript tiene un atajo: si pones un modificador (public, private, protected o readonly) delante de un parámetro del constructor, declara y asigna la propiedad automáticamente. Se llaman parameter properties:
class Libro {
constructor(
public readonly isbn: string,
public titulo: string,
private prestado = false,
) {}
prestar(): string {
if (this.prestado) return `"${this.titulo}" ya está prestado`;
this.prestado = true;
return `Prestado: "${this.titulo}"`;
}
}
const libro = new Libro("978-84-376-0494-7", "Cien años de soledad");
console.log(libro.prestar()); // Prestado: "Cien años de soledad"
console.log(libro.prestar()); // "Cien años de soledad" ya está prestado
console.log(libro.isbn); // 978-84-376-0494-7
El constructor queda vacío ({}) porque no hay nada más que hacer. Es muy común en proyectos con Angular o NestJS.
Nota: las parameter properties generan código JavaScript (las asignaciones
this.isbn = isbn), así que no son “solo tipos”. Por eso no funcionan cuando ejecutas TypeScript con Node.js directamente ni con la opciónerasableSyntaxOnly, como verás en la lección de enums. Si tu proyecto usa esa opción, declara las propiedades a mano.
private de TypeScript frente a #privado de JavaScript
JavaScript ya tiene propiedades privadas con #. ¿Cuál usar? La diferencia es cuándo se protege el dato:
privatesolo existe para el compilador. Al compilar desaparece y la propiedad queda como una normal en el JavaScript final. Cualquiera puede leerla desde la consola o saltarse la comprobación con(objeto as any).codigo.#codigoes privado de verdad, también en ejecución. Ni siquiera la consola puede leerlo desde fuera.
class Monedero {
#saldo = 0;
private movimientos = 0;
ingresar(cantidad: number): void {
this.#saldo += cantidad;
this.movimientos++;
}
get saldo(): number {
return this.#saldo;
}
}
const m = new Monedero();
m.ingresar(20);
console.log(m.saldo); // 20
console.log((m as any).movimientos); // 1: private no protege en ejecución
console.log(Object.keys(m)); // [ 'movimientos' ]: #saldo ni aparece
Las dos opciones son válidas. # es estándar y seguro; private es más corto, se combina con parameter properties y es lo que verás en mucho código existente. Lo importante es ser coherente dentro de un proyecto.
Getters y setters
Funcionan como en JavaScript. TypeScript infiere el tipo de la propiedad a partir del getter, y si solo hay get (sin set), la propiedad es de solo lectura:
class Termostato {
#grados = 20;
get grados(): number {
return this.#grados;
}
set grados(valor: number) {
if (valor < 5 || valor > 30) {
throw new RangeError(`Temperatura fuera de rango: ${valor}`);
}
this.#grados = valor;
}
get descripcion(): string {
return `${this.#grados} °C`;
}
}
const salon = new Termostato();
salon.grados = 22;
console.log(salon.descripcion); // 22 °C
Si intentas salon.descripcion = "frío", el compilador protesta porque no hay setter. Los setters son el sitio natural para validar antes de guardar.
Miembros static
Las propiedades y métodos static pertenecen a la clase, no a cada objeto. En TypeScript se tipan igual y se pueden combinar con private y readonly:
class Ticket {
private static siguiente = 1;
static readonly PREFIJO = "TK";
readonly codigo: string;
constructor() {
this.codigo = `${Ticket.PREFIJO}-${Ticket.siguiente++}`;
}
static reiniciar(): void {
Ticket.siguiente = 1;
}
}
console.log(new Ticket().codigo); // TK-1
console.log(new Ticket().codigo); // TK-2
El contador siguiente es privado: solo la clase puede avanzarlo, así que nadie puede generar códigos repetidos desde fuera.
implements: cumplir una interfaz
Una interfaz describe la forma de un objeto. Con implements le dices a TypeScript que tu clase promete tener esa forma, y él lo comprueba:
interface Exportable {
formato: string;
exportar(): string;
}
class Factura implements Exportable {
formato = "csv";
constructor(private numero: number, private importe: number) {}
exportar(): string {
return `${this.numero};${this.importe.toFixed(2)}`;
}
}
class Informe implements Exportable {
formato = "txt";
// error TS2420: Class 'Informe' incorrectly implements interface 'Exportable'.
// Property 'exportar' is missing in type 'Informe' but required in type 'Exportable'.
}
Una clase puede implementar varias interfaces a la vez (class X implements A, B). Gracias a eso puedes escribir funciones que acepten cualquier cosa exportable sin importar su clase:
function guardar(doc: Exportable): string {
return `[${doc.formato}] ${doc.exportar()}`;
}
Nota:
implementssolo comprueba; no añade nada a la clase. Además, TypeScript compara tipos por su forma (tipado estructural): un objeto literal conformatoyexportartambién se puede pasar aguardaraunque no venga de ninguna clase. En Java, en cambio, hace falta declararimplementssí o sí.
Clases abstractas
Una clase abstracta es una clase incompleta: sirve de base para otras, pero no se pueden crear objetos de ella directamente. Puede tener métodos ya escritos y métodos abstract, que solo declaran la firma y obligan a las hijas a escribirlos:
abstract class Actividad {
constructor(protected minutos: number) {}
abstract caloriasPorMinuto(): number;
calorias(): number {
return Math.round(this.minutos * this.caloriasPorMinuto());
}
}
class Natacion extends Actividad {
caloriasPorMinuto(): number {
return 9.5;
}
}
class Escalada extends Actividad {
caloriasPorMinuto(): number {
return 8;
}
}
const sesiones: Actividad[] = [new Natacion(40), new Escalada(90)];
for (const s of sesiones) {
console.log(s.calorias());
}
// 380
// 720
const a = new Actividad(30);
// error TS2511: Cannot create an instance of an abstract class.
¿Interfaz o clase abstracta? Usa una interfaz cuando solo quieres describir qué debe tener algo. Usa una clase abstracta cuando además quieres compartir código entre las hijas (aquí, el cálculo de calorias). El concepto es el mismo que en Java.
Herencia y override
La herencia funciona con extends y super como en JavaScript. TypeScript añade la palabra override, que marca que un método sustituye a otro de la clase padre:
class Producto {
constructor(public nombre: string, public precio: number) {}
etiqueta(): string {
return `${this.nombre}: ${this.precio.toFixed(2)} €`;
}
}
class ProductoRebajado extends Producto {
constructor(nombre: string, precio: number, private descuento: number) {
super(nombre, precio);
}
override etiqueta(): string {
const final = this.precio * (1 - this.descuento);
return `${super.etiqueta()} -> ${final.toFixed(2)} € (rebajado)`;
}
}
const zapatillas = new ProductoRebajado("Zapatillas", 80, 0.25);
console.log(zapatillas.etiqueta()); // Zapatillas: 80.00 € -> 60.00 € (rebajado)
¿Para qué sirve si funciona igual sin ella? Para protegerte de erratas y cambios. Si escribes override etiquetta() o alguien renombra el método en la clase padre, TypeScript avisa:
class Base {
saludar(): string {
return "hola";
}
}
class Hija extends Base {
override saludr(): string {
return "buenas";
}
// error TS4117: This member cannot have an 'override' modifier because it is not declared
// in the base class 'Base'. Did you mean 'saludar'?
}
Con la opción noImplicitOverride del tsconfig.json (la verás en módulos y tsconfig) el override pasa a ser obligatorio en todos los métodos que sobrescriben, y así nunca sobrescribes uno sin darte cuenta.
Errores frecuentes
- No declarar las propiedades: en TypeScript
this.nombre = nombrefalla sinombreno está declarada en la clase (Property 'nombre' does not exist on type...). Declárala o usa una parameter property. - Olvidar el modificador en una parameter property:
constructor(nombre: string) {}no crea ninguna propiedad; hace faltapublic,private,protectedoreadonlydelante. - Creer que
privateprotege los datos en ejecución: solo lo comprueba el compilador. Si necesitas privacidad real (por ejemplo, en una librería que usarán otros), usa#. - Abusar de
!para silenciar el error de inicialización: si luego no asignas el valor, tendrásundefineddonde TypeScript cree que hay unstring. - Pensar que
readonlycongela un array u objeto: solo impide reasignar la propiedad. Para el contenido usaReadonlyArray<T>oreadonly string[]. - Intentar hacer
newde una clase abstracta: crea una de sus hijas.
Resumen
| Concepto | Para qué sirve |
|---|---|
nombre: string; | Declarar una propiedad con su tipo |
public / protected / private | Acceso desde cualquier sitio / la clase y sus hijas / solo la clase |
readonly | Propiedad que solo se asigna al crear el objeto |
constructor(private x: number) {} | Parameter property: declara y asigna en una línea |
#campo | Privado real de JavaScript, también en ejecución |
get / set | Propiedades calculadas y validación al asignar |
static | Miembro de la clase, no de cada objeto |
implements Interfaz | Comprobar que la clase cumple una interfaz |
abstract class / abstract metodo() | Clase base sin instancias que obliga a las hijas a implementar métodos |
override | Marcar un método que sustituye al del padre (con noImplicitOverride, obligatorio) |
En la siguiente lección verás cómo representar un conjunto cerrado de valores con enums, uniones de literales y objetos as const.
Pon a prueba lo que has aprendido
¿Te ha quedado claro? Márcala y verás tu progreso en el explorador.