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.
