Inicio / Artículos / Elegir patrones de TypeScript según la complejidad que eliminan

Elegir patrones de TypeScript según la complejidad que eliminan

Un recorrido por los patrones de diseño clásicos y las técnicas de TypeScript a nivel de tipos, con indicaciones claras sobre cuándo cada uno tiene su lugar y cuándo el código simple es la mejor opción.

5991 palabras

La mayoría de los equipos adoptan TypeScript para el autocompletado y la detección de errores tipográficos, y luego descubren su verdadero valor: permite codificar decisiones de diseño para que el compilador las aplique. Esta guía explica los patrones orientados a objetos clásicos y las técnicas a nivel de tipos que son más importantes en bases de código front-end y full-stack grandes, y muestra cómo decidir cuándo vale la pena utilizar un patrón.

Considere el sistema de tipos como algo que puede responder a preguntas arquitectónicas:

  • ¿Es realmente posible esta combinación de valores de estado?
  • ¿Podría esta API devolver una estructura que el resto del código no espera?
  • ¿Puede un ProductId pasar a una función que requiere un UserId?
  • ¿Se pueden asignar a un componente propiedades que se contradigan entre sí?
  • Si se agrega un nuevo estado, ¿se verá obligado cada consumidor a manejarlo?
  • ¿Se puede reemplazar una implementación sin afectar a quienes la llaman?
  • ¿Se puede reutilizar una abstracción sin recurrir a any?
  • Un patrón no es una característica que se añade posteriormente. Es el nombre de una solución que reconoces cuando aparece un problema recurrente.

    Cartografía del panorama

    La clasificación clásica incluye patrones creacionales, estructurales y de comportamiento; TypeScript añade una cuarta familia de técnicas a nivel de tipos basadas en genericos, uniones y herramientas de seguridad.

                         TYPESCRIPT PATTERNS
                                    │
            ┌───────────────────────┼────────────────────────┐
            │                       │                        │
            ▼                       ▼                        ▼
       CREATIONAL              STRUCTURAL                BEHAVIORAL
            │                       │                        │
            ├─ Factory              ├─ Adapter              ├─ Strategy
            ├─ Builder              ├─ Facade               ├─ Observer
            ├─ Singleton            ├─ Decorator            ├─ Command
            └─ Abstract Factory     ├─ Repository           └─ State
                                    └─ Composition
    
                                    +
    
                        TYPESCRIPT TYPE PATTERNS
                                    │
            ┌───────────────────────┼────────────────────────┐
            │                       │                        │
            ▼                       ▼                        ▼
        Generics                Unions                  Type Safety
            │                       │                        │
            ├─ Constraints          ├─ Discriminated         ├─ Type Guards
            ├─ keyof                  Unions                ├─ Branded Types
            ├─ typeof               ├─ Result Types          ├─ Exhaustiveness
            ├─ infer                └─ State Modeling        └─ satisfies
            └─ Mapped Types
    

    Las aplicaciones reales rara vez utilizan un solo patrón. Un flujo de datos típico en React combina varios de ellos, y cada límite puede tener tipos precisos:

    React Component
          │
          ▼
    Custom Hook
          │
          ▼
    Service
          │
          ▼
    Repository
          │
          ▼
    API Client
          │
          ▼
    Result<T, E>
          │
          ▼
    Discriminated Union
    

    Cuando los tipos fluyen a través de toda esa cadena, TypeScript se convierte en una descripción de cómo está permitido que el sistema se comporte.

    Comienza con el problema, no con el patrón

    Una trampa común es razonar en la dirección equivocada:

    "I know Factory Pattern.
    Where can I use Factory?"
    

    Elegir un patrón primero genera abstracciones que nadie necesitaba. Deje que el problema guíe la elección en su lugar:

    What problem do I have?
            ↓
    Where is the complexity?
            ↓
    What is changing frequently?
            ↓
    What should remain stable?
            ↓
    What abstraction reduces that complexity?
            ↓
    Is a known pattern appropriate?
    

    Considere un flujo de pago que incluye una serie de verificaciones sobre el método de pago:

    if (paymentMethod === "card") {
      // ...
    }
    
    if (paymentMethod === "paypal") {
      // ...
    }
    if (paymentMethod === "upi") {
      // ...
    }
    

    La respuesta instintiva suele ser esta:

    Don’t immediately think:
    

    “Esto necesita Strategy”. Antes de recurrir a ella, pregúntese si las ramas son realmente algoritmos intercambiables bajo un mismo contrato. Si lo son, Strategy es la opción adecuada. Si el problema real es que algunos campos solo tienen sentido para ciertos métodos, una unión discriminada que impida representar combinaciones inválidas podría ser la herramienta más adecuada. Conocer el catálogo es fácil; lo importante es saber cómo asociar una entrada a una situación específica.

    Patrones de creación

    Singleton: una instancia compartida

    Un Singleton garantiza que una clase tenga exactamente una instancia. El constructor privado impide su uso fuera de new, y un método estático crea y almacena la instancia de forma perezosa:

    class Logger {
      private static instance: Logger;
    
    private constructor() {}
      static getInstance(): Logger {
        if (!Logger.instance) {
          Logger.instance = new Logger();
        }
        return Logger.instance;
      }
      log(message: string) {
        console.log(message);
      }
    }
    

    Los que lo solicitan obtienen el objeto compartido en lugar de crear uno nuevo:

    const logger = Logger.getInstance();
    logger.log("Application started");
    

    Cada consumidor termina apuntando al mismo objeto:

                      Logger
                        │
                 getInstance()
                        │
                        ▼
                 ┌───────────┐
                 │  Logger   │
                 │ Instance  │
                 └───────────┘
                   ▲       ▲
                   │       │
              Service A  Service B
    

    Algunos candidatos razonables incluyen:

    • registro de logs
    • gestores de análisis
    • guardianes de configuración
    • algunos gestores de conexiones
    • otros servicios de infraestructura transversal

    El problema es que un Singleton es en realidad un acceso global disfrazado: los tests se vuelven más difíciles de aislar, las dependencias desaparecen de las firmas de funciones, el ciclo de vida se vuelve confuso y el estado mutable compartido cambia de maneras imposibles de rastrear. En un frontend moderno, una instancia exportada desde un módulo ES ya está compartida, y la inyección de dependencias, React Context o una biblioteca de estado ofrecen la misma garantía con un esquema visible.

    Fábrica: ocultar qué clase se crea

    Una fábrica toma la decisión sobre qué clase concreta instanciar lejos del consumidor. Compare la construcción directa:

    const payment = new StripePayment();
    

    con la construcción delegada:

    const payment = PaymentFactory.create("stripe");
    

    Ambos proveedores implementan una misma interfaz, y la fábrica asocia una unión de literales de cadena a la clase adecuada. Dado que el tipo del parámetro es "stripe" | "paypal", un nombre no soportado impide la compilación:

    interface PaymentProvider {
      pay(amount: number): Promise<void>;
    }
    
    class StripePayment implements PaymentProvider {
      async pay(amount: number) {
        console.log("Stripe:", amount);
      }
    }
    class PayPalPayment implements PaymentProvider {
      async pay(amount: number) {
        console.log("PayPal:", amount);
      }
    }
    class PaymentFactory {
      static create(
        provider: "stripe" | "paypal"
      ): PaymentProvider {
        switch (provider) {
          case "stripe":
            return new StripePayment();
          case "paypal":
            return new PayPalPayment();
        }
      }
    }
    

    Visualmente, la fábrica es un tenedor identificado por el nombre del proveedor:

                  PaymentFactory
                           │
              ┌────────────┴────────────┐
              │                         │
           "stripe"                  "paypal"
              │                         │
              ▼                         ▼
         StripePayment             PayPalPayment
    

    Una fábrica resulta útil cuando:

    • la construcción implica lógica real
    • varias implementaciones comparten un mismo contrato
    • los consumidores no deben conocer las clases concretas
    • las implementaciones deben evolucionar de forma independiente de quienes las llaman

    Para construcciones triviales como la de abajo, solo añade una capa adicional que hay que analizar:

    new User();
    

    Fábrica abstracta: familias de objetos relacionados

    La fábrica abstracta extiende esta idea a grupos de objetos que deben ser compatibles entre sí. En un kit de interfaz gráfica dirigido a varias plataformas, un botón web nunca debe combinarse con un modal móvil; por eso cada fábrica genera una familia coherente:

    interface Button {
      render(): void;
    }
    
    interface Modal {
      open(): void;
    }
    
    interface UIFactory {
      createButton(): Button;
      createModal(): Modal;
    }
    
                      UIFactory
                        │
               ┌────────┴────────┐
               ▼                 ▼
         WebUIFactory       MobileUIFactory
               │                 │
          ┌────┴────┐       ┌────┴────┐
          ▼         ▼       ▼         ▼
       Button     Modal   Button     Modal
    

    Es potente, pero es fácil sobreconstruirlo. En la mayoría de las aplicaciones frontales, la composición simple de componentes permite lograr el mismo resultado con menos formalismos.

    Constructor: construcción controlada paso a paso

    El constructor resulta útil cuando un objeto tiene muchas configuraciones opcionales o se configura en etapas. Cada método de asignación actualiza la configuración privada y devuelve this, lo que permite el encadenamiento:

    class RequestBuilder {
      private config: RequestInit = {};
    
    setMethod(method: string) {
        this.config.method = method;
        return this;
      }
      setHeaders(headers: HeadersInit) {
        this.config.headers = headers;
        return this;
      }
      setBody(body: BodyInit) {
        this.config.body = body;
        return this;
      }
      build() {
        return this.config;
      }
    }
    

    El montaje de una solicitud se presenta como pasos explícitos:

    const request = new RequestBuilder()
      .setMethod("POST")
      .setHeaders({
        "Content-Type": "application/json"
      })
      .setBody(JSON.stringify(data))
      .build();
    

    La sintaxis fluida es un efecto secundario, no el objetivo principal. Lo importante es mantener la construcción complicada de forma explícita y en un único lugar, donde build() también puede validarla, por ejemplo rechazando un cuerpo en una solicitud GET.

    Patrones estructurales

    Adaptador: un contrato estable alrededor de la API de otro

    El adaptador es uno de los patrones más utilizados en el código de aplicaciones. Supongamos que su código espera este contrato interno:

    interface PaymentGateway {
      pay(amount: number): Promise<void>;
    }
    

    Mientras que un SDK de terceros expone un método con otro nombre:

    class LegacyPaymentSDK {
      makePayment(value: number) {
        // third-party implementation
      }
    }
    

    Un adaptador sencillo implementa su interfaz y traduce las llamadas:

    class PaymentAdapter implements PaymentGateway {
      constructor(
        private readonly sdk: LegacyPaymentSDK
      ) {}
    
    async pay(amount: number) {
        this.sdk.makePayment(amount);
      }
    }
    
    Application
         │
         ▼
    PaymentGateway
         ▲
         │
    PaymentAdapter
         │
         ▼
    Third-party SDK
    

    La aplicación ahora depende de una interfaz que usted controla:

    PaymentGateway
    

    en lugar de depender de cada proveedor involucrado:

    Stripe
    PayPal
    LegacySDK
    SomeFutureProvider
    

    Soportar a un nuevo proveedor implica escribir otro adaptador.

    Fachada: una llamada para un flujo de trabajo multietapa

    Una fachada proporciona un punto de entrada simple frente a un subsistema complejo. El proceso de inicio de sesión podría involucrar varios servicios:

    Authentication
         +
    User Service
         +
    Permissions
         +
    Notification
         +
    Analytics
    

    Sin una fachada, cada componente que gestiona el inicio de sesión debe orquestar la secuencia por sí mismo:

    auth.login();
    user.load();
    permission.load();
    analytics.track();
    

    Una fachada se encarga de orquestar todo eso una sola vez:

    class AppFacade {
      async login(username: string, password: string) {
        const token = await auth.login(username, password);
    
    const user = await userService.getUser(token);
        await permissionService.load(user);
        analytics.track("login");
        return user;
      }
    }
    

    y el componente se reduce a una sola llamada:

    await appFacade.login(username, password);
    
                   Component
                         │
                         ▼
                     AppFacade
                         │
           ┌─────────────┼─────────────┐
           ▼             ▼             ▼
         Auth          User        Permission
        Service       Service       Service
    

    Esto es útil cuando el orden de los pasos es importante.

    Decorador: agregar comportamiento mediante envoltura

    Un Decorador agrega comportamiento sin cambiar la implementación original, ya que el objeto envolvente y el objeto envuelto comparten una interfaz. El contrato:

    interface Logger {
      log(message: string): void;
    }
    

    Una implementación sencilla:

    class ConsoleLogger implements Logger {
      log(message: string) {
        console.log(message);
      }
    }
    

    Un decorador que almacena cualquier Logger y añade una marca de tiempo al principio de los mensajes:

    class TimestampLogger implements Logger {
      constructor(
        private readonly logger: Logger
      ) {}
    
    log(message: string) {
        this.logger.log(
          `[${new Date().toISOString()}] ${message}`
        );
      }
    }
    

    La envoltura es simplemente un proceso de construcción:

    const logger = new TimestampLogger(
      new ConsoleLogger()
    );
    

    Dado que cada decorador es a su vez un Logger, se apilan:

    Logger
      │
      ▼
    ConsoleLogger
      │
      ▼
    TimestampLogger
      │
      ▼
    AdditionalDecorator
    

    La misma idea aparece bajo otros nombres:

    • cadenas de middleware
    • funciones envolventes
    • Componentes de orden superior en React
    • registro de logs
    • capas de caché
    • verificaciones de autorización
  • Instrumentación y seguimiento
  • Patrones de comportamiento

    Estrategia: algoritmos intercambiables

    La estrategia elimina las reglas de negocio con ramificaciones. Tomemos un tipo de nivel de cliente:

    type CustomerType =
      | "regular"
      | "premium"
      | "enterprise";
    

    Una implementación condicional coloca cada regla en una función:

    function calculateDiscount(
      type: CustomerType,
      price: number
    ) {
      if (type === "regular") {
        return price;
      }
    
    if (type === "premium") {
        return price * 0.9;
      }
      return price * 0.8;
    }
    

    Con la estrategia, cada regla se convierte en una clase detrás de una interfaz compartida:

    interface DiscountStrategy {
      calculate(price: number): number;
    }
    
    class RegularDiscount implements DiscountStrategy {
      calculate(price: number) {
        return price;
      }
    }
    class PremiumDiscount implements DiscountStrategy {
      calculate(price: number) {
        return price * 0.9;
      }
    }
    class EnterpriseDiscount implements DiscountStrategy {
      calculate(price: number) {
        return price * 0.8;
      }
    }
    
                      Order
                          │
                          ▼
                  DiscountStrategy
                          │
              ┌───────────┼───────────┐
              ▼           ▼           ▼
           Regular      Premium    Enterprise
           Strategy     Strategy    Strategy
    

    Agregar un nuevo nivel generalmente significa añadir una estrategia en lugar de editar la lógica existente, lo cual representa el Principio de Apertura/Cierre en la práctica. Para tres reglas muy simples, la solución condicional es adecuada; la estrategia resulta útil cuando las reglas desarrollan sus propias dependencias o pruebas.

    Observador: notificaciones uno-a-muchos

    El observador permite que un sujeto notifique a cualquier número de oyentes cuando algo cambia:

                     Subject
                         │
              ┌──────────┼──────────┐
              ▼          ▼          ▼
          Observer A Observer B Observer C
    

    Un pequeño emisor tipado almacena a los oyentes en un Set y devuelve una función de limpieza cuando se registra un oyente:

    type Listener<T> = (value: T) => void;
    
    class EventEmitter<T> {
      private listeners = new Set<Listener<T>>();
      subscribe(listener: Listener<T>) {
        this.listeners.add(listener);
        return () => {
          this.listeners.delete(listener);
        };
      }
      emit(value: T) {
        this.listeners.forEach(listener => {
          listener(value);
        });
      }
    }
    
    const emitter = new EventEmitter<string>();
    
    const unsubscribe = emitter.subscribe(message => {
      console.log(message);
    });
    emitter.emit("Hello");
    unsubscribe();
    

    Esa función de limpieza se encarga de la gestión del ciclo de vida. Olvidarse de llamarla conlleva:

    • fugas de memoria debido a oyentes que sobreviven a sus propietarios
    • manejo duplicado cuando un oyente se adjunta dos veces
    • cierres obsoletos que leen valores desactualizados
    • efectos secundarios que se activan después de que un componente haya desaparecido

    En React, es exactamente lo que se devuelve desde la función de callback de useEffect.

    Comando: acciones como objetos

    El comando convierte una acción en un objeto con una interfaz uniforme:

    interface Command {
      execute(): void;
    }
    

    Los comandos concretos lo implementan:

    class SaveCommand implements Command {
      execute() {
        console.log("Saving...");
      }
    }
    
    class UndoCommand implements Command {
      execute() {
        console.log("Undo");
      }
    }
    

    Tratar las acciones como valores es útil cuando se necesita:

    • un historial de lo que ha ocurrido
    • funciones de deshacer y rehacer
  • Ejecución en cola
  • Reintentos
  • Ejecución diferida
  • Registro de auditoría
  • El verdadero deshacer suele requerir que cada comando se invierta a sí mismo, por lo que las versiones de producción suelen añadir un método undo() junto a execute().

    User Action
        │
        ▼
     Command
        │
        ├── execute()
        │
        ▼
     Receiver
    

    Patrones de datos y composición

    Repository: aislar el acceso a los datos

    Cuando el acceso a los datos deja de ser sencillo, un Repository establece un contrato entre la lógica de negocio y todo aquello que almacena o obtiene los datos:

    UI
     │
     ▼
    Hook / Controller
     │
     ▼
    Service
     │
     ▼
    Repository
     │
     ├── REST
     ├── GraphQL
     ├── IndexedDB
     └── Cache
    

    El contrato describe qué puede solicitar la aplicación:

    interface UserRepository {
      getUser(id: string): Promise<User>;
      getUsers(): Promise<User[]>;
    }
    

    Una implementación se comunica con una API REST:

    class ApiUserRepository implements UserRepository {
      async getUser(id: string) {
        const response = await fetch(`/users/${id}`);
    
    return response.json();
      }
      async getUsers() {
        const response = await fetch("/users");
        return response.json();
      }
    }
    

    El código de negocio depende de la interfaz:

    UserRepository
    

    no de un medio de transporte:

    fetch()
    axios()
    graphqlClient()
    

    Esto es importante cuando cambia el código fuente: de REST a GraphQL, la adición de un caché IndexedDB o un modelo en memoria para pruebas. Tenga en cuenta que response.json() devuelve un valor sin tipado, por lo que el repositorio también es el lugar adecuado para validar las respuestas.

    Composición sobre herencia

    En el trabajo frontend, la composición es más importante que cualquier patrón basado en herencia. En lugar de tener un único componente que se encargue de todo:

    MegaComponent
     ├── Authentication
     ├── Table
     ├── Filters
     ├── Modal
     ├── Notifications
     ├── API calls
     └── Business logic
    

    divida las responsabilidades en partes específicas:

    Dashboard
     ├── Header
     ├── Sidebar
     ├── FilterPanel
     ├── DataTable
     └── NotificationPanel
    

    En React esto se logra simplemente anidando componentes:

    <Dashboard>
      <Header />
      <Sidebar />
      <MainContent />
    </Dashboard>
    

    Cada parte puede ser comprendida, probada y reemplazada por separado. Para más información sobre cómo dividir componentes demasiado grandes, consulte cómo solucionar la sobrecarga de propiedades en React con composición y slots.

    Modelado del estado con el sistema de tipos

    A partir de aquí, TypeScript en sí es la herramienta arquitectónica.

    Uniones discriminadas para el estado de la solicitud

    Una solicitud pasa por fases que contienen datos diferentes. Una unión discriminada le da a cada fase su propia estructura, unida por un campo literal status:

    type RequestState =
      | {
          status: "idle";
        }
      | {
          status: "loading";
        }
      | {
          status: "success";
          data: User[];
        }
      | {
          status: "error";
          error: string;
        };
    

    Al utilizar el discriminante, se restringe el tipo en cada rama:

    function render(state: RequestState) {
      switch (state.status) {
        case "idle":
          return "Nothing started";
    
        case "loading":
          return "Loading...";
        case "success":
          return state.data;
        case "error":
          return state.error;
      }
    }
    

    El compilador registra qué campos existen después de cada verificación:

    status = "success"
            ↓
    data exists
    
    status = "error"
            ↓
    error exists
    

    Compare el modelo común de booleanos y valores opcionales:

    interface State {
      loading: boolean;
      data?: User[];
      error?: string;
    }
    

    Nada impide describir un estado que nunca debería ocurrir:

    {
      loading: true,
      data: [...],
      error: "Something failed"
    }
    

    La unión hace que tales combinaciones sean muy difíciles de expresar. Modelice los estados válidos en lugar de dispersar propiedades opcionales y confiar en que todos las combinen correctamente.

    Tipos de resultado para fallos explícitos

    Muchas operaciones tienen exactamente dos resultados:

    Success
       OR
    Failure
    

    El tipo Result especifica ambos resultados mediante un discriminante booleano:

    type Result<T, E> =
      | {
          success: true;
          data: T;
        }
      | {
          success: false;
          error: E;
        };
    

    Una función que lo devuelve:

    function getUser(): Result<User, string> {
      return {
        success: true,
        data: user
      };
    }
    

    El llamante debe verificar success antes de acceder a data o error:

    const result = getUser();
    
    if (result.success) {
      console.log(result.data);
    } else {
      console.error(result.error);
    }
    
                 Service
                    │
                    ▼
              Result<T, E>
               /       \
              /         \
             ▼           ▼
          Success      Failure
            │             │
           data          error
    

    Esto se aplica a los fallos comerciales esperados, como “el correo electrónico ya está registrado”. Las excepciones siguen siendo adecuadas para los errores reales y las fallas de infraestructura; el tipo Result simplemente hace que los fallos previstos sean visibles en la firma.

    Genéricos y herramientas a nivel de tipo

    Los genéricos preservan la relación entre entrada y salida

    Usar any descarta la información:

    function identity(value: any): any {
      return value;
    }
    

    Un parámetro de tipo la mantiene:

    function identity<T>(value: T): T {
      return value;
    }
    

    El resultado inferido sigue al argumento:

    const a = identity("hello");
    // string
    
    const b = identity(100);
    // number
    

    La relación entre entrada y salida se mantiene:

    Input T
      │
      ▼
    Function<T>
      │
      ▼
    Output T
    

    Los genericos también hacen que los contratos compartidos sean reutilizables. Un solo sobre de respuesta:

    interface ApiResponse<T> {
      data: T;
      status: number;
      message: string;
    }
    

    describe muchos payloads:

    type UserResponse =
      ApiResponse<User>;
    
    type ProductResponse =
      ApiResponse<Product>;
    

    Restricciones: requerir una forma

    Esto no se compila:

    function getId<T>(item: T) {
      return item.id;
    }
    

    porque nada le indica a TypeScript que T tiene un id. Una restricción agrega esa garantía:

    function getId<T extends { id: string }>(
      item: T
    ) {
      return item.id;
    }
    

    Se acepta cualquier objeto con un id de tipo cadena, incluidos los campos adicionales:

    getId({
      id: "123",
      name: "Hareesh"
    });
    

    Lea la restricción de esta manera:

    T can be anything
    BUT
    T must have id: string
    

    keyof y acceso indexado

    Dada una interfaz:

    interface User {
      id: string;
      name: string;
      age: number;
    }
    

    keyof genera la unión de los nombres de sus propiedades:

    type UserKey = keyof User;
    
    "id" | "name" | "age"
    

    Combinar keyof con un segundo parámetro de tipo y el tipo de acceso indexado T[K] genera un accesorio cuyo tipo de retorno coincide con la clave:

    function getProperty<T, K extends keyof T>(
      object: T,
      key: K
    ): T[K] {
      return object[key];
    }
    
    const user = {
      id: "1",
      name: "Hareesh",
      age: 30
    };
    
    getProperty(user, "name");
    

    Una clave real se compila correctamente:

    getProperty(user, "name");
    

    Una clave faltante provoca un error de compilación:

    getProperty(user, "salary");
    

    La solidez proviene de la combinación de tres herramientas que trabajan juntas:

    Generics
       +
    keyof
       +
    Indexed Access
    

    Tipos mapeados: transformar un tipo existente

    Los tipos mapeados recorren las claves de un tipo para crear uno nuevo. Partiendo de:

    interface User {
      id: string;
      name: string;
      email: string;
    }
    

    se puede derivar una versión con todos los campos opcionales:

    type OptionalUser = {
      [K in keyof User]?: User[K];
    };
    

    conceptualmente equivalente a escribir:

    {
      id?: string;
      name?: string;
      email?: string;
    }
    

    Así se definen funciones integradas como Partial.

    Tipos condicionales: decisiones a nivel de tipo

    Los tipos condicionales eligen entre dos tipos según su capacidad de asignación:

    T extends U ? X : Y
    

    Este desempaqueta los tipos de elementos de array y deja todo lo demás intacto:

    type Flatten<T> =
      T extends Array<infer U>
        ? U
        : T;
    
    type A = Flatten<string[]>;
    // string
    
    type B = Flatten<number>;
    // number
    

    En este punto, el sistema de tipos se comporta como un pequeño lenguaje en tiempo de compilación, lo que exige moderación.

    infer: extraer parte de un tipo

    Dentro de un tipo condicional, infer declara una variable de tipo que TypeScript completa mediante coincidencias. Esto reimplanta el ReturnType incorporado:

    type MyReturnType<T> =
      T extends (...args: any[]) => infer R
        ? R
        : never;
    

    Úsalo junto con typeof para derivar un tipo a partir de una función existente, de modo que nunca se desvíe de su implementación:

    function getUser() {
      return {
        id: "1",
        name: "Hareesh"
      };
    }
    
    type User = MyReturnType<typeof getUser>;
    

    Las definiciones de tipos de las bibliotecas dependen en gran medida de esto.

    Primero, los tipos de utilidad incorporados

    Antes de escribir ayudantes inteligentes, conozca qué viene incluido con el lenguaje:

    Partial
    Required
    Readonly
    Pick
    Omit
    Record
    Exclude
    Extract
    NonNullable
    ReturnType
    Parameters
    InstanceType
    Awaited
    

    Eliminar un campo sensible, por ejemplo, se hace en una sola línea en lugar de mediante una interfaz duplicada que causaría desincronización:

    interface User {
      id: string;
      name: string;
      email: string;
      password: string;
    }
    
    type PublicUser =
      Omit<User, "password">;
    

    La guía sobre los tipos de utilidad integrados en TypeScript aborda cada uno de estos temas en profundidad.

    Record con un conjunto cerrado de claves

    Record es adecuado para búsquedas y configuraciones, especialmente cuando las claves provienen de una unión literal:

    type Permission =
      "read" |
      "write" |
      "delete";
    
    type PermissionMap =
      Record<Permission, boolean>;
    
    const permissions: PermissionMap = {
      read: true,
      write: false,
      delete: false
    };
    

    Si se omite un permiso, TypeScript informa sobre la clave faltante. Un tipo de clave amplio pierde esa garantía:

    const permissions: Record<string, boolean>
    

    debido a que Record<string, boolean> acepta prácticamente cualquier clave de tipo string, las omisiones y errores tipográficos pasan desapercibidos.

    Patrones de seguridad para identificadores y datos no confiables

    Tipos especializados para identificadores de dominio

    Dos identificadores pueden ser cadenas pero significar cosas diferentes:

    const userId: string;
    const productId: string;
    

    Estructuralmente, TypeScript no puede distinguirlos. Al interseccionar string con una propiedad fantasma se crean tipos distintos:

    type UserId =
      string & {
        readonly __brand: "UserId";
      };
    
    type ProductId =
      string & {
        readonly __brand: "ProductId";
      };
    

    Luego, las funciones pueden exigir el tipo correcto de ID:

    function getUser(id: UserId) {}
    
    function getProduct(id: ProductId) {}
    

    Passar un ProductId donde se espera un UserId genera un error de compilación. La marca nunca existe en tiempo de ejecución; se crean valores con marca a través de un pequeño constructor o función de validación que realiza el casting en un único lugar. Conceptualmente:

    string
      │
      ├── UserId
      ├── ProductId
      ├── OrderId
      └── TransactionId
    

    Esto resulta útil en sistemas grandes donde docenas de identificadores comparten un mismo tipo primitivo.

    Guardianes de tipo para entrada unknown

    Los datos provenientes de la red, el almacenamiento o la entrada del usuario deben ingresar como unknown:

    const data: unknown = await response.json();
    

    Un casting es el atajo tentador:

    const user = data as User;
    

    En su lugar, restrinja el valor con un guardián de tipo definido por el usuario; su tipo de retorno value is User le indica al compilador qué demuestra un resultado true:

    function isUser(
      value: unknown
    ): value is User {
      return (
        typeof value === "object" &&
        value !== null &&
        "id" in value &&
        "name" in value
      );
    }
    
    if (isUser(data)) {
      console.log(data.name);
    }
    

    Tenga en cuenta que los tipos se eliminan en tiempo de ejecución. Una interfaz como esta:

    interface User {
      id: string;
    }
    

    no valida nada de lo que envía la API. El guardián anterior solo verifica que existan las claves, no sus tipos; por lo tanto, para entradas no confiables, combine TypeScript con un validador de esquema en tiempo de ejecución.

    Verificación exhaustiva con never

    Una de las combinaciones más efectivas del lenguaje:

    Discriminated Union
            +
    never
            +
    switch
    

    Utilice una unión de estados:

    type Status =
      | "loading"
      | "success"
      | "error";
    

    y una función auxiliar que solo acepte never:

    function assertNever(
      value: never
    ): never {
      throw new Error(
        `Unexpected value: ${value}`
      );
    }
    

    Cuando se maneja cada miembro, la rama por defecto muestra el tipo never, por lo que la llamada se compila:

    function render(status: Status) {
      switch (status) {
        case "loading":
          return "Loading";
        case "success":
          return "Success";
        case "error":
          return "Error";
        default:
          return assertNever(status);
      }
    }
    

    Ahora un compañero de equipo extiende la unión:

    Now imagine someone adds:"cancelled" to Status.
    

    La rama por defecto recibe "cancelled", que no se puede asignar a never, y la compilación falla hasta que se maneje ese caso. El compilador actúa como un revisor de diseño que recuerda a cada consumidor.

    Tipos de literales de plantilla

    TypeScript puede componer tipos de literales de cadena a partir de otros tipos:

    type Entity =
      "user" |
      "order" |
      "product";
    
    type Event =
      `${Entity}:created` |
      `${Entity}:updated` |
      `${Entity}:deleted`;
    

    La unión resultante contiene todas las combinaciones:

    user:created
    user:updated
    user:deleted
    
    order:created
    order:updated
    order:deleted
    
    product:created
    product:updated
    product:deleted
    

    Los usos prácticos incluyen:

    • nombres de eventos
    • claves de eventos analíticos
    • cadenas de permisos
    • patrones de rutas
    • nombres de flags de funcionalidad
    • tokens del sistema de diseño

    as const cuando los valores son la fuente de verdad

    Un literal de array de cadenas se amplía:

    const roles = [
      "admin",
      "editor",
      "viewer"
    ];
    

    Su tipo es string[]. Al agregar as const se obtiene una tupla de literales de solo lectura:

    const roles = [
      "admin",
      "editor",
      "viewer"
    ] as const;
    

    De la cual se deriva directamente un tipo de unión:

    type Role =
      typeof roles[number];
    
    becomes:
    
    
    "admin" |
    "editor" |
    "viewer"
    

    Defina los valores una sola vez y nunca mantenga manualmente una unión paralela.

    satisfies para configuración verificada

    satisfies valida una expresión contra un tipo sin ampliarla a ese tipo:

    type Config = {
      retries: number;
      environment:
        | "development"
        | "production";
    };
    
    const config = {
      retries: 3,
      environment: "production"
    } satisfies Config;
    

    El objeto se verifica contra Config, pero config.environment mantiene el tipo literal "production", algo que una anotación simple perdería. Es adecuado para:

    • configuración de rutas
    • banderas de funcionalidad
    • tokens de diseño
    • objetos de configuración estática
    • mapas de permisos

    Inyección de dependencias

    Crear dependencias dentro de una clase la une a ellas:

    class UserService {
      private api = new ApiClient();
    }
    

    Recibirlas a través del constructor mantiene a la clase agnóstica:

    class UserService {
      constructor(
        private readonly api: ApiClient
      ) {}
    }
    

    En producción se pasa el cliente real:

    const service =
      new UserService(apiClient);
    

    y en las pruebas se utiliza uno simulado:

    const service =
      new UserService(mockApiClient);
    
                    UserService
                          ▲
                          │
                     Dependency
                          │
                 ┌────────┴────────┐
                 ▼                 ▼
            ApiClient         MockApiClient
           Production            Testing
    

    Es una de las formas más económicas de obtener código probable, y solo se necesitan los parámetros del constructor.

    Diferenciar patrones similares

    Patrón de estado o unión discriminada?

    Dado el ciclo de vida de un pedido:

    Draft
    Paid
    Shipped
    Cancelled
    

    Una unión discriminada podría ser todo lo que necesitas:

    type Order =
      | { status: "draft" }
      | { status: "paid" }
      | { status: "shipped" }
      | { status: "cancelled" };
    

    Pero cuando cada estado implica un comportamiento considerable:

    Draft
     ├── edit()
     ├── submit()
    
    Paid
     ├── refund()
     ├── ship()
    Shipped
     ├── track()
     └── deliver()
    

    un patrón de estado, donde cada objeto de estado implementa las operaciones permitidas, podría ser más adecuado. Decide según el nivel de complejidad que tenga cada estado, no por la terminología.

    Estrategia o Estado?

    Un tema frecuente en las entrevistas. Con Strategy, el cliente elige el algoritmo:

    Order
      │
      ▼
    Strategy
      ├── CreditCard
      ├── PayPal
      └── UPI
    

    Con State, el objeto cambia su propio comportamiento a medida que avanza por su ciclo de vida:

    Order
      │
      ├── Draft
      ├── Paid
      └── Shipped
    

    Strategy varía la forma en que se realiza una tarea; State varía lo que hace un objeto en su etapa actual.

    Fábrica o Strategy?

    Una Fábrica responde “¿qué objeto debe crearse?”:

    PaymentFactory.create("stripe");
    

    Strategy responde “¿qué comportamiento debe ejecutarse?”:

    new Order(discountStrategy);
    

    Se combinan de forma natural: una fábrica produce la estrategia que realiza el trabajo:

    Factory
       ↓
    creates
       ↓
    Strategy
       ↓
    executes behavior
    

    Aplicación de los patrones en React

    Propiedades Variant en lugar de flags booleanos

    Los booleanos independientes permiten situaciones absurdas, como un botón que sea al mismo tiempo principal y de peligro:

    interface ButtonProps {
      primary?: boolean;
      danger?: boolean;
      loading?: boolean;
    }
    

    Una unión de variantes permite que cada una declare sus propios requisitos:

    type ButtonProps =
      | {
          variant: "primary";
          loading?: boolean;
        }
      | {
          variant: "danger";
          confirmationRequired: boolean;
        };
    

    La variante de peligro ahora debe especificar confirmationRequired.

    APIs de componentes que rechazan contradicciones

    Un componente que muestra un enlace o un botón con href y onClick opcionales permitiría usar ambos o ninguno. El fragmento a continuación muestra la versión laxa, la alternativa diferenciada y un uso válido:

    {
      href?: string;
      onClick?: () => void;
    }
    
    use:
    type ActionProps =
      | {
          type: "link";
          href: string;
        }
      | {
          type: "button";
          onClick: () => void;
        };
    
    
    Now:
    
    <Action
      type="link"
      href="/users"
    />
    

    Se rechaza una variante de enlace a la que se le proporciona un manejador de clic en lugar de href:

    <Action
      type="link"
      onClick={...}
    />
    

    Los modos soportados, expresados de forma clara:

    Link
    Button
    

    TypeScript ahora forma parte de la arquitectura del componente en lugar de ser una documentación que se vuelve obsoleta.

    Un bus de eventos seguro desde el punto de vista tipológico

    Comience con un mapa que relacione los nombres de eventos con los tipos de carga:

    type Events = {
      "user:created": User;
      "user:deleted": UserId;
      "order:created": Order;
    };
    

    Un genérico para el bus basado en ese mapa vincula cada nombre a su carga mediante keyof y T[K]:

    class EventBus<T extends Record<string, unknown>> {
      on<K extends keyof T>(
        event: K,
        handler: (data: T[K]) => void
      ) {
        // implementation
      }
    
    emit<K extends keyof T>(
        event: K,
        data: T[K]
      ) {
        // implementation
      }
    }
    

    El payload correcto se compila:

    bus.emit("user:created", user);
    

    El incorrecto no se compila:

    bus.emit("user:created", order);
    

    Varios herramientas son útiles aquí:

    Generics
    +
    keyof
    +
    Indexed Access
    +
    Mapped Type thinking
    

    Tipos en toda la capa de API

    Un frontend maduro suele organizar el acceso a los datos de esta manera:

    Component
         ↓
    Hook
         ↓
    Service
         ↓
    Repository
         ↓
    API Client
         ↓
    HTTP
    

    con tipos que se transmiten en cada paso. Un repositorio puede aceptar IDs personalizados y devolver un Result:

    type ApiResponse<T> = {
      data: T;
      status: number;
    };
    
    interface UserRepository {
      getUser(id: UserId):
        Promise<Result<User, ApiError>>;
    }
    

    El componente ya no se ocupa de esto en cada capa:

    any
    

    Las propias firmas indican qué puede tener éxito, qué puede fallar y con qué tipos.

    Antipatrones a evitar

    any en todas partes

    function process(data: any) {}
    

    Un parámetro any desactiva la verificación de todo lo que toca. Prefiera:

    function process(data: unknown) {}
    

    y restrinja el tipo antes de usarlo.

    Afirmaciones como validación

    const user =
      response.data as User;
    

    Un cast con as no verifica nada; solo le pide al compilador que confíe en usted. Valide los datos no fiables en tiempo de ejecución.

    Genéricos por sí mismos

    Evite firmas como estas:

    function transform<
      T,
      U,
      V,
      R
    >(...) {}
    

    a menos que cada parámetro de tipo exprese una relación real. Un parámetro de tipo utilizado una sola vez suele ser innecesario.

    Uniones enormes

    Una unión discriminada con cien miembros es difícil de navegar y evolucionar. El dominio podría necesitar una abstracción diferente, como uniones anidadas o trasladar la variación a los datos.

    Ingenuidad a nivel de tipo

    Cuando un tipo es más difícil de seguir que la lógica empresarial que describe, retroceda:

    Type complexity
           │
           ▼
    Developer complexity
           │
           ▼
    Maintenance cost
    

    La seguridad de tipo tiene un costo. Busque la mayor seguridad útil por unidad de complejidad, no los tipos más sofisticados.

    Un árbol de decisiones para elegir

    Este árbol relaciona las presiones comunes con los patrones candidatos:

    PROBLEM
       │
       ├── Need to create objects?
       │       ├── Simple creation → Constructor
       │       ├── Complex creation → Builder
       │       ├── Multiple implementations → Factory
       │       └── Families of objects → Abstract Factory
       │
       ├── Need to integrate another system?
       │       └── Adapter
       │
       ├── Complex subsystem?
       │       └── Facade
       │
       ├── Add behavior without modifying object?
       │       └── Decorator
       │
       ├── Multiple interchangeable algorithms?
       │       └── Strategy
       │
       ├── Subscribers react to changes?
       │       └── Observer
       │
       ├── Need actions/history/undo?
       │       └── Command
       │
       ├── Data-access abstraction?
       │       └── Repository
       │
       ├── Complex state lifecycle?
       │       └── State / Discriminated Union
       │
       └── Type-level problem?
               ├── Reuse → Generics
               ├── Transform → Mapped Types
               ├── Decision → Conditional Types
               ├── Extract → infer
               ├── Property safety → keyof
               ├── Literal safety → as const
               ├── Contract validation → satisfies
               └── Domain safety → Branded Types
    

    Considérelo un punto de partida para la discusión; el principio de “empezar simple” sigue siendo aplicable en cada nodo.

    Qué aprender primero

    Para los ingenieros front-end que se preparan para roles de nivel senior o líder, memorizar cada patrón del Gang of Four es un mal uso del tiempo. Un orden práctico:

    Lo esencial:

    Discriminated Unions
    Generics
    Type Guards
    keyof
    Mapped Types
    Utility Types
    Composition
    Strategy
    Repository
    Factory
    

    Se recomienda encarecidamente lo siguiente:

    Result Type
    Branded Types
    Conditional Types
    infer
    Exhaustive Checking
    Dependency Injection
    Adapter
    Facade
    Observer
    Decorator
    

    Vale la pena comprenderlo conceptualmente:

    Builder
    Command
    State
    Abstract Factory
    Singleton
    

    El trabajo diario en front-end se basa mucho más en esta combinación:

    Generics
    +
    Discriminated Unions
    +
    Composition
    +
    Strategy
    +
    Repository
    

    que en cualquier patrón de Fábrica Abstracta de los libros de texto.

    Cómo cambia el juicio con la experiencia

    Al principio, los ingenieros se preguntan: “¿Qué patrón debo usar aquí?”. Con experiencia en sistemas grandes, la pregunta pasa a ser: “¿Cuál es la abstracción más simple que resuelve este problema sin hacer que el sistema sea más difícil de entender?”. El flujo de trabajo basado en los patrones se ve así:

    Problem
      ↓
    Pattern
      ↓
    More classes
      ↓
    More abstractions
    

    El flujo de trabajo basado en el problema se ve así:

    Problem
      ↓
    Understand volatility
      ↓
    Identify boundary
      ↓
    Start simple
      ↓
    Introduce abstraction only where repetition/change justifies it
    

    Los buenos patrones surgen cuando se clarifican las presiones de la arquitectura; no se imponen de antemano.

    Preguntas de entrevista que evalúan la verdadera comprensión

    “¿Qué es el patrón Factory?”, prueba la memorización. Estas prueban el juicio.

    Arquitectura:

    1. Frente a un componente React de 2,000 líneas, ¿cómo elige qué abstracción introducir?
    2. ¿Cuándo rechazaría un patrón que parece adecuado?
    3. ¿Cómo puede saber si una abstracción es prematura?
  • ¿Cuándo es preferible la composición sobre la herencia?
  • ¿Cómo se evita que un Singleton se convierta en un estado mutable global?
  • Conceptos básicos de TypeScript:

    1. ¿Cómo modelarías una solicitud con estados de carga, éxito, error y reintentos?
    2. ¿Cómo se impiden las combinaciones inválidas de propiedades en React?
    3. ¿En qué se diferencian unknown, any y never?
    4. ¿Cuándo elegirías type en lugar de interface, y viceversa?
    5. ¿Cómo interactúa keyof con los genericos?

    TypeScript avanzado:

    1. ¿Cuál es un caso de uso concreto para los tipos condicionales?
    2. ¿Qué función tiene infer?
    3. ¿Qué es un tipo mapeado?
    4. ¿Qué son los tipos condicionales distributivos?
    5. ¿Cuándo valen la pena los tipos marcados?
    6. ¿Qué problema resuelve satisfies?
    7. ¿Cómo afecta as const a la inferencia?

    Diseño en el mundo real:

    1. Diseñar un bus de eventos seguro desde el punto de vista tipológico.
    2. Diseñar un cliente API seguro desde el punto de vista tipológico.
    3. Diseñar un sistema de permisos utilizando TypeScript.
    4. Diseñar una abstracción de pagos que soporte Stripe, PayPal y un tercer proveedor.
    5. ¿Cómo agregaría una capa de Repository a una aplicación React existente sin tener que reescribirla?
    6. ¿Cómo tipificaría eventos WebSocket con diferentes cargas de datos?
    7. ¿Cómo evitaría que se pasara un ProductId donde se requiere un UserId?

    Una pregunta reveladora: “Describa un momento en el que eligió deliberadamente no usar un patrón de diseño”. Una respuesta sólida explica el equilibrio: al tener una única implementación y sin cambios probables que prevenir, la indirección no habría reducido la complejidad, por lo que el código se mantuvo simple hasta que otro caso de uso lo justificó.

    Un modelo mental final

    Comience con el problema empresarial, identifique dónde reside la complejidad, separe los cambios de comportamiento de los cambios estructurales, y luego deje que la seguridad tipológica refuerce el resultado.

                     BUSINESS PROBLEM
                            │
                            ▼
                    Identify Complexity
                            │
                            ▼
                 What is likely to change?
                            │
                 ┌──────────┴──────────┐
                 ▼                     ▼
              Behavior              Structure
                 │                     │
            Strategy/State       Adapter/Facade
                 │                     │
                 └──────────┬──────────┘
                            ▼
                       Type Safety
                            │
            ┌───────────────┼────────────────┐
            ▼               ▼                ▼
         Generics        Unions           Utilities
            │               │                │
            ▼               ▼                ▼
         keyof          Result Type      Mapped Types
         infer          State Model      Conditional
         satisfies      Exhaustive       Record
            │               │                │
            └───────────────┼────────────────┘
                            ▼
                     SIMPLEER CODE
    

    Puntos clave

    Los patrones funcionan mejor como vocabulario compartido: “esto es un adaptador”, “estos comportamientos son intercambiables”, “estos estados pertenecen a una unión”, “etiqueta estos IDs” y, lo más importante, “esta abstracción aún no ha merecido su complejidad”. Una buena implementación en TypeScript se evalúa por si el sistema posee estas características, y no por lo ingeniosos que sean sus tipos:

    Invalid states
         ↓
    become difficult to represent
    
    Changing implementations
         ↓
    don't break consumers
    Business rules
         ↓
    are visible in the types
    Shared behavior
         ↓
    is reusable without duplication
    Complexity
         ↓
    is isolated behind clear boundaries
    
    • Elija los patrones según la complejidad que eliminen, no por su familiaridad.
    • Preferir las uniones, los tipos Result y las verificaciones exhaustivas en lugar de campos opcionales y la esperanza.
    • Valide los datos en los límites del tiempo de ejecución; los tipos por sí solos no lo protegen allí.
    • Busque la abstracción más simple que maneje el cambio que realmente espera.

    Lectura relacionada