Hay un momento específico en tu relación con TypeScript donde dejás de pelearle y empezás a entenderlo. Para mí fue a las 2AM de un martes, con un bug de producción que hubiera sido imposible con tipos bien definidos. Desde ese momento cambié cómo pienso el código.
Esto no es un tutorial de introducción. Si todavía estás peleando con interface vs type, hay mil artículos para eso. Esto es lo que realmente tengo en mi cabeza cuando programo en TypeScript hoy — los patrones que uso sin pensar, los que me salvaron el culo más de una vez y los errores que cometí antes de entenderlos.
Discriminated Unions: el patrón que más uso
Si tuviera que quedarme con un solo patrón de TypeScript, es este. La idea es simple: tenés un tipo unión donde cada variante tiene una propiedad discriminante — generalmente type o kind — que le dice a TypeScript exactamente con qué estás trabajando.
type ApiResponse<T> =
| { status: 'loading' }
| { status: 'error'; error: string; code: number }
| { status: 'success'; data: T; timestamp: Date };
function renderUser(response: ApiResponse<User>) {
switch (response.status) {
case 'loading':
return <Spinner />;
case 'error':
// TypeScript sabe que acá existe response.error y response.code
return <ErrorMessage message={response.error} code={response.code} />;
case 'success':
// TypeScript sabe que acá existe response.data y response.timestamp
return <UserCard user={response.data} />;
}
}
Lo que me encanta de esto es que TypeScript te avisa si te olvidás un caso. Agregás 'cancelled' a la unión y de repente el compilador te dice exactamente dónde tenés que manejar esa situación. Es como tener un colega que revisa tu código sin ser molesto.
Lo uso en eventos de dominio, estados de UI, resultados de operaciones asíncronas. En un proyecto de e-commerce que hice el año pasado, modelé todos los estados de un pedido así:
type OrderState =
| { kind: 'draft'; items: CartItem[] }
| { kind: 'pending_payment'; orderId: string; total: Money }
| { kind: 'paid'; orderId: string; paymentId: string; paidAt: Date }
| { kind: 'shipped'; orderId: string; trackingCode: string }
| { kind: 'delivered'; orderId: string; deliveredAt: Date }
| { kind: 'cancelled'; orderId: string; reason: string };
Cada estado tiene exactamente la información que tiene sentido para ese estado. No hay campos opcionales raros, no hay trackingCode: string | null que no sabés si es null porque no fue enviado o porque es un pedido viejo. La forma del tipo es la documentación.
Branded Types: cuando string no alcanza
Este me costó más entenderlo pero hoy no puedo vivir sin él. El problema es simple: userId: string y productId: string son el mismo tipo para TypeScript, pero no para tu negocio. Mezclarlos es un bug.
// Sin branded types — TypeScript no se queja de esto:
function getUser(id: string) { /* ... */ }
function getProduct(id: string) { /* ... */ }
const productId = '123';
getUser(productId); // TypeScript dice que está bien. Está mal.
La solución con branded types:
type Brand<T, B> = T & { readonly __brand: B };
type UserId = Brand<string, 'UserId'>;
type ProductId = Brand<string, 'ProductId'>;
type OrderId = Brand<string, 'OrderId'>;
// Funciones constructoras que validan y brandean
function createUserId(id: string): UserId {
if (!id.match(/^usr_[a-z0-9]+$/)) {
throw new Error(`Invalid user ID format: ${id}`);
}
return id as UserId;
}
function getUser(id: UserId): Promise<User> { /* ... */ }
function getProduct(id: ProductId): Promise<Product> { /* ... */ }
const userId = createUserId('usr_abc123');
const productId = 'prod_xyz789' as ProductId;
getUser(productId); // TS Error: Argument of type 'ProductId' is not assignable to parameter of type 'UserId'
getUser(userId); // OK
El __brand nunca existe en runtime — es solo una ficción para el type checker. El costo es cero en producción, el beneficio es enorme en desarrollo.
Lo uso también para valores primitivos con semántica específica:
type Percentage = Brand<number, 'Percentage'>;
type Milliseconds = Brand<number, 'Milliseconds'>;
type USD = Brand<number, 'USD'>;
function calculateDiscount(price: USD, discount: Percentage): USD {
return (price * (1 - discount / 100)) as USD;
}
// No podés accidentalmente pasar milisegundos como precio
const delay: Milliseconds = 5000 as Milliseconds;
const price: USD = 99.99 as USD;
calculateDiscount(delay, price); // Error en compilación, no en producción
Generics avanzados: más allá de Array<T>
Generics es donde TypeScript se pone realmente poderoso y donde la gente se pierde. Yo me perdí muchas veces. Voy a mostrar los patrones que quedaron en mi toolbelt.
Conditional Types
type Awaited<T> = T extends Promise<infer U> ? U : T;
// Uso real: cuando tenés que manejar valores que pueden ser async o sync
type MaybeAsync<T> = T | Promise<T>;
type Resolved<T> = T extends Promise<infer U> ? U : T;
// Unwrap nested arrays
type Flatten<T> = T extends Array<infer U> ? U : T;
type StringOrNumber = Flatten<string[]>; // string
type JustString = Flatten<string>; // string
Template Literal Types
Este me voló la cabeza cuando lo descubrí. Podés hacer aritmética de strings en el type system:
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';
type ApiEndpoint = '/users' | '/products' | '/orders';
type ApiRoute = `${HttpMethod} ${ApiEndpoint}`;
// = 'GET /users' | 'GET /products' | 'GET /orders' | 'POST /users' | ...
// Esto lo uso para event names en sistemas de eventos
type EntityName = 'user' | 'product' | 'order';
type CrudAction = 'created' | 'updated' | 'deleted';
type DomainEvent = `${EntityName}.${CrudAction}`;
// = 'user.created' | 'user.updated' | 'user.deleted' | 'product.created' | ...
type EventHandler<T extends DomainEvent> = (event: T) => void;
function on<T extends DomainEvent>(event: T, handler: EventHandler<T>) {
// registro el handler
}
on('user.created', (event) => { /* event es 'user.created' */ });
on('invalid.event', () => {}); // Error de compilación
Mapped Types con modificadores
// El clásico DeepReadonly que no viene en la stdlib
type DeepReadonly<T> = {
readonly [K in keyof T]: T[K] extends object ? DeepReadonly<T[K]> : T[K];
};
// DeepPartial para formularios
type DeepPartial<T> = {
[K in keyof T]?: T[K] extends object ? DeepPartial<T[K]> : T[K];
};
// Pick con dot notation — lo escribí para un form builder
type PathsToString<T> = T extends string | number | boolean
? never
: {
[K in keyof T & string]: K | `${K}.${PathsToString<T[K]>}`;
}[keyof T & string];
Cómo pienso en tipos: el cambio mental
Antes pensaba en tipos como anotaciones — escribía el código y después le ponía tipos encima. Error conceptual enorme. Ahora pienso en tipos primero, especialmente en el dominio.
Los tipos son tu modelo de negocio. Si el tipo compila, las invariantes del negocio se cumplen — o deberían. Si podés construir un estado inválido con tus tipos, los tipos están mal.
Un ejemplo concreto: un carrito de compras no puede tener cantidad negativa de items. Si tenés quantity: number, estás mintiendo. Tenés quantity: PositiveInteger o tenés un bug esperando pasar.
type PositiveInteger = Brand<number, 'PositiveInteger'>;
function toPositiveInteger(n: number): PositiveInteger {
if (!Number.isInteger(n) || n <= 0) {
throw new Error(`Expected positive integer, got: ${n}`);
}
return n as PositiveInteger;
}
interface CartItem {
productId: ProductId;
quantity: PositiveInteger;
unitPrice: USD;
}
Ahora es imposible tener un CartItem con cantidad cero o negativa sin pasar por la función que valida. La validación vive en un solo lugar.
El patrón Result que reemplazó mis try/catch
Esto lo tomé prestado de Rust y cambió cómo manejo errores:
type Result<T, E = Error> =
| { ok: true; value: T }
| { ok: false; error: E };
function ok<T>(value: T): Result<T, never> {
return { ok: true, value };
}
function err<E>(error: E): Result<never, E> {
return { ok: false, error };
}
// En vez de throw:
async function fetchUser(id: UserId): Promise<Result<User, 'NOT_FOUND' | 'NETWORK_ERROR'>> {
try {
const user = await db.users.findById(id);
if (!user) return err('NOT_FOUND');
return ok(user);
} catch {
return err('NETWORK_ERROR');
}
}
// En el caller, TypeScript me fuerza a manejar ambos casos:
const result = await fetchUser(userId);
if (!result.ok) {
switch (result.error) {
case 'NOT_FOUND': return redirect('/404');
case 'NETWORK_ERROR': return showRetryButton();
}
}
// Acá TypeScript sabe que result.value existe y es User
console.log(result.value.name);
Los errores posibles están en la firma de la función. No tenés que leer la implementación para saber qué puede fallar. Es la diferencia entre documentación que se desactualiza y tipos que son verdad por construcción.
Lo que no uso
Serío con esto: no uso any salvo para interop con librerías viejas y siempre lo encapsulo. unknown es casi siempre la respuesta correcta cuando no sabés el tipo. No uso as salvo en las funciones constructoras de branded types y cuando sé exactamente lo que estoy haciendo. Si encontrás que usás as seguido para que el código compile, los tipos están mal — no el compilador.
También evito tipos demasiado complejos que nadie puede leer. Un tipo que necesita comentarios para explicarse falló en su trabajo principal. Si llegás a cuatro niveles de condicional genérico, parateé un segundo y pensá si no hay una abstracción más simple.
El viaje vale la pena
Me acuerdo perfectamente de cuando TypeScript me parecía burocracia innecesaria. "¿Para qué poner tipos si JavaScript igual funciona?" — esa es la pregunta de alguien que todavía no tuvo el bug de producción suficientemente doloroso.
Hoy no concibo escribir una aplicación seria sin él. No porque sea una regla, sino porque cuando los tipos están bien, el código te habla. Refactorizás con confianza porque el compilador te dice exactamente qué rompiste. Entrás a un codebase de otra persona y los tipos te cuentan el modelo de negocio sin que tengas que leer comentarios desactualizados.
Empieza con discriminated unions. Ese es mi consejo. Es el patrón con mejor ratio de complejidad/valor y una vez que lo internalizás, empezás a ver oportunidades para usarlo en todos lados.
Comentarios (0)
Deja un comentario
Artículos Relacionados
pnpm vs npm vs yarn vs bun: la comparativa definitiva que nadie te va a dar en 2025
Usé los cuatro en proyectos reales. Uno me rompió un monorepo a las 3am. Otro me salvó la vida en producción. Te cuento todo sin filtros.
Metí un LLM chico adentro de una app Next.js y esto fue lo que aprendí
Reproducí el experimento del LLM tiny que explotó en Show HN: Gemma corriendo en el browser, sin API keys, desde mi stack habitual. Acá está todo lo que salió mal — y lo poco que salió bien.
De DOS a Cloud: mi viaje de 33 años con la tecnología — desde una Amiga en 1994 hasta deployar en Railway con Next.js
Empecé tocando una Amiga 500 a los 3 años sin entender nada. Hoy hago deploy en segundos desde una terminal. En el medio: cyber cafés, servidores Linux a las 3am, y un pivot de carrera que cambió todo. Esta es mi historia.