Inicio / Artículos

Diez patrones de TypeScript que convierten errores en tiempo de ejecución en errores de compilación

Aprenda diez técnicas de TypeScript, desde uniones discriminadas y satisfies hasta tipos marcados e inferir, que permiten al compilador rechazar estados inválidos antes de que su código sea distribuido.

Editor: REACTAPP.TOP12 min de lectura2744 palabras

Fuentes: Top 10 TypeScript Techniques Senior Developers Secretly Use

Muchos equipos adoptan TypeScript al agregar anotaciones al JavaScript existente y se detienen ahí. Eso detecta errores de escritura, pero deja la mayor parte del sistema de tipos sin utilizar. La ventaja real es codificar las reglas como tipos, de modo que todo tipo de errores impide la compilación y no se necesita ninguna verificación en tiempo de ejecución. A continuación se presentan diez técnicas que logran esto, con indicaciones sobre en qué casos resulta útil cada una.

Modelar estados de forma explícita con uniones discriminadas

Un hábito común es describir un valor como un conjunto de campos opcionales y confiar en que quienes lo utilizan verifiquen la combinación correcta. Nada de lo anterior impide que tanto data como error se establezcan al mismo tiempo.

// nothing stops you from having both data and error at once
type ApiResponse = {
  data?: User
  error?: string
  loading?: boolean
}

// consumer has no idea what to check
function handleResponse(res: ApiResponse) {
  if (res.data) { /* maybe fine */ }
  // what if data AND error are both set?
}

Una unión discriminada enumera en su lugar las formas válidas. Cada variante comparte un campo literal (status), y TypeScript lo utiliza para restringir el tipo: en la rama success está garantizado res.data, mientras que en la rama error lo está res.message.

type ApiResponse =
  | { status: 'loading' }
  | { status: 'success'; data: User }
  | { status: 'error'; message: string }

function handleResponse(res: ApiResponse) {
  switch (res.status) {
    case 'loading':
      return showSpinner()
    case 'success':
      // TypeScript KNOWS res.data exists here
      return renderUser(res.data)
    case 'error':
      // TypeScript KNOWS res.message exists here
      return showError(res.message)
  }
}

Cuando más adelante se agrega un cuarto estado, cualquier switch que lo ignore puede convertirse en un error de compilación, especialmente con la verificación de exhaustividad que se muestra más abajo.

Validar objetos con satisfies sin ampliar su tipo

Disponible desde TypeScript 4.9, satisfies soluciona un problema sutil relacionado con las anotaciones. const config: Config = ... verifica el objeto, pero el tipo de la variable se convierte exactamente en Config, por lo que config1.theme representa la unión completa en lugar de 'dark'. Con satisfies, el objeto sigue siendo verificado (los errores tipográficos en las claves se consideran errores), pero el tipo inferido con precisión se mantiene: config2.theme sigue siendo 'dark' y secondary permanece como una clave reconocida.

type Config = {
  theme: 'light' | 'dark' | 'system'
  colors: Record<string, string>
}
// with a normal annotation you lose the literal type
const config1: Config = {
  theme: 'dark',
  colors: { primary: '#3d6fe8' }
}
// config1.theme is now 'light' | 'dark' | 'system', not 'dark'

// with satisfies, the shape is validated but types stay narrow
const config2 = {
  theme: 'dark',
  colors: { primary: '#3d6fe8', secondary: '#7c3aed' }
} satisfies Config// config2.theme is still 'dark' — the literal type is preserved
// config2.colors.primary is still 'string' — shape is validated
// if you typo a field name, TypeScript still catches it

Añada anotaciones cuando desee el tipo más amplio, por ejemplo para un valor que volverá a asignarse; utilice satisfies cuando necesite una verificación además de una inferencia precisa.

Generar uniones de cadenas con tipos de literales de plantilla

TypeScript puede crear tipos de cadena con sintaxis de backticks. Una unión en un marcador de posición se expande en todas las combinaciones posibles, lo cual es útil para nombres de manejadores de eventos, propiedades personalizadas de CSS y endpoints METHOD /path.

type EventName<T extends string> = `on${Capitalize<T>}`
// produces: 'onClick' | 'onChange' | 'onSubmit'

type DomEvents = EventName<'click' | 'change' | 'submit'>
// type safe CSS properties

type CSSVar<T extends string> = `--${T}`
type AppVars = CSSVar<'primary' | 'secondary' | 'bg'>
// produces: '--primary' | '--secondary' | '--bg'
// type safe API routes

