Hay un momento en la vida de todo dev que trabaja con TypeScript en el que el compilador te marca un error y pensás: "¿cómo no lo vi antes?"
Ese momento es adictivo. Y los patrones que te cuento acá están diseñados para que TypeScript tenga ese momento por vos, antes de que el bug llegue a producción.
No son patrones de diseño GoF. Son patrones del sistema de tipos: herramientas que hacen que categorías enteras de bugs sean imposibles de escribir. Si escribís TypeScript hace un año o diez, alguno de estos te va a sorprender.
El repo con todo el código funcionando está en GitHub — cada archivo compila con el tsconfig más estricto del momento.
01. Discriminated Unions — eliminá los estados imposibles
El primer bug que ataca este patrón es uno que todos escribimos: el objeto con tres flags booleanos.
// ❌ Esto permite 8 combinaciones. La mayoría no tienen sentido.
interface FetchState {
isLoading: boolean
data: User | null
error: Error | null
}
// ¿Qué hacés con esto?
const estado = { isLoading: true, data: someUser, error: someError }
Tres booleans = 2³ = 8 combinaciones posibles. De esas 8, quizás 3 son válidas en tu app. Las otras 5 son estados imposibles que tu código nunca debería ver, pero que TypeScript no puede detectar porque estructuralmente son válidos.
La solución es un campo discriminante que hace que TypeScript sepa exactamente en qué estado estás:
// ✅ Solo 4 combinaciones, todas válidas
type FetchState<T> =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: T }
| { status: "error"; error: Error }
function renderFetch(state: FetchState<User>): string {
switch (state.status) {
case "success":
return `Hola, ${state.data.name}` // TypeScript sabe que data existe acá
case "error":
return `Error: ${state.error.message}` // y que error existe acá
// ...
}
}
El campo status es el discriminante. Cuando entrás al case "success", TypeScript narrowea automáticamente el tipo y sabe que data existe y no es null. Sin un solo !.
En producción lo uso para: estados de fetches, ciclos de vida de formularios, estados de uploads, y especialmente el ciclo de vida de los posts del blog: draft → scheduled → published → archived.
02. Branded Types — nunca más un ID en el lugar equivocado
Este patrón resuelve un problema que parece trivial hasta que lo tenés en producción.
// ❌ Ambos son string — TypeScript no puede distinguirlos
function getPost(userId: string, postId: string) { ... }
const userId = "user_123"
const postId = "post_456"
getPost(postId, userId) // compilá, deployá, rompé
TypeScript es estructural: si dos tipos tienen la misma forma, son intercambiables. UserId y PostId son ambos string, entonces TypeScript los acepta en cualquier orden.
La solución es agregarle una "marca" al tipo que solo existe en el sistema de tipos, no en runtime:
type Brand<T, B extends string> = T & { readonly __brand: B }
type UserId = Brand<string, "UserId">
type PostId = Brand<string, "PostId">
function getPost(userId: UserId, postId: PostId) { ... }
const uid = "user_123" as UserId
const pid = "post_456" as PostId
getPost(uid, pid) // ✅
getPost(pid, uid) // ❌ Error en compilación — exactamente lo que queremos
La propiedad __brand nunca existe en runtime (es una intersección fantasma), pero hace que TypeScript los trate como tipos nominalmente distintos. Zero overhead.
Lo llevo un paso más lejos con smart constructors que validan en el límite del sistema:
function createUserId(raw: string): UserId {
if (!raw.startsWith("user_")) throw new Error(`ID inválido: ${raw}`)
return raw as UserId
}
Una vez que el valor pasa por el constructor, adentro del sistema confiás en el tipo. Es el mismo principio que parse, don't validate.
03. satisfies + as const — validá sin perder los literales
Hay un trade-off incómodo cuando anotás objetos en TypeScript: si ponés el tipo, perdés los literales. Si no ponés el tipo, perdés la validación.
type Role = "admin" | "editor" | "reader"
// ❌ Anotar con Record amplía los valores — pierde true/false como literales
const perms: Record<Role, { canPublish: boolean }> = {
admin: { canPublish: true },
}
// perms.admin.canPublish es boolean, no true
// ❌ Sin anotar, TypeScript no avisa si olvidás un rol
const perms2 = {
admin: { canPublish: true },
// ...olvidaste editor y reader
}
satisfies resuelve exactamente este trade-off: valida la forma sin ampliar los tipos:
const perms = {
admin: { canPublish: true, canEdit: true },
editor: { canPublish: false, canEdit: true },
reader: { canPublish: false, canEdit: false },
} satisfies Record<Role, { canPublish: boolean; canEdit: boolean }>
// perms.admin.canPublish es true (literal preservado)
// TypeScript avisa si olvidás un rol o ponés un campo extra
El combo definitivo es con as const:
const ROUTES = {
home: "/",
blog: "/blog",
admin: "/admin",
} as const satisfies Record<string, `/${string}`>
type AppRoute = typeof ROUTES[keyof typeof ROUTES]
// AppRoute = "/" | "/blog" | "/admin" — los literales, no string
as const congela los valores. satisfies los valida. El orden importa: primero as const, después satisfies, o al revés dependiendo de lo que necesités preservar.
04. infer en Conditional Types — extraé tipos sin recurrir al any
Cuando trabajás con genéricos complejos, terminás escribiendo as any para "extraer" el tipo de adentro de un wrapper. infer es la solución real.
La idea es hacer pattern matching sobre la estructura de un tipo y capturar una parte de él:
// "Si T es una Promise de algo, capturá ese algo en R"
type Awaited_<T> = T extends Promise<infer R> ? R : T
type A = Awaited_<Promise<string>> // string
type B = Awaited_<Promise<number[]>> // number[]
En proyectos reales lo uso para extraer tipos de Server Actions sin repetirme:
type AsyncReturn<T extends (...args: never[]) => Promise<unknown>> =
T extends (...args: never[]) => Promise<infer R> ? R : never
async function getPosts(page: number): Promise<PaginatedResult<Post>> {
return prisma.post.findMany(...)
}
// Si cambia getPosts, cambia esto solo — sin mantener tipos a mano
type GetPostsResult = AsyncReturn<typeof getPosts>
// GetPostsResult = PaginatedResult<Post>
Y con template literal types, infer se vuelve una herramienta de extracción de substrings:
type RouteParam<T extends string> =
T extends `${string}:${infer Param}` ? Param : never
type BlogParam = RouteParam<"/blog/:slug"> // "slug"
type UserParam = RouteParam<"/users/:id"> // "id"
Esto es type-level programming. Usalo con criterio — si el tipo resultante es más difícil de entender que el problema que resuelve, no lo uses.
05. Exhaustive Check + noUncheckedIndexedAccess — los dos flags que más bugs eliminan
Exhaustive check: cuando agregás un nuevo valor a un union y olvidás actualizar el switch.
type NotificationType = "comment" | "like" | "follow" | "mention"
// ❌ Sin exhaustive check, TypeScript no avisa del caso nuevo
function handle(type: NotificationType): string {
if (type === "comment") return "Nuevo comentario"
if (type === "like") return "Le gustó tu post"
if (type === "follow") return "Nuevo seguidor"
return "Notificación" // "mention" cae acá silenciosamente
}
La solución es una función assertNever que convierte el caso no manejado en un error de tipos:
function assertNever(value: never, message?: string): never {
throw new Error(message ?? `Caso no manejado: ${JSON.stringify(value)}`)
}
function handle(type: NotificationType): string {
switch (type) {
case "comment": return "Nuevo comentario"
case "like": return "Le gustó tu post"
case "follow": return "Nuevo seguidor"
case "mention": return "Te mencionaron"
default:
return assertNever(type) // si olvidás un case, esto falla en compilación
}
}
noUncheckedIndexedAccess: activalo en tsconfig.json y cada acceso a array o index signature pasa a ser T | undefined:
// tsconfig.json: "noUncheckedIndexedAccess": true
const posts = ["post-1", "post-2"]
const first = posts[99] // string | undefined, no string
if (first !== undefined) {
console.log(first.toUpperCase()) // seguro
}
Parece molesto hasta que te das cuenta de que cada posts[i].title que escribías sin chequear era un crash esperando su momento.
06. Ejemplo combinado — PostStateMachine
Los primeros 5 patrones juntos modelando el ciclo de vida de un post. El código completo está en src/06-combined-post-machine.ts del repo:
// 1. Branded types para los IDs
type PostId = Brand<string, "PostId">
type AuthorId = Brand<string, "AuthorId">
// 2. Discriminated union para los estados
type Post =
| (BasePost & { status: "draft" })
| (BasePost & { status: "scheduled"; publishAt: Date })
| (BasePost & { status: "published"; publishedAt: Date; slug: string; views: number })
| (BasePost & { status: "archived"; archivedAt: Date; reason: string })
// 3. satisfies para las transiciones
const transitions = {
publish: (slug: string): Transition => (post) => ({ ... }),
archive: (reason: string): Transition => (post) => ({ ... }),
} satisfies Record<string, (...args: never[]) => Transition>
// 4. infer para extraer los nombres de transiciones
type TransitionName = keyof typeof transitions // "publish" | "archive"
// 5. Exhaustive check en el renderer
function renderPost(post: Post): string {
switch (post.status) {
case "draft": return "✏️ Borrador"
case "scheduled": return "⏰ Programado"
case "published": return `✅ /${post.slug}`
case "archived": return `📦 ${post.reason}`
default: return assertNever(post)
}
}
El resultado: un objeto que es imposible poner en un estado inválido, con IDs que no se pueden intercambiar, con transiciones validadas, y un renderer que falla en compilación si olvidás un estado.
El tsconfig que activa todo esto
{
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"noPropertyAccessFromIndexSignature": true,
"verbatimModuleSyntax": true
}
}
strict: true ya lo usás. Las otras cuatro opciones son las que hacen la diferencia. Activarlas en un proyecto existente va a marcar errores — eso es bueno. Cada error es un bug que no llegó a producción.
07. Result<T, E> — error handling sin excepciones implícitas
Este patrón viene de Rust y es el que más cambia la forma en que escribís código async.
El problema con las excepciones: las funciones que pueden fallar no lo dicen en su firma.
// ❌ ¿Qué pasa si esto falla? No hay forma de saberlo sin leer la implementación.
async function getUser(id: string): Promise<User> {
const res = await fetch(`/api/users/${id}`)
if (!res.ok) throw new Error(`HTTP ${res.status}`)
return res.json()
}
// El llamador asume que siempre funciona
const user = await getUser("u1") // puede explotar, TypeScript no avisa
Con Result<T, E>, el error es parte del contrato:
type Ok<T> = { readonly ok: true; readonly value: T }
type Err<E> = { readonly ok: false; readonly error: E }
type Result<T, E = Error> = Ok<T> | Err<E>
const ok = <T>(value: T): Ok<T> => ({ ok: true, value })
const err = <E>(error: E): Err<E> => ({ ok: false, error })
type UserError =
| { code: "NOT_FOUND"; message: string }
| { code: "NETWORK"; message: string }
async function getUser(id: string): Promise<Result<User, UserError>> {
const res = await fetch(`/api/users/${id}`).catch(e =>
err({ code: "NETWORK" as const, message: String(e) })
)
if (res instanceof Response && res.status === 404)
return err({ code: "NOT_FOUND", message: `User ${id} no existe` })
// ...
}
// Ahora TypeScript te obliga a manejar ambos casos:
const result = await getUser("u1")
if (!result.ok) {
switch (result.error.code) {
case "NOT_FOUND": console.log("Usuario no encontrado"); break
case "NETWORK": console.log("Error de red"); break
}
return
}
console.log(result.value.name) // TypeScript sabe que es User
El cambio mental es grande: en lugar de try/catch esparcidos por el código, el error viaja como un valor. Podés pasarlo, transformarlo, combinarlo. Es mucho más predecible.
// tryCatch envuelve cualquier función que pueda lanzar
function tryCatch<T>(fn: () => T): Result<T, Error> {
try { return ok(fn()) }
catch (e) { return err(e instanceof Error ? e : new Error(String(e))) }
}
// Pipeline de validación encadenado
const result = tryCatch(() => JSON.parse(rawInput))
// { ok: true, value: {...} } o { ok: false, error: SyntaxError }
08. Type Predicates — enseñale a TypeScript a narrowear tus tipos
TypeScript puede narrowear automáticamente con typeof e instanceof. Pero para objetos complejos o datos que vienen de fuera del sistema, necesitás enseñárselo vos.
// value is Post — el "type predicate" le dice a TypeScript qué es el valor
function isPost(value: unknown): value is Post {
return (
typeof value === "object" &&
value !== null &&
"slug" in value &&
"title" in value &&
typeof (value as Post).title === "string"
)
}
function processContent(raw: unknown): string {
if (isPost(raw)) return `Post: ${raw.title}` // TypeScript sabe que raw es Post acá
return "Desconocido"
}
El caso de uso que más me cambió el día a día es isDefined con array.filter:
function isDefined<T>(value: T | null | undefined): value is T {
return value !== null && value !== undefined
}
const rawPosts: (Post | null | undefined)[] = [post1, null, post2, undefined]
// ❌ ANTES: filter(Boolean) devuelve (Post | null | undefined)[] — no removió los null del tipo
const bad = rawPosts.filter(Boolean)
// ✅ DESPUÉS: filter con type predicate limpia el tipo también
const clean = rawPosts.filter(isDefined)
// clean es Post[] — TypeScript lo sabe sin castings
clean.forEach(post => console.log(post.title))
Y las assertion functions para cuando preferís lanzar en vez de retornar false:
function assertIsPost(value: unknown): asserts value is Post {
if (!isPost(value)) throw new Error(`Dato inválido: ${JSON.stringify(value)}`)
}
async function publishPost(rawData: unknown) {
assertIsPost(rawData)
// A partir de acá, TypeScript sabe que rawData es Post — sin if, sin castings
console.log(`Publicando: ${rawData.title}`)
}
09. Mapped Types — transformá la forma de un tipo sin repetirte
Cuando tenés que crear variantes de un tipo (readonly, nullable, con campos opcionales, con prefijo en los keys), la tentación es copiar y pegar la interface. Los mapped types te dan una forma de describir la transformación una sola vez.
// { [K in keyof T]: ... } — "para cada key de T, hacé algo"
type Nullable<T> = { [K in keyof T]: T[K] | null }
type DeepPartial<T> = T extends object
? { [K in keyof T]?: DeepPartial<T[K]> }
: T
// Key remapping con `as` — renombrá las keys durante el mapeo
type AsyncGetters<T> = {
[K in keyof T as `get${Capitalize<string & K>}`]: () => Promise<T[K]>
}
type UserGetters = AsyncGetters<{ id: string; name: string }>
// { getId: () => Promise<string>; getName: () => Promise<string> }
El combo más útil en la práctica: satisfies + mapped type para diccionarios tipados donde no querés perder los literales:
type PostStatusConfig = {
label: string
color: string
icon: string
}
const POST_STATUS = {
draft: { label: "Borrador", color: "#9ca3af", icon: "✏️" },
scheduled: { label: "Programado", color: "#fbbf24", icon: "⏰" },
published: { label: "Publicado", color: "#00ff88", icon: "✅" },
archived: { label: "Archivado", color: "#8b5cf6", icon: "📦" },
} satisfies Record<"draft" | "scheduled" | "published" | "archived", PostStatusConfig>
// satisfies verifica que estén todos los estados y todos los campos
// Los valores mantienen sus literales — color es "#9ca3af", no string
type PostStatusKey = keyof typeof POST_STATUS
// "draft" | "scheduled" | "published" | "archived"
Y para formularios, generar el tipo del form a partir del modelo:
type FormFields<T> = {
[K in keyof T]: {
value: string
error: string | null
touched: boolean
}
}
// El tipo del formulario se deriva del modelo — si cambia User, cambia UserForm
type UserForm = FormFields<Pick<User, "name" | "email">>
El tsconfig que activa todo esto
{
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"noPropertyAccessFromIndexSignature": true,
"verbatimModuleSyntax": true
}
}
strict: true ya lo usás. Las otras cuatro opciones son las que hacen la diferencia. Activarlas en un proyecto existente va a marcar errores — eso es bueno. Cada error es un bug que no llegó a producción.
El repo con todos los ejemplos compilando y un runner interactivo está en github.com/JuanTorchia/typescript-patterns. Cloná, corrí npm run run para verlos todos en acción, o abrí cada archivo en tu editor y rompé los ejemplos para ver cómo responde el compilador.
Si estás empezando con estos patrones, el orden que recomiendo: 01 → 05 → 07 → 08. Son los que más impacto inmediato tienen en código real. Los demás los incorporás naturalmente cuando los necesitás.
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.
Research-Driven Agents: cuando un agente lee antes de codear
Meses viendo agentes tirar código sin contexto y romper todo. Armé un experimento real: forzar al agente a producir un artefacto de investigación antes de tocar un archivo. Lo que medí cambió cómo trabajo con IA para siempre.
La criptografía que usás para firmar digitalmente tiene fecha de vencimiento: qué publicó NIST y cómo migrar tu HSM
NIST finalizó los estándares post-quantum en agosto de 2024. RSA y ECDSA tienen deadline de 2035. Si firmás documentos, JWTs o certificados con un HSM, esto te afecta ahora — te explico ML-DSA, cómo impacta al hardware, y qué hacer esta semana.