Anterior
TypeScript Patterns Arquitectura Engineering

TypeScript a escala: por qué los tipos son código, no comentarios

Samuel Peña
Samuel Peña
28 de abril de 2026 8 min de lectura

El TypeScript que conocemos vs el que hace trabajo

La mayoría de equipos que adoptan TypeScript empiezan tratándolo como un linter pasivo. Anotaciones aquí, any allá, contentos con que el editor les muestre el tipo del campo email. Eso está bien — es mejor que JavaScript crudo. Pero no es para lo que TypeScript fue construido.

El TypeScript que hace trabajo de verdad codifica las reglas que tu negocio no puede romper en el sistema de tipos. Si el compilador no las puede expresar, la regla la rompe alguien a las 3 AM.

Caso 1: estados imposibles deben ser imposibles

Mira este patrón común:

interface User {
  id: string
  name: string
  email?: string
  emailVerifiedAt?: Date
}

¿Qué pasa si email es undefined pero emailVerifiedAt no? Inválido. Pero el tipo lo permite. El bug vive en cualquier rama que asuma una cosa o la otra.

Versión que codifica la realidad:

type User = {
  id: string
  name: string
} & (
  | { email: string; emailVerifiedAt: Date }
  | { email: string; emailVerifiedAt: null }
  | { email: null; emailVerifiedAt: null }
)

Ahora el compilador rechaza el estado imposible. Tu rama que dependía de emailVerifiedAt != null ya no puede ver email == null.

Conexión real: en una integración con DGII tuvimos un bug donde un e-CF “firmado” pero “sin certificado” llegaba al endpoint. Lo resolvimos con un tipo así. Bug eliminado de raíz, no parchado.

Caso 2: branded types para identidades

¿Cuántas veces has visto un bug donde alguien pasó un customerId donde se esperaba un orderId? Ambos son string. El compilador no protesta. Bienvenido al infierno.

// Antes
function getOrder(orderId: string) { ... }
function shipOrder(customerId: string, orderId: string) { ... }

// Compila — y rompe en producción
shipOrder(orderId, customerId)  // Argumentos cambiados de orden

Con branded types:

type Brand<T, B> = T & { __brand: B }

type CustomerId = Brand<string, 'CustomerId'>
type OrderId = Brand<string, 'OrderId'>

function getOrder(orderId: OrderId) { ... }
function shipOrder(customerId: CustomerId, orderId: OrderId) { ... }

const customerId = '...' as CustomerId
const orderId = '...' as OrderId

shipOrder(orderId, customerId)
// ❌ Type 'OrderId' is not assignable to parameter of type 'CustomerId'

Lo único de costo: convertir string a CustomerId en el límite (el constructor que recibe el string crudo de la DB). Una sola vez, no en cada función.

Caso 3: builders con phantom states

En nuestro SDK de e-CF, un comprobante pasa por estados: DraftSignedSubmittedAcknowledged. Algunas operaciones solo aplican en ciertos estados:

  • sign() solo en Draft.
  • submit() solo en Signed.
  • getAcknowledgement() solo en Submitted o más adelante.

En lugar de runtime checks repartidos:

class Comprobante<State extends 'draft' | 'signed' | 'submitted' | 'acknowledged'> {
  constructor(private state: State, private data: ComprobanteData) {}

  sign(this: Comprobante<'draft'>, cert: Certificate): Comprobante<'signed'> {
    return new Comprobante('signed', { ...this.data, signature: cert.sign(this.data) })
  }

  submit(this: Comprobante<'signed'>): Promise<Comprobante<'submitted'>> {
    // solo compilable si this es Comprobante<'signed'>
  }

  ack(this: Comprobante<'submitted' | 'acknowledged'>): Acknowledgement | null {
    // ...
  }
}

const c = new Comprobante('draft', data)
c.submit() // ❌ no compila, falta sign()
c.sign(cert).submit() // ✓

La firma del método (this: Comprobante<'draft'>) le dice a TypeScript: este método solo es invocable cuando el receptor tiene este tipo. Errores que antes eran runtime exceptions ahora son errores de compilación.

Caso 4: satisfies para configuración con tipo seguro

Antes de TypeScript 4.9, había que elegir entre:

  • Anotar el tipo (const config: Config = {...}) y perder los tipos literales.
  • Inferir todo (const config = {...}) y no garantizar que cumpla Config.

Hoy:

const routes = {
  home: { path: '/', requiresAuth: false },
  dashboard: { path: '/dashboard', requiresAuth: true },
  admin: { path: '/admin', requiresAuth: true, role: 'admin' }
} satisfies Record<string, RouteConfig>

routes.home.requiresAuth // tipo: false (no boolean)
routes.admin.role // tipo: 'admin' (no string)

satisfies valida que el objeto cumpla el tipo sin perder la información literal. Ideal para configuración estática.

Caso 5: discriminated unions para errores

Otra trampa frecuente: tratar errores como Error | null. Pierdes contexto, pierdes recuperabilidad por tipo.

// Antes
async function chargeCard(): Promise<{ ok: boolean, error?: Error }> { ... }

// Después
type ChargeResult =
  | { ok: true; transactionId: string; receipt: Receipt }
  | { ok: false; error: 'card_declined'; reason: string }
  | { ok: false; error: 'rate_limited'; retryAfterMs: number }
  | { ok: false; error: 'network_error'; cause: unknown }

const result = await chargeCard()
if (!result.ok) {
  switch (result.error) {
    case 'card_declined':
      return showDeclinedMessage(result.reason)
    case 'rate_limited':
      return scheduleRetry(result.retryAfterMs)
    case 'network_error':
      return logToObservability(result.cause)
  }
  // El compilador garantiza que tratamos todos los casos.
  // Si añades un nuevo error, esto deja de compilar hasta que lo manejes.
}

Lo que pagamos

TypeScript a este nivel no es gratis. Lo que cuesta:

  • Curva de aprendizaje del equipo. Los tipos avanzados (conditional types, mapped types, template literals) toman tiempo en entrar al músculo.
  • Compile time. Una base de código con mucho tipo derivado puede pasar de 5s a 30s en tsc. Vale la pena, pero hay que ser consciente.
  • Errores difíciles de leer. “Type X is not assignable to type Y” donde X tiene 12 niveles de profundidad. Aprender a desempacar esos errores es habilidad propia.

A cambio, lo que ganas es brutal: bugs que no escriben porque el compilador no los deja. Refactorings que tocan 80 archivos y compilan al primer intento. Onboarding nuevo donde “lo que pasa en este flow” se lee directamente en los tipos.

Cierre

TypeScript no es JavaScript con tipos. Es un lenguaje de programación con su propio sistema de tipos sofisticado. Tratado como tal — codificando las reglas de tu dominio en el sistema de tipos — se convierte en uno de los mejores ROI que te puedes regalar como equipo.

La regla simple: si una propiedad del negocio se puede expresar en el tipo, exprésala. El compilador no se cansa, no se duerme y no olvida.

Samuel