type HttpMethod = 'GET' | 'POST' | 'DELETE'
type Route = '/users' | '/orders' | '/products'
type ApiEndpoint = `${HttpMethod} ${Route}`
// produces: 'GET /users' | 'GET /orders' | 'POST /users' | ...

function callApi(endpoint: ApiEndpoint) { /* ... */ }
callApi('GET /users')     // valid
callApi('PATCH /users')   // TypeScript error: not in the type

callApi('PATCH /users') es rechazado porque PATCH no está en HttpMethod. Las combinaciones se multiplican rápidamente, por lo que es necesario mantener pequeñas las uniones de entrada para evitar ralentizar el compilador.

Separar valores similares con tipos personalizados

TypeScript compara los tipos por su estructura, así que type UserId = string es en realidad simplemente string; por lo tanto, un ID de usuario puede pasar sin problemas a una función que espera un ID de pedido. Los tipos personalizados combinan la primitiva con una propiedad ficticia que nunca existe en tiempo de ejecución, lo que hace que ambas sean incompatibles. Las funciones constructoras se convierten en la única forma de crear tales valores y en un lugar adecuado para realizar validaciones.

// without branding — TypeScript cannot tell these apart
type UserId = string
type OrderId = string

function deleteOrder(id: OrderId) { /* ... */ }
const userId: UserId = 'user-42'
deleteOrder(userId) // no error - catastrophic bug

// with branded types - structurally different, enforced
type Brand<T, B> = T & { readonly _brand: B }
type UserId  = Brand<string, 'UserId'>
type OrderId = Brand<string, 'OrderId'>
// constructor functions act as the only way to create these types
const UserId  = (id: string) => id as UserId
const OrderId = (id: string) => id as OrderId
const userId  = UserId('user-42')
const orderId = OrderId('order-99')
deleteOrder(userId)  // TypeScript error: UserId is not assignable to OrderId
deleteOrder(orderId) // works fine

Los buenos candidatos son identificadores (UserId, OrderId, ProductId), valores verificados como EmailAddress, HashedPassword o ValidatedInput, y unidades que se confunden fácilmente como milisegundos frente a segundos o dólares frente a centavos. El fragmento declara cada alias dos veces para fines de comparación; en el código real mantenga solo la versión oficial.

Sacar tipos de otros tipos con infer

Dentro de un tipo condicional, infer declara una posición que TypeScript llena mediante coincidencia de patrones. Así se construyen ReturnType, Parameters y Awaited, y también permite crear sus propios extractores.

// extract the return type of any function
type ReturnType<T> = T extends (...args: any[]) => infer R ? R : never

function getUser() { return { id: 1, name: 'Alice' } }
type User = ReturnType<typeof getUser>
// User = { id: number, name: string }
// extract the resolved value of a Promise
type UnwrapPromise<T> = T extends Promise<infer V> ? V : T
type Data = UnwrapPromise<Promise<User>>
// Data = User
// extract the element type of an array
type ArrayElement<T> = T extends (infer E)[] ? E : never
type Names = ArrayElement<string[]>
// Names = string
// extract first argument of any function
type FirstArg<T> = T extends (first: infer A, ...rest: any[]) => any ? A : never
type Arg = FirstArg<typeof getUser>
// Arg = never (no args), try a fn with args:
type A2 = FirstArg<(id: string) => void>
// A2 = string

Lea cada definición como “si T coincide con esta forma, ¿qué se coloca en el slot infer?”. Si el patrón falla, gana la rama falsa, y por eso FirstArg de una función sin argumentos es never. Asigne nombres distintos a sus propios auxiliares en lugar de sobrescribir el ReturnType global.

Hacer que los switches incompletos fallen con never

Combine uniones con un auxiliar que solo acepte never. Una vez que cada variante tenga un case, el tipo restante en default será never y la llamada se compilará. Si agrega hexagon sin tratarlo, el tipo restante hará que la compilación falle inmediatamente.

type Shape =
  | { kind: 'circle';    radius: number }
  | { kind: 'rectangle'; width: number; height: number }
  | { kind: 'triangle';  base: number;  height: number }

