Todas mis APIs internas usan el mismo contrato de error handling en TypeScript SaaS API porque un fallo debe ser entendible sin una reunión, sin leer el código fuente y sin conocer la historia del servicio. En un SaaS con agentes operando sobre workflows, el error no es solo una respuesta negativa: es una instrucción. Un error bien formado le dice al receptor qué pasó, si tiene sentido reintentar y qué necesita para continuar.
Esto no es un detalle técnico menor. Cuando empecé a operar varios SaaS en paralelo con agentes ejecutando tareas automáticamente, los errores inconsistentes se convirtieron en el principal obstáculo operativo. El agente recibía un error, no podía interpretar si debía reintentar, escalar o ignorar, y terminaba fallando por razones que no tenían nada que ver con el problema original.
El 94 por ciento de los errores de compilación en código generado por LLMs son fallas de type-check. Un error de tipo en compilación cuesta 25 USD en promedio; el mismo error en producción cuesta entre 750 USD y 1.500 USD en tiempo de desarrollo e impacto. Un contrato explícito de errores en todas las APIs reduce el tiempo de debugging en 3 veces.
Por qué la inconsistencia de errores es un problema real
Tres reglas que sigo en todos mis SaaS para el contrato de errores:
- Todo endpoint devuelve el mismo shape: un objeto con data, error y status — el cliente sabe siempre qué esperar.
- Los errores tienen código de dominio: no solo el HTTP status, sino el reason interno para logging.
- Los errores de validación son separados de errores de negocio: 422 con lista de campos vs 409 con razón de negocio.
Cuando empecé a construir varios SaaS en paralelo, cada uno tenía su propia convención de errores. Algunos lanzaban excepciones con message libre. Otros devolvían { error: string }. Otros mezclaban HTTP status codes con mensajes en body de maneras distintas.
El resultado: cada vez que un agente o un servicio nuevo consumía una API, necesitaba leer el código fuente para entender cómo manejar los errores. Eso no escala.
Un contrato de error consistente resuelve ese problema. No es un detalle de implementación: es una interfaz de comunicación entre partes del sistema.
El contrato
| Patrón de error | Costo en desarrollo | Costo en producción |
|---|---|---|
| throw new Error("msg") genérico | Bajo inicial | Alto — sin contexto |
| return null silencioso | Bajo inicial | Muy alto — bug silencioso |
| Result type | Medio inicial | Bajo — explícito |
| Custom error classes | Medio inicial | Bajo — trazable |
de error handling TypeScript que uso
Definí un tipo AppError que todas las APIs devuelven cuando algo falla:
type AppError = {
code: string; // código estable y legible: 'TENANT_NOT_FOUND', 'PAYMENT_FAILED'
message: string; // mensaje para humanos
retryable: boolean; // ¿tiene sentido reintentar?
field?: string; // campo que falló, si aplica
operation?: string; // qué operación produjo el error
}
Y un tipo Result<T> que envuelve todas las respuestas:
type Result<T> =
| { ok: true; data: T }
| { ok: false; error: AppError }
Los handlers nunca lanzan excepciones no controladas. Siempre devuelven un Result. Los consumidores siempre chequean ok antes de usar data.
Por qué retryable es un campo de primera clase
El campo retryable parece un detalle, pero es lo que permite que los agentes operen sin supervisión. Si un agente llama a una API y recibe un error, necesita saber si tiene sentido reintentar automáticamente o si debe escalar al humano.
Un error de red es reintentable. Un error de validación no lo es. Un error de rate limit es reintentable con backoff. Un error de permisos requiere intervención humana.
Sin ese campo, el agente tiene que inferir la reintentabilidad del código HTTP o del mensaje de error, lo cual es frágil y propenso a errores.
En la práctica, retryable: false en un error de permisos evitó que un agente entrara en un loop de reintentos que habría generado alertas falsas y cargado innecesariamente las APIs. La diferencia entre un sistema que falla bien y uno que se degrada en cascada puede estar en este campo.
Cómo manejo los errores de terceros en el contrato
Las APIs de terceros (Stripe, SendGrid, servicios externos) no usan mi contrato. El trabajo de adaptación ocurre en la capa de integración.
Cada cliente de API externa tiene un wrapper que traduce errores externos al formato AppError. Eso significa:
- Un error 402 de Stripe se convierte en
{ code: 'PAYMENT_CARD_DECLINED', retryable: false }. - Un timeout de red se convierte en
{ code: 'DEPENDENCY_TIMEOUT', retryable: true }. - Un error 429 se convierte en
{ code: 'RATE_LIMIT_EXCEEDED', retryable: true }con información de cuándo reintentar.
Esta normalización permite que el resto del sistema trate todos los errores de la misma manera, independientemente de dónde vinieron. El agente que maneja un flujo de checkout no necesita saber si el error viene de Stripe o de una validación interna.
Cómo tipeo errores de dominio sin perder generalidad
El code string es suficiente para la mayoría de los casos, pero en dominios complejos conviene ir más lejos. Defino uniones de literales para los códigos posibles en cada dominio:
type PaymentErrorCode =
| 'PAYMENT_CARD_DECLINED'
| 'PAYMENT_INSUFFICIENT_FUNDS'
| 'PAYMENT_PROVIDER_TIMEOUT'
| 'PAYMENT_INVALID_METHOD';
type PaymentError = AppError & { code: PaymentErrorCode };
Eso permite que el consumidor haga switch exhaustivo sobre los códigos de error de pago y TypeScript avise si agrego un código nuevo sin actualizar todos los manejadores. Es la misma idea que las enumeraciones discriminadas: el compilador valida que todos los casos están cubiertos.
No aplico esto en todos los dominios — solo donde la cantidad de errores posibles justifica el overhead de mantener la unión. En dominios con uno o dos errores posibles, el string libre es suficiente.
Cómo evolucioné el contrato sin romper consumidores
El contrato empezó simple y creció con el sistema. Agregué field cuando empecé a tener más validaciones. Agregué operation cuando los logs necesitaban más contexto para debuggear problemas en producción.
Lo que no cambié: la forma de Result<T>. Esa es la interface que todos los consumidores conocen. Los campos adicionales en AppError son aditivos y opcionales.
Cuando necesité cambiar el significado de un campo existente — lo cual ocurrió una vez con field, que pasó de string a string[] para soportar errores de múltiples campos — lo hice en dos pasos: primero acepté ambas formas, migré todos los consumidores, y después eliminé la forma antigua. Ese proceso de migración tomó más tiempo que el cambio en sí, pero no rompió nada en producción.
Este principio de evolución aditiva es el mismo que aplico a schema migration con PlanetScale: agregar antes de reemplazar, no romper lo que ya funciona.
El contrato de error también se conecta con el principio de código mantenible que no pide atención: un error bien estructurado no necesita que yo esté disponible para ser interpretado.
Si necesitás diseñar una API con manejo de errores que funcione bien para agentes y para humanos, trabajo en proyectos de software bajo solu30.
Preguntas frecuentes
¿Qué es un contrato de error en TypeScript? Es un tipo compartido que define la estructura de todos los errores de la API: código, mensaje, si es reintentable, qué dato falta, en qué operación ocurrió. Permite que cualquier consumidor entienda el fallo sin contexto adicional.
¿Por qué es importante la consistencia en el error handling de una SaaS API? Porque los errores inconsistentes requieren conocimiento interno para interpretarlos. En un sistema con múltiples servicios y agentes, un error debe ser autodescriptivo.
¿Cómo manejo errores en Next.js API routes con TypeScript?
Definiendo un tipo Result<T, E> o un AppError compartido, y usando funciones helper para construir respuestas de error consistentes. Los handlers devuelven resultados tipados, no excepciones no controladas.
¿Qué incluye un buen mensaje de error de API? Código de error estable, mensaje legible, flag de reintentabilidad, campo que falló o dato que faltó, y operación que lo produjo.

