Saltar al contenido
clases.ts · devschool

Clases en TypeScript

Lección 9 de 15 · 12 min de lectura · Actualizado el

En esta lección
  1. Propiedades tipadas
  2. Modificadores de acceso: public, private y protected
  3. readonly: propiedades que no cambian
  4. Parameter properties: el atajo del constructor
  5. private de TypeScript frente a #privado de JavaScript
  6. Getters y setters
  7. Miembros static
  8. implements: cumplir una interfaz
  9. Clases abstractas
  10. Herencia y override
  11. Errores frecuentes
  12. 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:

ModificadorAccesible desde
public (por defecto)Cualquier sitio
protectedLa propia clase y sus clases hijas
privateSolo 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ón erasableSyntaxOnly, 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:

  • private solo 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.
  • #codigo es 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: implements solo comprueba; no añade nada a la clase. Además, TypeScript compara tipos por su forma (tipado estructural): un objeto literal con formato y exportar también se puede pasar a guardar aunque no venga de ninguna clase. En Java, en cambio, hace falta declarar implements sí 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 = nombre falla si nombre no 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 falta public, private, protected o readonly delante.
  • Creer que private protege 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ás undefined donde TypeScript cree que hay un string.
  • Pensar que readonly congela un array u objeto: solo impide reasignar la propiedad. Para el contenido usa ReadonlyArray<T> o readonly string[].
  • Intentar hacer new de una clase abstracta: crea una de sus hijas.

Resumen

ConceptoPara qué sirve
nombre: string;Declarar una propiedad con su tipo
public / protected / privateAcceso desde cualquier sitio / la clase y sus hijas / solo la clase
readonlyPropiedad que solo se asigna al crear el objeto
constructor(private x: number) {}Parameter property: declara y asigna en una línea
#campoPrivado real de JavaScript, también en ejecución
get / setPropiedades calculadas y validación al asignar
staticMiembro de la clase, no de cada objeto
implements InterfazComprobar que la clase cumple una interfaz
abstract class / abstract metodo()Clase base sin instancias que obliga a las hijas a implementar métodos
overrideMarcar 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

[TypeScript] Con strict activado, ¿qué error da esta clase?
class Ruta {
  nombre: string;
  kilometros: number;
  constructor(nombre: string) {
    this.nombre = nombre;
  }
}

[TypeScript] ¿Cuál es la diferencia real entre private de TypeScript y #campo de JavaScript?

[TypeScript] ¿Qué error da la última línea?
abstract class Actividad {
  abstract caloriasPorMinuto(): number;
}
class Natacion extends Actividad {
  caloriasPorMinuto(): number { return 9.5; }
}
const a = new Actividad();

[TypeScript] ¿Qué avisa TypeScript en esta clase hija?
class Base {
  saludar(): string { return "hola"; }
}
class Hija extends Base {
  override saludr(): string { return "buenas"; }
}

¿Te ha quedado claro? Márcala y verás tu progreso en el explorador.