// this helper makes the default case scream at compile time
function assertNever(value: never): never {
  throw new Error(`Unhandled case: ${JSON.stringify(value)}`)
}
function getArea(shape: Shape): number {
  switch (shape.kind) {
    case 'circle':
      return Math.PI * shape.radius ** 2
    case 'rectangle':
      return shape.width * shape.height
    case 'triangle':
      return 0.5 * shape.base * shape.height
    default:
      return assertNever(shape)
      // if you add 'hexagon' to Shape and forget to handle it,
      // TypeScript throws an error RIGHT HERE at compile time
  }
}

Dado que el auxiliar también lanza excepciones, protege contra valores inesperados provenientes del exterior del sistema de tipos, como las respuestas de API.

Reformatear tipos con tipos mapeados y reasignación de claves

Los tipos mapeados iteran sobre las claves de un tipo para generar uno nuevo, y una cláusula as renombra las claves o las elimina al mapearlas a never. Los ejemplos crean firmas recursivas parciales de getters y un filtro que mantiene solo los métodos.

// make all properties optional but keep their types
type DeepPartial<T> = {
  [K in keyof T]?: T[K] extends object ? DeepPartial<T[K]> : T[K]
}

// prefix all keys with 'get'
type Getters<T> = {
  [K in keyof T as `get${Capitalize<string & K>}`]: () => T[K]
}
type User = { id: string; name: string; email: string }
type UserGetters = Getters<User>
// { getId: () => string, getName: () => string, getEmail: () => string }
// pick only function properties from a type
type FunctionKeys<T> = {
  [K in keyof T as T[K] extends Function ? K : never]: T[K]
}
type Service = { id: string; save(): void; delete(): void; name: string }
type ServiceMethods = FunctionKeys<Service>
// { save: () => void, delete: () => void }

Se necesita string & K porque las claves también pueden ser números o símbolos, los cuales Capitalize rechaza. Este sencillo DeepPartial también recurre en funciones y arrays, lo cual es adecuado para datos simples pero podría requerir ajustes.

Enseñe al compilador sus propias verificaciones con protectores de tipo

TypeScript restringe el rango de tipos mediante typeof, instanceof y comparaciones literales, pero no dentro de las comprobaciones personalizadas. Un predicado de tipo (animal es Cat) indica qué demuestra una función booleana; una función de afirmación (asserts val es NonNullable<T>) lanza un error en caso de fallo y restringe todo lo que sigue a la llamada.

type Cat = { kind: 'cat'; meows: boolean }
type Dog = { kind: 'dog'; barks: boolean }
type Animal = Cat | Dog

// type predicate - return type tells TypeScript what the guard proves
function isCat(animal: Animal): animal is Cat {
  return animal.kind === 'cat'
}
function speak(animal: Animal) {
  if (isCat(animal)) {
    console.log(animal.meows) // TypeScript knows animal is Cat here
  } else {
    console.log(animal.barks) // TypeScript knows animal is Dog here
  }
}
// assertion function - throws if condition not met, narrows after the call
function assertDefined<T>(val: T): asserts val is NonNullable<T> {
  if (val === null || val === undefined) {
    throw new Error('Value is null or undefined')
  }
}
const user = getUser() // returns User | null
assertDefined(user)    // throws at runtime if null
console.log(user.name) // TypeScript knows user is User here, not User | null

El compilador confía en estas firmas sin verificarlas, por lo que una condición incorrecta puede engañar a todos los llamantes. Manténgalas simples y probadas.

Mantenga los valores literales precisos con as const

Por defecto, TypeScript amplía los literales: 'production' se convierte en string. as const fija una expresión a sus tipos más específicos y la hace readonly. La verdadera ventaja radica en poder derivar uniones a partir de datos mediante typeof y el acceso indexado, de modo que la matriz u objeto se convierte en la única fuente de verdad.

// without as const — types are widened
const config = {
  env: 'production',     // inferred as string, not 'production'
  port: 3000,             // inferred as number, not 3000
  features: ['auth', 'search'] // inferred as string[]
}

// with as const - every value is exactly as written
const config = {
  env: 'production',
  port: 3000,
  features: ['auth', 'search']
} as const
// config.env is 'production', not string
// config.port is 3000, not number
// config.features is readonly ['auth', 'search']
// the real power: derive union types from the values
const ROLES = ['admin', 'editor', 'viewer'] as const
type Role = typeof ROLES[number]
// Role = 'admin' | 'editor' | 'viewer'
// add a new role to the array and the type updates automatically
const ROUTES = {
  home:    '/',
  users:   '/users',
  profile: '/profile'
} as const
type AppRoute = typeof ROUTES[keyof typeof ROUTES]
// AppRoute = '/' | '/users' | '/profile'

