En esta página

Clases y decoradores

14 min lectura TextoCap. 5 — TypeScript en práctica

Clases en TypeScript

TypeScript extiende las clases de JavaScript con un sistema de tipos completo: modificadores de acceso, propiedades estrictas, clases abstractas y chequeo de implementaciones de interfaces. Todo lo que aprendas aquí compila a JavaScript estándar.


Modificadores de acceso

TypeScript ofrece cuatro niveles de visibilidad:

Modificador Accesible desde
public (por defecto) Cualquier lugar
private Solo la clase donde se declara
protected La clase y sus subclases
readonly Solo lectura (combinable con los anteriores)
class CuentaBancaria {
  public titular: string;
  private saldo: number;
  protected numeroCuenta: string;
  readonly fechaCreacion: Date;

  constructor(titular: string, saldoInicial: number) {
    this.titular = titular;
    this.saldo = saldoInicial;
    this.numeroCuenta = Math.random().toString(36).slice(2).toUpperCase();
    this.fechaCreacion = new Date();
  }

  public depositar(cantidad: number): void {
    if (cantidad <= 0) throw new Error("La cantidad debe ser positiva");
    this.saldo += cantidad;
  }

  public retirar(cantidad: number): boolean {
    if (cantidad > this.saldo) return false;
    this.saldo -= cantidad;
    return true;
  }

  public consultarSaldo(): number {
    return this.saldo;
  }
}

const cuenta = new CuentaBancaria("Ana García", 1000);
cuenta.depositar(500);
console.log(cuenta.consultarSaldo()); // 1500
// cuenta.saldo; // ❌ Error: propiedad privada
// cuenta.fechaCreacion = new Date(); // ❌ Error: solo lectura

`private` vs `#private` (nativo de JS)

TypeScript también soporta los campos privados nativos de JavaScript (ECMAScript) con el prefijo #. A diferencia del private de TypeScript, estos son realmente privados en tiempo de ejecución:

class Token {
  #secreto: string; // Privado en tiempo de ejecución

  constructor(secreto: string) {
    this.#secreto = secreto;
  }

  verificar(intento: string): boolean {
    return this.#secreto === intento;
  }
}

Propiedades de parámetros

Una de las características más cómodas de TypeScript es poder declarar y asignar propiedades directamente en los parámetros del constructor:

// Sin propiedades de parámetros (verbose):
class ProductoVerboso {
  public readonly nombre: string;
  private precio: number;
  protected categoria: string;

  constructor(nombre: string, precio: number, categoria: string) {
    this.nombre = nombre;
    this.precio = precio;
    this.categoria = categoria;
  }
}

// Con propiedades de parámetros (conciso, equivalente):
class Producto {
  constructor(
    public readonly nombre: string,
    private precio: number,
    protected categoria: string
  ) {}

  obtenerPrecio(): number {
    return this.precio;
  }
}

Miembros estáticos

Los miembros static pertenecen a la clase, no a las instancias:

class Contador {
  private static instancias = 0;
  private id: number;

  constructor(public readonly nombre: string) {
    Contador.instancias++;
    this.id = Contador.instancias;
  }

  static obtenerTotal(): number {
    return Contador.instancias;
  }

  toString(): string {
    return `Contador #${this.id} (${this.nombre})`;
  }
}

const c1 = new Contador("Primero");
const c2 = new Contador("Segundo");

console.log(Contador.obtenerTotal()); // 2

Clases abstractas

Una clase abstracta define una plantilla: puede tener implementaciones concretas y también métodos abstractos que cada subclase debe implementar:

abstract class Exportador {
  // Método concreto reutilizable
  protected formatearFecha(fecha: Date): string {
    return fecha.toISOString().split("T")[0];
  }

  // Método abstracto: cada subclase elige cómo exportar
  abstract exportar(datos: unknown[]): string;

  // Template method: usa el método abstracto
  exportarConEncabezado(datos: unknown[], titulo: string): string {
    const contenido = this.exportar(datos);
    return `=== ${titulo} ===\n${contenido}`;
  }
}

class ExportadorCSV extends Exportador {
  exportar(datos: unknown[]): string {
    return datos.map((fila) => JSON.stringify(fila)).join("\n");
  }
}

class ExportadorJSON extends Exportador {
  exportar(datos: unknown[]): string {
    return JSON.stringify(datos, null, 2);
  }
}

