Programación defensiva en TypeScript: casi todos la hacen mal
El bug tardó dos días en encontrarse y quince segundos en arreglarse.
El panel de facturación de un cliente mostraba 0 € de descuento a gente que sí lo tenía. Solo a veces, sin patrón. Nadie había tocado ese módulo en meses.
La causa estaba en tres líneas: un as UserProfile sobre la respuesta del fetch, un ?? 0 sobre el descuento y, tres capas más arriba, un catch que logueaba y seguía. Tres líneas escritas para proteger el código.
Esa es la trampa de la programación defensiva tal y como la practica casi todo el mundo: no hace el sistema más robusto, hace los fallos más silenciosos.
El culpable de fondo era una caché que bajo carga devolvía un 200 con el perfil incompleto. Eso no llegó a ningún log.
Qué es la programación defensiva: decidir dónde desconfías
La programación defensiva en TypeScript es escribir código que sigue comportándose de forma predecible cuando recibe datos o condiciones que no esperaba. Se concreta en tres decisiones: validar de forma exhaustiva en las fronteras del sistema, fallar de inmediato y con contexto cuando algo no cuadra, y modelar los tipos para que los estados inválidos no se puedan ni construir.
La versión mala la conoces: try/catch envolviendo todo, comprobar null en cada función interna, copias defensivas por si acaso, validar los argumentos de tus propios métodos privados. Mucho código de más que no atrapa nada.
La versión que funciona son tres decisiones:
- Valida en las fronteras y confía en el interior.
- Falla rápido y ruidoso.
- Haz que los estados inválidos no se puedan representar.
No es desconfiar de tu propio código. Es elegir con precisión los sitios donde desconfías —pocos, explícitos, en el borde— para poder confiar en todo lo demás.
Guard clauses: la programación defensiva que se nota al leer
Una guard clause es una salida temprana que valida una precondición y aborta la función antes de entrar en la lógica principal. Empieza por aquí, que es lo más barato. Esto lo he visto con nombres distintos en muchos repos:
async function publicarPost(userId: string, draftId: string) {
const user = await repo.findUser(userId)
if (user) {
if (user.plan !== 39;free39;) {
const draft = await repo.findDraft(draftId)
if (draft && draft.ownerId === user.id) {
if (draft.body.length > 0) {
return repo.publish(draft.id)
}
}
}
}
throw new Error(39;No se pudo publicar el post39;)
}
Cuatro niveles de indentación y un error final que no dice nada. Cuando salte en producción no sabrás si el usuario no existe, si el borrador es de otro o si venía vacío.
Dale la vuelta:
async function publicarPost(userId: string, draftId: string) {
const user = await repo.findUser(userId)
if (!user) throw new NotFoundError(`user ${userId}`)
if (user.plan === 39;free39;) throw new ForbiddenError(`plan free no publica: user ${user.id}`)
const draft = await repo.findDraft(draftId)
if (!draft) throw new NotFoundError(`draft ${draftId}`)
if (draft.ownerId !== user.id) throw new ForbiddenError(`draft ${draftId} no es de ${user.id}`)
if (draft.body.length === 0) throw new ValidationError(`draft ${draftId} sin contenido`)
return repo.publish(draft.id)
}
El camino feliz queda al final, sin indentar, y cada salida lleva su motivo. No has añadido lógica: has sacado las excepciones del flujo.
(NotFoundError, ForbiddenError y ValidationError son tres clases propias que extienden Error. El tipo del error es lo que luego mapeas a un 404, un 403 o un 422 en un único sitio.)
Valida en las fronteras, confía en el interior
Una frontera es cualquier sitio donde entran datos que no controlas: input de usuario, respuesta de una API externa, un fichero, un mensaje de una cola, process.env, los params de una URL.
Ahí toca ser exhaustivo, y ahí casi nadie lo es porque TypeScript da una falsa sensación de seguridad:
const res = await fetch(`/api/invoices/${id}`)
const invoice = (await res.json()) as Invoice // cero validaciones en runtime
total += invoice.amount * invoice.rate // ¿y si amount llega como "1250"?
Ese as es una mentira que el compilador se cree: no comprueba nada, solo le prometes al type checker que confíe. Si el backend cambia amount de número a string, TypeScript sigue verde y el bug aparece dos pantallas más allá.
Y aquí JavaScript te hace un favor envenenado. "1250" * 1.21 da 1512.5, no da error: la coerción silenciosa produce un número plausible y todo sigue funcionando. Un NaN sería una suerte, porque se ve. Lo que rompe de verdad son los casos que casi funcionan: "1.250,00" sí da NaN, y una cadena vacía da 0 — el mismo cero fantasma del principio de este post, entrando ahora por otra puerta.
La frontera se valida con un esquema. Zod encaja bien porque el tipo sale del esquema, no al lado del esquema:
import { z } from 39;zod39;
const Invoice = z.object({
id: z.uuid(),
amount: z.number().int().nonnegative(), // céntimos
rate: z.number().positive(),
status: z.enum([39;draft39;, 39;sent39;, 39;paid39;]),
})
type Invoice = z.infer<typeof Invoice>
async function fetchInvoice(id: string): Promise<Invoice> {
const res = await fetch(`/api/invoices/${id}`)
if (!res.ok) throw new Error(`GET /invoices/${id} devolvió ${res.status}`)
try {
return Invoice.parse(await res.json())
} catch (cause) {
throw new Error(`respuesta inválida de GET /invoices/${id}`, { cause })
}
}
(Los formatos de string van al primer nivel desde Zod 4: si sigues en la 3, z.uuid() es z.string().uuid().)
A partir de ese parse, Invoice es verdad. No un deseo. Por eso ninguna función interna vuelve a preguntar si amount es un número: revalidar lo ya validado en el borde es ruido que te hace creer que estás cubierto donde no lo estás. Para exprimir la herramienta, el curso de Zod.
Pero el interior tiene bordes propios, y son los que nadie mira: la base de datos —una columna JSON, una migración corrida a mano, un campo que el ORM jura que no es nulo y en producción tiene nulos de 2023—, un módulo legacy sin strict en medio de tu app, y lo que devuelve una librería de terceros cuyo .d.ts solo opina. La regla corta: si el tipo no lo produjo un parse tuyo, es frontera aunque esté dentro.
La frontera más rentable y la más ignorada es la configuración: un esquema de process.env en el arranque convierte "la app lleva dos horas fallando raro" en "la app no arranca y te dice qué falta".
Parse, don't validate: que el tipo cargue con la prueba
Parse, don't validate —el principio que Alexis King formuló en 2019— dice que una comprobación no debe devolver un booleano, sino un dato con un tipo más estrecho que demuestre que la comprobación ocurrió.
Una función de validación clásica devuelve un boolean y tira la información a la basura:
declare function isEmail(value: string): boolean
if (isEmail(input)) {
await sendWelcome(input) // input sigue siendo string
}
// 200 líneas después, en otro fichero
await sendWelcome(req.body.email) // compila igual, nadie validó nada
El problema no es la regex. Es que después del if no queda rastro en el sistema de tipos de que la comprobación ocurrió.
Devuelve el dato convertido a un tipo más estrecho:
type Email = string & { readonly __brand: 39;Email39; }
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
export function parseEmail(value: string): Email {
const normalizado = value.trim().toLowerCase()
if (!EMAIL_RE.test(normalizado)) throw new ValidationError(`email inválido: ${value}`)
return normalizado as Email
}
async function sendWelcome(to: Email) { /* ... */ }
const email: string = req.body.email
sendWelcome(email) // ❌ error de compilación: string no es Email
sendWelcome(parseEmail(email)) // ✅ única forma de entrar
Ya es imposible escribir a una dirección sin validar: no porque te acuerdes, sino porque no compila.
Con una excepción que conviene saber: si el dato sale de un req.body que es any —lo que te da Express por defecto—, el any se cuela y compila igual. El brand te protege del interior; del exterior te protege el esquema de la frontera. Los dos, no uno.
El único as que me permito es el de dentro del parseo, encerrado en cuatro líneas auditables. Con Zod tienes el atajo, aunque el tipo hay que extraerlo: const Email = z.email().brand<'Email'>() y type Email = z.infer<typeof Email>.
Y si lo que quieres es decidir cuándo merece la pena montar un validador de esquemas, lo comparé en detalle en cuándo usar Zod en lugar de TypeScript para validar en runtime.
Falla rápido y ruidoso: el fail fast que sí protege
Un error que explota donde se produjo cuesta minutos de depuración. El mismo error tragado cuesta días. Los sospechosos habituales:
try {
applyConfig(await loadConfig())
} catch (e) {
console.error(39;error cargando config39;, e) // y la app arranca con los defaults
}
const descuento = user.discount ?? 0
const items = res.data?.items || []
El catch que loguea y sigue es peor que no tener catch: además de no arreglar nada, deja la conciencia tranquila en la revisión de código. Cuando el que ejecuta el código es un agente el problema se multiplica, y de eso va cómo manejar errores en agentes de IA con TypeScript.
Y los valores por defecto silenciosos merecen párrafo propio. ?? 0 no significa "no hay descuento", significa "no sé si hay descuento". Al escribirlo conviertes no sé en sí sé, y vale cero. Eso no es un fallback, es el bug. Con || [] igual: nadie distingue un carrito vacío de una petición que falló.
Un valor por defecto es legítimo cuando la ausencia del dato es un estado real del dominio, no cuando es el síntoma de que algo se rompió antes.
El compilador de TypeScript como primera línea de defensa
Cada comprobación que mueves a tiempo de compilación es una que no escribes, ni mantienes, ni testeas.
Lo obvio primero: strict activado, any prohibido y noUncheckedIndexedAccess si te atreves. Si arrastras un proyecto sin strict, migrar a TypeScript 6.0 con strict activado es lo primero que haría, antes de tocar nada más.
Después, modela para que el estado imposible no exista. Este tipo permite { status: 'paid' } sin fecha, { status: 'pagado' } con typo y un pendiente con fecha de pago:
type Pago = { status: string; paidAt?: Date; receiptUrl?: string }
Este otro no permite ninguno de los tres:
type Pago =
| { status: 39;pending39; }
| { status: 39;paid39;; paidAt: Date; receiptUrl: string }
Y para lo que debe ser cierto siempre, el patrón assertNever:
function assertNever(x: never): never {
throw new Error(`caso no manejado: ${JSON.stringify(x)}`)
}
function colorDeEstado(pago: Pago): string {
switch (pago.status) {
case 39;pending39;: return 39;gray39;
case 39;paid39;: return 39;green39;
default: return assertNever(pago)
}
}
Añade 'refunded' al union y el build se rompe señalando cada switch pendiente. Sin esa línea, el caso nuevo devuelve undefined un jueves por la tarde.
Qué NO es programación defensiva
Esto separa la técnica del dogma. Ninguna de estas cosas te protege:
try/catchglobal que traga. Convierte un fallo localizado en un misterio distribuido.- Comprobar
nullen funciones privadas que solo llamas tú. Si ya validaste arriba, no puede saltar nunca: código muerto que aparenta cuidado. - Revalidar en cada capa la forma de lo ya validado en la frontera. Si no confías en tu tipo
Invoice, el problema es el tipo, no la capa. Los permisos son otra historia: eso sí se comprueba lo más cerca posible del dato, aunque ya lo hayas comprobado arriba. - Copias defensivas por defecto. Clonar todo lo que entra y sale cuesta, y resuelve un problema que casi nunca tienes —si publicas una librería, la copia en el borde de tu API pública sí se paga sola.
- Programar para requisitos hipotéticos. El parámetro opcional "por si algún día" es una rama sin testear.
| Parece defensivo | Qué hace en realidad | Qué hacer en su lugar |
|---|---|---|
try/catch global que loguea y sigue |
Convierte un fallo localizado en un misterio distribuido | Relanzar con cause, o manejarlo con una acción concreta |
Comprobar null en funciones privadas |
Código muerto que aparenta cuidado | Confiar en el tipo parseado en la frontera |
| Revalidar la forma en cada capa | Ruido que sugiere que el tipo miente | Arreglar el tipo, no añadir capas |
as sobre res.json() |
Silencia al compilador sin comprobar nada | Esquema.parse(await res.json()) |
?? 0 sobre un dato ausente |
Convierte "no sé" en "sí sé, y vale cero" | Fallar, o modelar la ausencia como estado del dominio |
El coste no es rendimiento, es atención. Cada comprobación de más grita "aquí puede llegar un null" cuando no puede llegar. El lector acaba ignorándolas todas, y ese es el día en que se ignora la que sí importaba.
Es el mismo mecanismo que conté en cuándo evitar los principios SOLID: un principio aplicado por dogma, sin medir el contexto, produce peor código que no aplicarlo.
Por qué la programación defensiva importa más con código de IA
Nada de lo anterior es nuevo. Lo que ha cambiado es quién escribe el código.
Una parte creciente de lo que entra en tus repos no lo has teclado tú, lo ha generado un agente. No te voy a dar un porcentaje: abre el último PR que mergeaste y cuéntalo.
El código generado es sintácticamente impecable y plausible: se lee bien, pasa el linter, convence en diez minutos de revisión. Falla en los casos límite y en las suposiciones sobre la forma de los datos —que el endpoint siempre devuelve el campo, que el array nunca viene vacío— y reparte ?? 0 y catch silenciosos, porque ha aprendido del código defensivo mal escrito de internet.
Eso lo detectas leyendo despacio, no en una revisión rápida. Y vas a hacer revisiones rápidas, porque el volumen ha subido — un problema que merece su propio protocolo, y del que hablé en cómo gestionar PRs generadas por agentes en la revisión de código.
Las fronteras validadas y el fallo ruidoso son la red que atrapa eso sin depender de que revises cada línea: si el esquema está en el borde, el dato con la forma equivocada muere en el parse, lo escriba quien lo escriba. Cuanto más código generes, más vale la red. En esa dirección va cómo garantizar la confiabilidad del código generado por IA.
Hay un segundo movimiento, de proceso: la forma de los datos es lo que la spec fija antes de que el agente escriba una línea. Es el núcleo del libro de Spec-Driven Development.
Tres cambios que puedes hacer hoy en 30 minutos
Tres cosas, en este orden.
- Escribe el esquema de
process.envy párselo en el arranque. Es la frontera más tonta de tu app y la que más tiempo te devuelve. - Busca
catchseguido deconsole. Cada uno es una decisión que alguien no tomó: o lo manejas, o lo relanzas con contexto usandocause. - Añade
assertNeveralswitchmás grande que tengas sobre un union de estados. Tres líneas que convierten una clase entera de bugs de runtime en errores de compilación.
Después lleva esas reglas a tu CLAUDE.md o AGENTS.md: esquema en las fronteras, prohibido as sobre respuestas externas, prohibido catch que solo loguea. Configurar así al agente antes de que escriba una línea es el flujo que enseño en Construye con IA.
La programación defensiva no es desconfiar de tu código. Es decidir dónde desconfías para poder confiar en el resto. Elige tres fronteras esta semana y déjalas cerradas.
Si quieres ver estos patrones sobre proyectos reales, con el código completo, es una de las conversaciones habituales en Dominicode Labs.
Preguntas frecuentes
¿Qué es la programación defensiva?
La programación defensiva es escribir código que sigue comportándose de forma predecible cuando recibe datos o condiciones que no esperaba. En su versión útil son tres decisiones: validar de forma exhaustiva en las fronteras del sistema, fallar de inmediato y con contexto cuando algo no cuadra, y modelar los tipos para que los estados inválidos no se puedan ni construir. No consiste en llenar el código de comprobaciones por si acaso: eso esconde los bugs.
¿La programación defensiva es lo mismo que envolver todo en try/catch?
No. La programación defensiva y el try/catch global son estrategias opuestas: un try/catch que captura un error, lo loguea y continúa deja el programa corriendo con datos en estado desconocido, y el fallo aparece más tarde, en otro sitio y sin rastro de su causa.
Captura un error solo cuando puedes hacer algo concreto: reintentar, devolver un 4xx, activar un fallback que sea un estado legítimo del dominio, o relanzarlo con new Error(mensaje, { cause }) — que necesita lib: ES2022 en tu tsconfig.
¿Dónde están las fronteras de mi aplicación?
Las fronteras de una aplicación son los puntos por donde entran datos cuya forma no controlas: los handlers HTTP (body, query, params, headers), las respuestas de APIs de terceros, process.env, los ficheros que lees o te suben, los mensajes de una cola o un webhook, y localStorage.
Añade dos que casi nunca se cuentan: la base de datos, porque una columna JSON o una migración corrida a mano te devuelven cualquier cosa; y las colas, porque el payload lo escribió la versión anterior de tu propio código. La regla corta: si el tipo no lo produjo un parse tuyo, es frontera aunque esté dentro.
¿Los tipos de TypeScript me protegen en producción?
No, y es el malentendido más caro. Los tipos de TypeScript desaparecen al compilar, así que en ejecución no existe ninguna comprobación. Cuando escribes const data = await res.json() as MiTipo no validas nada: silencias al compilador con una promesa que el runtime nunca verifica.
La frontera necesita un validador de esquemas —Zod es el que uso— que compruebe la forma real del dato y devuelva un tipo. Si quieres el criterio para decidir cuándo montarlo, lo comparé en cuándo usar Zod en lugar de TypeScript para validar en runtime.
¿Cuánto código defensivo es demasiado?
Una comprobación es demasiada cuando no puede saltar nunca. La regla verificable: si no puedes nombrar el caller concreto que la haría fallar, bórrala. Y si la respuesta es "ninguno, porque el dato ya viene validado de la frontera", con más razón.
La señal de alarma es un fichero con más líneas de defensa que de lógica de negocio.
¿Cómo aplico la programación defensiva al código que genera un agente de IA?
La programación defensiva se aplica al código generado fijando las fronteras antes de generar y dejándolas por escrito en las instrucciones del agente. Tres reglas en tu CLAUDE.md o AGENTS.md cubren la mayor parte: toda entrada externa se valida con un esquema, prohibido as sobre datos sin parsear, y prohibido capturar un error solo para loguearlo.
Funciona porque no depende de que detectes el fallo leyendo: si el esquema está en el borde, el dato con la forma equivocada muere ahí, lo escriba quien lo escriba.
Por Bezael Pérez — Developer senior con más de 15 años de experiencia y fundador de Dominicode.
