Saltar al contenido

Patrones de TypeScript para bases de código grandes

Patrones avanzados de TypeScript que uso a diario: uniones discriminadas, branded types, patrón builder y utility types para bases de código grandes.

3 min de lectura
Código TypeScript en un editor moderno con tema oscuro

Más allá de los tipos básicos

El verdadero poder de TypeScript se nota en las bases de código grandes, donde la seguridad de tipos evita clases enteras de errores. Estos son los patrones a los que recurro con más frecuencia.

Uniones discriminadas para máquinas de estado

En lugar de campos opcionales y flags booleanos, modela los estados de forma explícita:

tstypescript
// ❌ Boolean soup — easy to create invalid states
interface Request {
  data?: Response;
  error?: Error;
  isLoading: boolean;
  isError: boolean;
}
 
// ✅ Discriminated union — each state is explicit
type AsyncState<T> =
  | { status: "idle" }
  | { status: "loading" }
  | { status: "success"; data: T }
  | { status: "error"; error: Error };
 
function renderUser(state: AsyncState<User>) {
  switch (state.status) {
    case "idle":
      return null;
    case "loading":
      return <Spinner />;
    case "success":
      return <UserCard user={state.data} />;
    case "error":
      return <ErrorMessage error={state.error} />;
  }
}

El compilador garantiza que manejes todos los casos. ¿Agregas un nuevo estado? TypeScript te indica cada lugar que necesita actualizarse.

Branded types para la seguridad del dominio

Los tipos primitivos no evitan que confundas valores que comparten un mismo tipo:

tstypescript
// ❌ Both are strings — easy to swap by accident
function transferMoney(fromAccountId: string, toAccountId: string) {}
transferMoney(toId, fromId); // Bug! No compiler error.
 
// ✅ Branded types — compiler catches the swap
type AccountId = string & { readonly __brand: "AccountId" };
type TransactionId = string & { readonly __brand: "TransactionId" };
 
function accountId(id: string): AccountId {
  return id as AccountId;
}
 
function transfer(from: AccountId, to: AccountId) {}
transfer(txnId, accountId("123")); // ✅ Compiler error!

Uso branded types para IDs, monedas, correos electrónicos y cualquier valor de dominio donde confundir primitivos causaría errores reales.

El patrón builder para objetos complejos

Al construir objetos con muchos campos opcionales, los builders ofrecen una API fluida con validación en tiempo de compilación:

tstypescript
class QueryBuilder<T extends Record<string, unknown>> {
  private query: Partial<T> = {};
 
  where<K extends keyof T>(key: K, value: T[K]): this {
    this.query[key] = value;
    return this;
  }
 
  orderBy(field: keyof T, direction: "asc" | "desc" = "asc"): this {
    // Store ordering config
    return this;
  }
 
  limit(n: number): this {
    // Store limit
    return this;
  }
 
  build(): T {
    return this.query as T;
  }
}
 
// Usage — fully type-safe
const query = new QueryBuilder<User>()
  .where("role", "admin")
  .where("active", true)
  .orderBy("createdAt", "desc")
  .limit(10)
  .build();

Tipos de utilidad que deberías conocer

Los tipos de utilidad integrados en TypeScript son muy potentes. Estos son los que más uso:

tstypescript
// Extract only the keys whose values match a type
type KeysOfType<T, V> = {
  [K in keyof T]: T[K] extends V ? K : never;
}[keyof T];
 
type User = {
  id: number;
  name: string;
  email: string;
  age: number;
};
 
type StringKeys = KeysOfType<User, string>; // "name" | "email"
 
// Make specific keys required
type RequireKeys<T, K extends keyof T> = T & Required<Pick<T, K>>;
 
type Config = {
  host?: string;
  port?: number;
  debug?: boolean;
};
 
type ProductionConfig = RequireKeys<Config, "host" | "port">;
// { host: string; port: number; debug?: boolean }
 
// Deep readonly — prevents mutation at any depth
type DeepReadonly<T> = {
  readonly [K in keyof T]: T[K] extends object ? DeepReadonly<T[K]> : T[K];
};

satisfies para aserciones const

El operador satisfies te da lo mejor de ambos mundos: verificación de tipos e inferencia de tipos precisa:

tstypescript
const ROUTES = {
  home: "/",
  blog: "/blog",
  about: "/about",
  contact: "/contact",
} as const satisfies Record<string, string>;
 
// Type is narrowed to the literal values
type Route = (typeof ROUTES)[keyof typeof ROUTES];
// "/" | "/blog" | "/about" | "/contact"
 
// Typo in the value? Compiler catches it.
// Missing a required key? Compiler catches it.

Emisores de eventos con seguridad de tipos

Combina genéricos con tipos mapeados para obtener eventos completamente tipados:

tstypescript
type EventMap = {
  "user:login": { userId: string; timestamp: number };
  "user:logout": { userId: string };
  "order:created": { orderId: string; total: number };
};
 
class TypedEmitter<T extends Record<string, unknown>> {
  private handlers = new Map<keyof T, Set<(data: never) => void>>();
 
  on<K extends keyof T>(event: K, handler: (data: T[K]) => void): void {
    if (!this.handlers.has(event)) {
      this.handlers.set(event, new Set());
    }
    this.handlers.get(event)!.add(handler as (data: never) => void);
  }
 
  emit<K extends keyof T>(event: K, data: T[K]): void {
    this.handlers.get(event)?.forEach((handler) => handler(data as never));
  }
}
 
const emitter = new TypedEmitter<EventMap>();
 
emitter.on("user:login", (data) => {
  console.log(data.userId); // ✅ Typed
  console.log(data.timestamp); // ✅ Typed
});
 
emitter.emit("order:created", {
  orderId: "123",
  total: 99.99,
  // extra: true — ✅ Compiler error!
});

Puntos clave

  1. Modela estados, no flags — Las uniones discriminadas eliminan estados imposibles
  2. Marca tus primitivos — No dejes que string lo signifique todo
  3. Aprovecha satisfies — Obtén seguridad de tipos e inferencia literal a la vez
  4. Construye tipos de utilidad — Pequeños helpers de tipos se acumulan hasta lograr enormes mejoras de seguridad
  5. Deja que el compilador trabaje para ti — Si un error se puede detectar en tiempo de compilación, debería detectarse ahí

TypeScript da lo mejor de sí cuando te apoyas en el sistema de tipos en lugar de pelear contra él con any y aserciones de tipo. La inversión inicial da sus frutos en cada refactorización, cada revisión de código y cada despliegue a producción.

Wilfredo Rujel

Wilfredo Rujel

Ingeniero de Software Full Stack

Compartir esta publicaciónX