tutoriales.com

Patrones de Diseño Arquitectónicos y Tipado Avanzado en TypeScript

Descubre cómo estructurar aplicaciones robustas aplicando patrones de diseño clásicos como Singleton, Factory, Observer y Strategy combinados con el sistema de tipos avanzados de TypeScript para garantizar máxima seguridad y mantenibilidad.

Avanzado12 min de lectura9 views
Reportar error

Introducción a la Arquitectura de Software con TypeScript

El desarrollo de aplicaciones web y de backend a gran escala requiere de una disciplina estricta en la estructuración del código. TypeScript no es simplemente un lenguaje con comprobación estática de tipos; es una herramienta de diseño arquitectónico que nos permite modelar dominios complejos de manera explícita y segura.

Cuando combinamos patrones de diseño con el potente sistema de tipos de TypeScript (como tipos condicionales, mapeados, y genéricos avanzados), obtenemos una arquitectura autodocumentada donde los errores de lógica e integración se detectan en tiempo de compilación y no en producción.

📌 Nota: Este tutorial asume que tienes conocimientos intermedios de TypeScript, incluyendo interfaces, clases, genéricos básicos y modificadores de acceso.

1. El Patrón Strategy con Tipado Estricto

El patrón Strategy nos permite definir una familia de algoritmos, encapsular cada uno de ellos y hacerlos intercambiables. En TypeScript puro, es fácil cometer errores al pasar argumentos genéricos a las estrategias. Vamos a diseñar un motor de cálculo de impuestos y descuentos altamente tipado.

Contexto ejecutarEstrategia() «interface» Strategy calcular(monto) EstrategiaDescuentoFijo calcular(monto) EstrategiaDescuentoPorcentaje calcular(monto)

Implementación Base de las Estrategias

Comenzaremos definiendo un contexto genérico donde las estrategias dependen de la estructura de los datos que van a procesar.

interface ItemCarrito {
  id: string;
  nombre: string;
  precio: number;
  cantidad: number;
}

interface EstrategiaCalculoTotal {
  calcular(items: ItemCarrito[]): number;
}

class DescuentoFijoStrategy implements EstrategiaCalculoTotal {
  constructor(private descuento: number) {}

  calcular(items: ItemCarrito[]): number {
    const total = items.reduce((acc, item) => acc + (item.precio * item.cantidad), 0);
    return Math.max(0, total - this.descuento);
  }
}

class DescuentoPorcentajeStrategy implements EstrategiaCalculoTotal {
  constructor(private porcentaje: number) {}

  calcular(items: ItemCarrito[]): number {
    const total = items.reduce((acc, item) => acc + (item.precio * item.cantidad), 0);
    const factor = (100 - this.porcentaje) / 100;
    return total * factor;
  }
}

Contexto de Uso

Ahora implementamos el contexto que consume estas estrategias asegurando que no se pueda inyectar una estrategia inválida.

class CarritoCompraContext {
  private estrategia: EstrategiaCalculoTotal;

  constructor(estrategiaInicial: EstrategiaCalculoTotal) {
    this.estrategia = estrategiaInicial;
  }

  public setEstrategia(nuevaEstrategia: EstrategiaCalculoTotal): void {
    this.estrategia = nuevaEstrategia;
  }

  public calcularTotalFinal(items: ItemCarrito[]): number {
    return this.estrategia.calcular(items);
  }
}
💡 Consejo: Utiliza tipos mapeados o uniones discriminadas si tus estrategias necesitan recibir payloads con estructuras completamente diferentes según el tipo de operación.

2. Patrón Factory con Mapeo de Tipos Condicionales

El patrón Factory method se encarga de delegar la creación de objetos a subclases o funciones centralizadas. Utilizando los tipos condicionales y mapeados de TypeScript, podemos crear una factoría fuertemente tipada que devuelva la instancia exacta basada en un discriminador.

Definición de Entidades

interface NotificacionEmail {
  tipo: 'email';
  destinatario: string;
  asunto: string;
  cuerpo: string;
}

interface NotificacionSMS {
  tipo: 'sms';
  telefono: string;
  mensaje: string;
}

interface NotificacionPush {
  tipo: 'push';
  tokenDispositivo: string;
  titulo: string;
  payload: Record<string, unknown>;
}

type NotificacionPayload = NotificacionEmail | NotificacionSMS | NotificacionPush;

Mapeo de Clases y Creador Genérico

Implementemos un registro dinámico de creadores utilizando un mapa de tipos:

interface CreadorNotificacion<T extends NotificacionPayload> {
  enviar(payload: T): void;
}

class EmailService implements CreadorNotificacion<NotificacionEmail> {
  enviar(payload: NotificacionEmail): void {
    console.log(`Enviando email a ${payload.destinatario}: ${payload.asunto}`);
  }
}

class SMSService implements CreadorNotificacion<NotificacionSMS> {
  enviar(payload: NotificacionSMS): void {
    console.log(`Enviando SMS al teléfono ${payload.telefono}`);
  }
}

class NotificacionFactory {
  private static registry = new Map<NotificacionPayload['tipo'], CreadorNotificacion<any>>();

  public static registrar<T extends NotificacionPayload>(
    tipo: T['tipo'],
    creador: CreadorNotificacion<T>
  ): void {
    this.registry.set(tipo, creador);
  }

