TypeScript a escala: por qué los tipos son código, no comentarios
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: Draft → Signed → Submitted → Acknowledged. Algunas operaciones solo aplican en ciertos estados:
sign()solo enDraft.submit()solo enSigned.getAcknowledgement()solo enSubmittedo 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 cumplaConfig.
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
Recomendado para ti