El fragmento declara config dos veces para contrastar. Con este patrón, al agregar un rol o ruta se actualizan todos los tipos dependientes sin duplicaciones.

Variar las formas de retorno e entrada con tipos condicionales

Los tipos condicionales permiten que una función genérica devuelva estructuras diferentes según sus argumentos, y así es como Prisma define los tipos de sus resultados include y select. Aquí, includePosts: true genera un tipo con posts, mientras que en el segundo ejemplo publishedAt solo es obligatorio en el modo publish.

// function returns different shapes based on a flag
type UserResponse<T extends boolean> =
  T extends true
    ? { user: User; posts: Post[] }   // with posts
    : { user: User }                   // without posts

function fetchUser<T extends boolean>(
  id: string,
  includePosts: T
): Promise<UserResponse<T>> {
  /* implementation */
}
// TypeScript knows exactly what comes back
const result1 = await fetchUser('123', true)
result1.posts   // valid - TypeScript knows posts exists
const result2 = await fetchUser('123', false)
result2.posts   // TypeScript error - posts does not exist on this return type
// another real world example: optional fields based on mode
type CreateMode = 'draft' | 'publish'
type ArticleInput<M extends CreateMode> =
  M extends 'publish'
    ? { title: string; content: string; publishedAt: Date }
    : { title: string; content?: string }
function createArticle<M extends CreateMode>(
  mode: M,
  input: ArticleInput<M>
) { /* ... */ }
// publishedAt is REQUIRED when mode is 'publish'
createArticle('publish', { title: 'Hey', content: '...', publishedAt: new Date() })
// publishedAt is NOT required when mode is 'draft'
createArticle('draft',   { title: 'Hey' })

Dentro de la implementación, TypeScript generalmente no puede demostrar que el valor devuelto coincida con un tipo condicional sin resolver, por lo que se requiere un cast. Para dos o tres variantes, las sobrecargas suelen ser más claras.

Cómo encajan las partes

Estos no son trucos aislados. Las uniones definen los estados, never hace que su manejo sea exhaustivo, as const y los literales de plantilla generan las uniones, mientras que los tipos mapeados y condicionales derivan el resto. Los tipos se convierten en una capa de lógica en lugar de documentación. La lista abreviada a continuación funciona como una lista de verificación; asume que existen tipos como Config, User y Animal.

// 01. Discriminated unions — model every valid state explicitly
type State = | { status: 'idle' } | { status: 'ok'; data: T } | { status: 'err'; msg: string }
// 02. satisfies - validate shape without widening literal types
const x = { mode: 'dark' } satisfies Config
// 03. Template literal types - build string unions programmatically
type Event<T extends string> = `on${Capitalize<T>}`
// 04. Branded types - prevent mixing structurally identical primitives
type UserId = string & { readonly _brand: 'UserId' }
// 05. infer - extract types from inside other types
type Unwrap<T> = T extends Promise<infer V> ? V : T
// 06. assertNever - exhaustiveness check at compile time
function assertNever(x: never): never { throw new Error('Unhandled case') }
// 07. Mapped types - transform type shapes programmatically
type Getters<T> = { [K in keyof T as `get${Capitalize<string & K>}`]: () => T[K] }
// 08. Type guards - teach TypeScript how to narrow your types
function isCat(a: Animal): a is Cat { return a.kind === 'cat' }
// 09. as const - lock values to literals, derive union types from data
const ROLES = ['admin', 'editor'] as const
type Role = typeof ROLES[number]
// 10. Conditional types - return different shapes based on input
type Res<T extends boolean> = T extends true ? { data: User; posts: Post[] } : { data: User }

Puntos clave

  • Preferir estructuras precisas en lugar de campos opcionales; las uniones junto con assertNever convierten los casos faltantes en errores de compilación.
  • Usar satisfies y as const para validar valores manteniendo los tipos literales.
  • Dar nombres significativos a las primitivas para que los IDs y las unidades no puedan intercambiarse.
  • Calcular tipos con infer, tipos mapeados y condicionales, pero mantenerlos legibles.
  • Trate las guardas y conversiones personalizadas como límites de confianza.
  • Para codificar reglas de negocio de manera más amplia, consulte modelado de dominios en TypeScript.

    Lecturas relacionadas