// const exp = new Exportador(); // ❌ No se puede instanciar clase abstracta
const csv  = new ExportadorCSV();
const json = new ExportadorJSON();

const filas = [{ id: 1, nombre: "Ana" }, { id: 2, nombre: "Luis" }];

console.log(csv.exportarConEncabezado(filas, "Usuarios"));
console.log(json.exportarConEncabezado(filas, "Usuarios"));

Implementación de interfaces con `implements`

implements fuerza a una clase a cumplir con un contrato de interfaz:

interface Serializable {
  serializar(): string;
  deserializar(datos: string): void;
}

interface Validable {
  esValido(): boolean;
  obtenerErrores(): string[];
}

class FormularioPedido implements Serializable, Validable {
  constructor(
    public productoId: string,
    public cantidad: number,
    public direccion: string
  ) {}

  serializar(): string {
    return JSON.stringify({ productoId: this.productoId, cantidad: this.cantidad, direccion: this.direccion });
  }

  deserializar(datos: string): void {
    const obj = JSON.parse(datos) as { productoId: string; cantidad: number; direccion: string };
    this.productoId = obj.productoId;
    this.cantidad   = obj.cantidad;
    this.direccion  = obj.direccion;
  }

  esValido(): boolean {
    return this.obtenerErrores().length === 0;
  }

  obtenerErrores(): string[] {
    const errores: string[] = [];
    if (!this.productoId) errores.push("El producto es obligatorio");
    if (this.cantidad <= 0) errores.push("La cantidad debe ser mayor que cero");
    if (!this.direccion.trim()) errores.push("La dirección no puede estar vacía");
    return errores;
  }
}

Decoradores TC39 (TypeScript 5+)

Los decoradores son funciones que modifican clases, métodos, campos o accesorios de forma declarativa. Los decoradores TC39 (stage 3) son los modernos, soportados desde TypeScript 5.0 sin ningún flag de configuración.

Anatomía de un decorador de método

function decoradorMetodo(
  target: unknown,
  context: ClassMethodDecoratorContext
) {
  // target: la función del método original
  // context: información sobre el método (nombre, tipo, etc.)

  context.addInitializer(function (this: unknown) {
    // Se ejecuta durante la inicialización de la instancia
    // `this` es la instancia de la clase
  });
}

Decorador de logging

function log(
  target: unknown,
  context: ClassMethodDecoratorContext
): void {
  const nombreMetodo = String(context.name);

  context.addInitializer(function (this: Record<string, unknown>) {
    const original = this[nombreMetodo] as (...args: unknown[]) => unknown;

    this[nombreMetodo] = function (...args: unknown[]) {
      const inicio = performance.now();
      console.log(`▶ ${nombreMetodo}(${args.map((a) => JSON.stringify(a)).join(", ")})`);

      const resultado = original.apply(this, args);

      const duracion = (performance.now() - inicio).toFixed(2);
      console.log(`◀ ${nombreMetodo} → ${JSON.stringify(resultado)} (${duracion}ms)`);

      return resultado;
    };
  });
}

Decorador de clase

function singleton<T extends new (...args: unknown[]) => unknown>(
  target: T,
  _context: ClassDecoratorContext
): T {
  let instancia: InstanceType<T> | null = null;

  return class extends target {
    constructor(...args: unknown[]) {
      if (instancia) return instancia as InstanceType<T>;
      super(...args);
      instancia = this as unknown as InstanceType<T>;
    }
  } as T;
}

Ejemplo completo: servicio con logging y singleton

// ──────────────────────────────────────────────────────────────
// Decorador de logging para métodos
// ──────────────────────────────────────────────────────────────
function log(target: unknown, context: ClassMethodDecoratorContext): void {
  const nombre = String(context.name);
  context.addInitializer(function (this: Record<string, unknown>) {
    const original = this[nombre] as (...args: unknown[]) => unknown;
    this[nombre] = function (...args: unknown[]) {
      console.log(`[LOG] ${nombre} iniciado`);
      try {
        const resultado = original.apply(this, args);
        console.log(`[LOG] ${nombre} completado correctamente`);
        return resultado;
      } catch (error) {
        console.error(`[LOG] ${nombre} falló:`, error);
        throw error;
      }
    };
  });
}

