Diseño de respuestas de error
El manejo de errores centralizado resuelve dónde se atrapan los errores — esta guía es sobre qué forma tiene la respuesta que el cliente recibe, para que sea consistente en toda la API.
Un formato, siempre igual
Sección titulada «Un formato, siempre igual»// Mal: cada endpoint devuelve una forma distintares.status(400).json({ error: "Falta el email" })res.status(400).json("Email inválido")res.status(400).json({ message: "Bad request", field: "email" })
// Bien: un solo formato, en todos ladosres.status(400).json({ error: { code: "VALIDATION_ERROR", message: "Email inválido" }})Un cliente (frontend, app mobile, otro servicio) que consume la API puede manejar errores genéricamente (if (response.error) ...) solo si la forma es predecible — tres formatos distintos según el endpoint obliga a manejar cada caso por separado.
Código de error propio, no solo status HTTP
Sección titulada «Código de error propio, no solo status HTTP»El status HTTP (400, 404, 409) dice la categoría general; un code propio permite que el cliente reaccione distinto ante errores específicos sin parsear el mensaje de texto (que puede cambiar, o estar en otro idioma):
class AppError extends Error { constructor( public status: number, public code: string, message: string ) { super(message) }}
throw new AppError(409, "EMAIL_YA_REGISTRADO", "Ese email ya está en uso")throw new AppError(404, "USUARIO_NO_ENCONTRADO", "Usuario no encontrado")throw new AppError(400, "VALIDATION_ERROR", "Datos inválidos")// En el frontend, reaccionar al código específico, no al textoif (error.code === "EMAIL_YA_REGISTRADO") { mostrarLinkDeLogin()}Errores de validación: detalle por campo
Sección titulada «Errores de validación: detalle por campo»Para errores que vienen de Zod o express-validator, el cliente generalmente necesita saber qué campo falló, no solo que “algo” falló:
app.use((err, req, res, next) => { if (err instanceof ZodError) { return res.status(400).json({ error: { code: "VALIDATION_ERROR", message: "Datos inválidos", fields: err.flatten().fieldErrors // { email: ['Email no válido'], age: [...] } } }) } next(err)})Qué exponer vs qué ocultar
Sección titulada «Qué exponer vs qué ocultar»export const errorHandler: ErrorRequestHandler = (err, req, res, next) => { if (err instanceof AppError) { // Errores "esperados": el mensaje es seguro de mostrar return res .status(err.status) .json({ error: { code: err.code, message: err.message } }) }
// Errores inesperados (bug, falla de DB): loguear completo, NO exponer detalles console.error(err) res.status(500).json({ error: { code: "INTERNAL_ERROR", message: "Algo salió mal" } })}Un error de base de datos, un stack trace o el mensaje real de una excepción interna puede filtrar detalles de implementación: nombres de tablas, versiones de librerías o rutas del sistema. Solo los errores operativos creados explícitamente por la aplicación deben exponer un mensaje controlado.
Contrato de referencia
Sección titulada «Contrato de referencia»| Elemento | Para qué |
|---|---|
Formato único ({ error: { code, message } }) | El cliente maneja errores genéricamente |
code propio | Reaccionar a un error específico sin parsear texto |
fields en errores de validación | El cliente sabe qué campo mostrar en rojo |
| Errores esperados vs inesperados | Solo los esperados exponen su mensaje real |
Exposición segura de errores
Sección titulada «Exposición segura de errores»- Mensajes en el idioma de la API (o traducidos del lado del cliente usando
code) — mezclar textos hardcodeados en un idioma concodes en inglés es común y está bien, siempre que el cliente pueda decidir qué mostrar. - Este formato es una convención propia. Para interoperabilidad existe
application/problem+json, definido en RFC 9457, que reemplaza RFC 7807. Elige un contrato y aplícalo también a errores del parser, autenticación y límites de tráfico.