Por qué necesitas un manejo de errores consistente
En una API con Express, los errores pueden ocurrir por validaciones (entrada inválida), recursos inexistentes, conflictos de negocio (duplicados) o fallos inesperados. Si cada ruta responde distinto, el cliente no puede manejar bien los fallos. La idea es implementar un flujo único: capturar errores síncronos y asíncronos, centralizarlos en un middleware de error y responder siempre con un formato estable.
Formato de respuesta de error
Usaremos un esquema simple y predecible:
{ "message": "...", "code": "...", "details": { ... } }message: texto legible para el cliente (sin detalles internos).code: identificador estable para lógica del frontend (por ejemploVALIDATION_ERROR).details: información opcional (por ejemplo campos inválidos). Debe ser segura para exponer.
Paso a paso: flujo robusto de errores en Express
Paso 1: crear una clase de error personalizada
Necesitas una forma uniforme de “lanzar” errores con metadatos (código HTTP, código lógico y detalles). Crea un archivo errors/AppError.js:
class AppError extends Error { constructor({ message, code, status, details }) { super(message); this.name = "AppError"; this.code = code || "INTERNAL_ERROR"; this.status = status || 500; this.details = details; }}module.exports = { AppError };Este error será el que uses en tu código de negocio para expresar fallos esperados (400, 404, 409, etc.).
Paso 2: helpers para crear errores comunes
Para evitar repetir valores, crea un archivo errors/httpErrors.js:
- Escuche el audio con la pantalla apagada.
- Obtenga un certificado al finalizar.
- ¡Más de 5000 cursos para que explores!
Descargar la aplicación
const { AppError } = require("./AppError");const badRequest = (message, details) => new AppError({ message, code: "BAD_REQUEST", status: 400, details });const notFound = (message, details) => new AppError({ message, code: "NOT_FOUND", status: 404, details });const conflict = (message, details) => new AppError({ message, code: "CONFLICT", status: 409, details });module.exports = { badRequest, notFound, conflict };Así, en tus rutas o servicios podrás hacer throw notFound(...) o throw badRequest(...) de forma clara.
Paso 3: manejar errores síncronos vs asíncronos
En Express, un error síncrono lanzado dentro de un handler suele ser capturado automáticamente y enviado al middleware de error. El problema principal aparece con handlers async: si lanzas un error o falla una promesa, debes asegurarte de que llegue a next(err).
Paso 4: wrapper para handlers async (evitar try/catch repetitivo)
Crea un helper utils/asyncHandler.js para envolver handlers asíncronos:
const asyncHandler = (fn) => (req, res, next) => { Promise.resolve(fn(req, res, next)).catch(next);};module.exports = { asyncHandler };Con esto, cualquier throw dentro de un handler async o cualquier rechazo de promesa irá al middleware de error sin escribir try/catch en cada ruta.
Paso 5: middleware centralizado de error
Este middleware será el único lugar donde se decide el HTTP status y el formato de respuesta. Crea middlewares/errorHandler.js:
const { AppError } = require("../errors/AppError");const errorHandler = (err, req, res, next) => { const isAppError = err instanceof AppError; const status = isAppError ? err.status : 500; const code = isAppError ? err.code : "INTERNAL_ERROR"; const message = isAppError ? err.message : "Ocurrió un error inesperado"; const details = isAppError ? err.details : undefined; if (!isAppError) { console.error("[UNHANDLED_ERROR]", err); } const payload = { message, code }; if (details !== undefined) payload.details = details; res.status(status).json(payload);};module.exports = { errorHandler };Observa dos decisiones importantes:
- Si el error no es
AppError, respondemos con un mensaje genérico para no filtrar detalles internos. - El error real se registra en servidor (por ejemplo con
console.erroro un logger), pero no se expone al cliente.
Paso 6: conectar el middleware de error al final
En tu archivo principal (por ejemplo app.js), registra el middleware de error después de tus rutas:
const express = require("express");const { errorHandler } = require("./middlewares/errorHandler");const app = express();app.use(express.json());// ... aquí van tus rutas// Middleware de error al finalapp.use(errorHandler);module.exports = { app };El orden importa: si lo pones antes de las rutas, no recibirá los errores generados por ellas.
Mapeo de tipos de error a códigos HTTP (400, 404, 409, 500)
Una regla práctica es separar “errores esperados” (por entrada o negocio) de “errores inesperados” (bugs, caídas de servicios, etc.). Con AppError y los helpers, el mapeo queda explícito:
| Situación | HTTP | code | Ejemplo |
|---|---|---|---|
| Entrada inválida (validación) | 400 | BAD_REQUEST / VALIDATION_ERROR | Falta un campo requerido |
| Recurso inexistente | 404 | NOT_FOUND | ID no existe |
| Conflicto de negocio | 409 | CONFLICT | Email ya registrado |
| Fallo inesperado | 500 | INTERNAL_ERROR | Excepción no controlada |
Si quieres distinguir validación de otros 400, puedes crear un helper adicional:
const validationError = (details) => new AppError({ message: "Datos inválidos", code: "VALIDATION_ERROR", status: 400, details });Ejemplo práctico: rutas con errores consistentes
Supón un recurso users. Implementaremos tres casos: validación (400), no encontrado (404) y conflicto (409). En un archivo routes/users.js:
const express = require("express");const router = express.Router();const { asyncHandler } = require("../utils/asyncHandler");const { badRequest, notFound, conflict } = require("../errors/httpErrors");// Simulación de persistencia en memoriaconst users = new Map();router.post("/users", asyncHandler(async (req, res) => { const { email, name } = req.body; if (!email || !name) { throw badRequest("Faltan campos requeridos", { required: ["email", "name"] }); } if (users.has(email)) { throw conflict("El usuario ya existe", { field: "email" }); } const user = { email, name }; users.set(email, user); res.status(201).json({ data: user });}));router.get("/users/:email", asyncHandler(async (req, res) => { const { email } = req.params; const user = users.get(email); if (!user) { throw notFound("Usuario no encontrado", { email }); } res.json({ data: user });}));module.exports = { router };Notas:
- Los errores se lanzan con
throwy el wrapperasyncHandlerlos envía al middleware centralizado. - Las respuestas exitosas pueden tener su propio formato (por ejemplo
{ data: ... }), pero lo importante aquí es que los errores siempre respetan{ message, code, details }.
Conectar estas rutas
En tu archivo de app:
const express = require("express");const { router: usersRouter } = require("./routes/users");const { errorHandler } = require("./middlewares/errorHandler");const app = express();app.use(express.json());app.use(usersRouter);app.use(errorHandler);module.exports = { app };try/catch en handlers async: cuándo usarlo
Con asyncHandler, no necesitas try/catch para “reenviar” errores. Sin embargo, sí es útil cuando quieres transformar un error de bajo nivel en un error de dominio seguro.
Ejemplo: una función que podría lanzar un error interno (simulado) y tú quieres devolver 409 o 400 según el caso:
const { AppError } = require("../errors/AppError");const { conflict } = require("../errors/httpErrors");async function createUserInDb(input) { // Simula un error de "duplicado" desde una capa inferior const err = new Error("duplicate key"); err.code = "DUPLICATE_KEY"; throw err;}router.post("/users-db", asyncHandler(async (req, res) => { try { const user = await createUserInDb(req.body); res.status(201).json({ data: user }); } catch (err) { if (err.code === "DUPLICATE_KEY") { throw conflict("El usuario ya existe", { field: "email" }); } // Si no sabes qué es, re-lanza para que sea 500 genérico throw err; }}));La clave es: el try/catch no es para responder ahí mismo, sino para convertir errores a AppError cuando tiene sentido.
Evitar filtrar detalles internos al cliente
Un error inesperado puede contener stack traces, rutas del sistema, queries o datos sensibles. Para evitar filtraciones:
- Responde con un mensaje genérico cuando no sea un
AppError. - No incluyas
err.stackni el mensaje original en la respuesta. - En
details, incluye solo datos que el cliente pueda usar sin comprometer seguridad (por ejemplo campos inválidos, no valores sensibles).
Si necesitas depurar, registra el error completo en servidor. El cliente se guía por code y message, y opcionalmente por details.
Comprobación rápida con ejemplos de respuesta
Error 400 (validación)
// HTTP 400{ "message": "Faltan campos requeridos", "code": "BAD_REQUEST", "details": { "required": ["email", "name"] } }Error 404 (no encontrado)
// HTTP 404{ "message": "Usuario no encontrado", "code": "NOT_FOUND", "details": { "email": "test@example.com" } }Error 409 (conflicto)
// HTTP 409{ "message": "El usuario ya existe", "code": "CONFLICT", "details": { "field": "email" } }Error 500 (inesperado)
// HTTP 500{ "message": "Ocurrió un error inesperado", "code": "INTERNAL_ERROR" }