  public static obtenerCreador<T extends NotificacionPayload>(
    tipo: T['tipo']
  ): CreadorNotificacion<T> {
    const servicio = this.registry.get(tipo);
    if (!servicio) {
      throw new Error(`Servicio para el tipo ${tipo} no encontrado`);
    }
    return servicio;
  }
}

3. Patrón Observer Segregado por Tipos

El patrón Observer es fundamental para la programación reactiva y la gestión de eventos. En TypeScript, solemos caer en el uso de any para los payloads de los eventos. Vamos a resolver esto creando un sistema de observers seguro basado en un mapa de eventos y cargas útiles fuertemente tipadas.

Publicador Event Bus Subscriptores emit<T>(payload) Validación TypeScript interface Payload { ... } Notificar Sub A Notificar Sub B Estado: Emitido Estado: Recibido

Sistema de Eventos Tipados

type EventosMapa = {
  'usuario:login': { userId: string; timestamp: number };
  'usuario:logout': { userId: string; razon: string };
  'pedido:creado': { pedidoId: string; total: number };
};

type Listener<T> = (payload: T) => void;

class EventBus {
  private listeners: {
    [K in keyof EventosMapa]?: Array<Listener<EventosMapa[K]>>;
  } = {};

  public suscribir<K extends keyof EventosMapa>(
    evento: K,
    listener: Listener<EventosMapa[K]>
  ): () => void {
    if (!this.listeners[evento]) {
      this.listeners[evento] = [];
    }
    this.listeners[evento]!.push(listener);

    // Retornamos función para desuscribir
    return () => {
      this.listeners[evento] = this.listeners[evento]?.filter(l => l !== listener);
    };
  }

  public publicar<K extends keyof EventosMapa>(
    evento: K,
    payload: EventosMapa[K]
  ): void {
    const callbacks = this.listeners[evento];
    if (callbacks) {
      callbacks.forEach(callback => callback(payload));
    }
  }
}
⚠️ Advertencia: Al manejar suscripciones a eventos, asegúrate de implementar mecanismos de desuscripción para evitar pérdidas de memoria (memory leaks) en aplicaciones de larga ejecución como SPAs o servicios de Node.js.

4. Patrón Singleton con Control de Concurrencia e Inyección

El patrón Singleton garantiza que una clase tenga una única instancia y proporciona un punto de acceso global a ella. Aunque es criticado en programación orientada a objetos moderna por dificultar las pruebas unitarias, sigue siendo muy útil para gestores de conexiones o configuraciones globales.

Singleton Genérico Seguro

abstract class BaseSingleton {
  protected static instancia: any = null;

  protected constructor() {}

  public static obtenerInstancia<T extends BaseSingleton>(this: new () => T): T {
    if (!this.instancia) {
      this.instancia = new this();
    }
    return this.instancia;
  }
}

class ConfiguracionSistema extends BaseSingleton {
  public entorno: 'development' | 'production' = 'development';
  public apiTimeout: number = 5000;

  private constructor() {
    super();
  }
}

// Uso:
const config1 = ConfiguracionSistema.obtenerInstancia<ConfiguracionSistema>();
const config2 = ConfiguracionSistema.obtenerInstancia<ConfiguracionSistema>();

console.log(config1 === config2); // true

Comparativa de Patrones y su Aplicación en TypeScript

Patrón de DiseñoComplejidad de TipadoCaso de Uso Principal en TypeScriptVentaja Clave
------------
StrategyFácilAlgoritmos de negocio intercambiablesEvita condicionales complejos (switch/case)
FactoryIntermedioCreación dinámica de objetos complejosDesacopla la creación del uso
------------
ObserverAvanzadoSistemas reactivos y manejo de eventosComunicación desacoplada entre módulos
SingletonIntermedioGestión de recursos globales compartidosControl estricto de instanciación única

Resumen del Proceso de Diseño Arquitectónico

Paso 1: Modelado del Dominio: Define interfaces y tipos base estrictos para tus entidades de negocio.
Paso 2: Selección del Patrón: Identifica si requieres flexibilidad (Strategy), creación centralizada (Factory) o sincronización (Observer).
Paso 3: Refinamiento con Genéricos: Aplica restricciones de tipos (`extends`) para asegurar que los componentes interactúen sin errores.
Paso 4: Pruebas y Validación: Comprueba que el compilador rechace configuraciones erróneas en tiempo de compilación.
Preguntas Frecuentes sobre Patrones en TypeScript

¿El uso excesivo de patrones ralentiza la compilación de TypeScript? En proyectos pequeños o medianos el impacto es imperceptible. En monorepos masivos, el uso excesivo de tipos condicionales complejos y recursivos puede incrementar el tiempo del compilador (tsc). Es recomendable mantener los tipos limpios y modulares.

¿Es obligatorio usar clases para aplicar patrones de diseño? No necesariamente. Muchos patrones como Strategy o Factory se pueden implementar utilizando funciones puras y objetos de primera clase, aprovechando el paradigma funcional de JavaScript y TypeScript.

Tutoriales relacionados

Comentarios (0)

Aún no hay comentarios. ¡Sé el primero!