DEV Community

Cover image for 9 patrones de TypeScript que eliminan bugs antes de ejecutar el código
Juan Torchia
Juan Torchia

Posted on • Edited on • Originally published at juanchi.dev

9 patrones de TypeScript que eliminan bugs antes de ejecutar el código

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 }
Enter fullscreen mode Exit fullscreen mode

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á
    // ...
  }
}
Enter fullscreen mode Exit fullscreen mode

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é
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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[]
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
  }
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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)
  }
}
Enter fullscreen mode Exit fullscreen mode

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
  }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 }
Enter fullscreen mode Exit fullscreen mode

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"
}
Enter fullscreen mode Exit fullscreen mode

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))
Enter fullscreen mode Exit fullscreen mode

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}`)
}
Enter fullscreen mode Exit fullscreen mode

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> }
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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">>
Enter fullscreen mode Exit fullscreen mode

El tsconfig que activa todo esto

{
  "compilerOptions": {
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "exactOptionalPropertyTypes": true,
    "noPropertyAccessFromIndexSignature": true,
    "verbatimModuleSyntax": true
  }
}
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)