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.

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:
// ❌ 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:
// ❌ 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:
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:
// 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:
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:
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
- Modela estados, no flags — Las uniones discriminadas eliminan estados imposibles
- Marca tus primitivos — No dejes que
stringlo signifique todo - Aprovecha
satisfies— Obtén seguridad de tipos e inferencia literal a la vez - Construye tipos de utilidad — Pequeños helpers de tipos se acumulan hasta lograr enormes mejoras de seguridad
- 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.