// ──────────────────────────────────────────────────────────────
// Interfaces del dominio
// ──────────────────────────────────────────────────────────────
interface Usuario {
  id: string;
  nombre: string;
  email: string;
  rol: "admin" | "usuario";
}

interface RepositorioUsuarios {
  buscarPorId(id: string): Usuario | null;
  buscarTodos(): Usuario[];
  guardar(usuario: Omit<Usuario, "id">): Usuario;
}

// ──────────────────────────────────────────────────────────────
// Clase de servicio con decoradores
// ──────────────────────────────────────────────────────────────
class ServicioUsuarios {
  private readonly repo: RepositorioUsuarios;
  private cache: Map<string, Usuario> = new Map();

  constructor(repo: RepositorioUsuarios) {
    this.repo = repo;
  }

  @log
  obtenerUsuario(id: string): Usuario | null {
    if (this.cache.has(id)) {
      console.log("[CACHE] Hit para", id);
      return this.cache.get(id)!;
    }
    const usuario = this.repo.buscarPorId(id);
    if (usuario) this.cache.set(id, usuario);
    return usuario;
  }

  @log
  crearUsuario(datos: Omit<Usuario, "id">): Usuario {
    const nuevo = this.repo.guardar(datos);
    this.cache.set(nuevo.id, nuevo);
    return nuevo;
  }

  @log
  obtenerAdmins(): Usuario[] {
    return this.repo.buscarTodos().filter((u) => u.rol === "admin");
  }
}

// ──────────────────────────────────────────────────────────────
// Repositorio en memoria (implementación de prueba)
// ──────────────────────────────────────────────────────────────
class RepositorioEnMemoria implements RepositorioUsuarios {
  private usuarios: Usuario[] = [
    { id: "u1", nombre: "Ana García", email: "[email protected]", rol: "admin" },
    { id: "u2", nombre: "Luis Pérez", email: "[email protected]", rol: "usuario" },
  ];

  buscarPorId(id: string): Usuario | null {
    return this.usuarios.find((u) => u.id === id) ?? null;
  }

  buscarTodos(): Usuario[] {
    return [...this.usuarios];
  }

  guardar(datos: Omit<Usuario, "id">): Usuario {
    const nuevo: Usuario = { ...datos, id: `u${this.usuarios.length + 1}` };
    this.usuarios.push(nuevo);
    return nuevo;
  }
}

// ──────────────────────────────────────────────────────────────
// Uso
// ──────────────────────────────────────────────────────────────
const repo    = new RepositorioEnMemoria();
const servicio = new ServicioUsuarios(repo);

const ana  = servicio.obtenerUsuario("u1");
const luis = servicio.obtenerUsuario("u2");
const ana2 = servicio.obtenerUsuario("u1"); // Desde caché

console.log(ana?.nombre);  // Ana García
console.log(luis?.nombre); // Luis Pérez
console.log(ana2?.nombre); // Ana García (desde caché)

const carlos = servicio.crearUsuario({
  nombre: "Carlos Ríos",
  email: "[email protected]",
  rol: "usuario",
});

console.log("Creado:", carlos.nombre);
console.log("Admins:", servicio.obtenerAdmins().map((u) => u.nombre));

Resumen

  • Los modificadores de acceso (public, private, protected, readonly) comunican intención y el compilador los verifica.
  • Las propiedades de parámetros eliminan el boilerplate repetitivo en constructores.
  • Las clases abstractas definen contratos con implementación parcial; excelentes para el patrón Template Method.
  • implements garantiza que la clase cumple uno o más contratos de interfaz.
  • Los decoradores TC39 permiten extender comportamiento de forma declarativa y reutilizable sin modificar las clases destino.

En la siguiente lección aprenderás cómo organizar tu código TypeScript en módulos y namespaces, incluyendo importaciones type-only, resolución de módulos y archivos de declaración.

Propiedades de parámetros: código más conciso
Usar `constructor(private readonly nombre: string)` es idéntico a declarar la propiedad, recibirla como parámetro y asignarla. Es una de las características de TypeScript más apreciadas para reducir boilerplate en clases de datos y servicios.
Decoradores TC39 vs legacy experimentalDecorators
TypeScript 5.0+ soporta los decoradores TC39 (stage 3) sin activar ningún flag. Los decoradores legados (con `experimentalDecorators: true`) tienen una API diferente e incompatible. Esta lección usa los TC39 modernos; no mezcles ambos sistemas.