* notes / express

Manejo de errores real en Express: una jerarquía de clases que funciona

Cómo limpié años de try/catch desperdigados en mis controladores con una jerarquía de clases de error, un middleware central y la disciplina de distinguir errores operativos de errores de programación.

August 18, 20259 min read#express#typescript#errores#backend

Siete try/catch consecutivos en el mismo handler. Cada uno respondía con una forma JSON ligeramente distinta, cada uno con un console.log propio, cada uno olvidando algún detalle: el statusCode, el requestId, el cuerpo serializable. El problema no era ese controlador, sino la ausencia de una convención compartida para hablar de errores en toda la aplicación. Lo que sigue es la solución que adopté y que, refinada, sigue rigiendo todos mis backends en Express + TypeScript: una jerarquía de clases de error con un único middleware central que las traduce a respuestas HTTP.

Contexto: la salud de un backend se mide por su contrato de error

Cualquier API tiene dos contratos: el de éxito y el de error. El primero recibe casi toda la atención (tipos generados, OpenAPI, validación de entrada); el segundo suele ser una colección de adivinanzas. El cliente termina parsing condicional sobre cadenas de mensaje, los logs son inservibles porque cada lugar imprime lo que le parece, y un fallo nuevo encuentra siempre un handler incompleto donde rebotar de forma vergonzosa con un 500 Internal Server Error y un stack trace en la respuesta.

El patrón que voy a describir hace tres promesas y las cumple:

  1. Forma uniforme de respuesta de error, sea cual sea el origen del fallo.
  2. Centralización de la traducción: la lógica de negocio sólo lanza errores tipados; un único middleware sabe convertirlos en HTTP.
  3. Separación entre errores operativos (recuperables, esperables) y errores de programación (bugs, invariantes rotos), distinción clásica que Joyent popularizó en su célebre artículo sobre errores en Node.

La jerarquía: una clase base abstracta y muchos descendientes

En la raíz vive CustomError, abstracta, que fija el contrato común:

// errors/customError.ts
export abstract class CustomError extends Error {{
  abstract readonly statusCode: number;
  abstract readonly code: string;
 
  constructor(message: string, public readonly cause?: unknown) {{
    super(message);
    Object.setPrototypeOf(this, new.target.prototype);
    this.name = new.target.name;
  }}
 
  abstract serializeErrors(): Array<{{
    message: string;
    field?: string;
    code: string;
  }}>;
}}
 
export default CustomError;

Tres detalles merecen comentario. Primero, Object.setPrototypeOf es necesario porque, al extender Error en clases de TypeScript transpiladas a versiones antiguas de ECMAScript, la cadena de prototipos se rompe y instanceof deja de funcionar; esta línea la repara. Segundo, statusCode y code son abstractos, lo que obliga a cada descendiente a declararlos: no hay forma de añadir una clase a la jerarquía y olvidar el código HTTP. Tercero, serializeErrors() devuelve siempre un arreglo, porque la validación a menudo arroja varios fallos a la vez (un campo vacío, otro mal formado), y queremos una sola forma para todos los casos.

Sobre esa base, cuelgan los descendientes concretos. Dos ejemplos canónicos:

// errors/badRequestError.ts
import CustomError from './customError';
 
export default class BadRequestError extends CustomError {{
  readonly statusCode = 400;
  readonly code = 'BAD_REQUEST';
 
  constructor(message: string, public readonly field?: string) {{
    super(message);
  }}
 
  serializeErrors() {{
    return [{{ message: this.message, field: this.field, code: this.code }}];
  }}
}}
// errors/notFoundError.ts
import CustomError from './customError';
 
export default class NotFoundError extends CustomError {{
  readonly statusCode = 404;
  readonly code = 'NOT_FOUND';
 
  constructor(resource: string, id?: string | number) {{
    super(`No se encontró ${{resource}}${{id !== undefined ? ` (${{id}})` : ''}}`);
  }}
 
  serializeErrors() {{
    return [{{ message: this.message, code: this.code }}];
  }}
}}

Y, en la práctica, la jerarquía completa que mantengo, exportada desde un barrel, se parece a esto:

// errors/index.ts
export {{ default as BadRequestError }} from './badRequestError';
export {{ default as DatabaseError }} from './databaseError';
export {{ default as FacialRecognitionError }} from './facialRecognitionError';
export {{ default as NotAuthorizedError }} from './notAuthorizedError';
export {{ default as NotFoundError }} from './notFoundError';
// NOTE: Export all error types
export {{ default as ValidationError }} from './validationError';
export {{ default as ForbiddenError }} from './forbiddenError';
export {{ default as RequestValidationError }} from './requestValidationError';
export {{ default as ServerError }} from './serverError';
export {{ default as SubscriptionExpiredError }} from './subscriptionExpiredError';
export {{ default as SupportError }} from './supportError';
export {{ default as DetailedValidationError }} from './detailedValidationError';
export {{ default as CustomError }} from './customError';

Esa lista revela algo importante: las clases de error reflejan dominios reales de la aplicación. FacialRecognitionError no existe en un boilerplate genérico, pero sí en un sistema que hace verificación biométrica; SubscriptionExpiredError existe en uno que cobra suscripciones. Cada clase es un punto donde el negocio se cruza con el contrato HTTP, y ese cruce vive en un único sitio, listo para ser revisado, instrumentado y traducido.

El flujo, de extremo a extremo

Controladorthrow new NotFoundErrornext(err)errorHandler middlewareinstanceof CustomError ?res.status().json(){{ errors: [...] }}JerarquíaCustomError (abstracta)├─ BadRequestError├─ NotFoundError├─ DatabaseError, ...

El controlador lanza un error tipado; Express lo recibe y, gracias al cuarto parámetro (err, req, res, next), lo entrega al middleware de errores; éste hace una única comprobación, instanceof CustomError, y serializa. Cualquier otro error (un TypeError inesperado, un null donde no debía) se mapea a ServerError y se registra con el detalle completo en los logs, pero al cliente sólo se le entrega un mensaje genérico.

El middleware central

// middlewares/errorHandler.ts
import {{ Request, Response, NextFunction }} from 'express';
import {{ CustomError, ServerError }} from '../errors';
import {{ logger }} from '../logger';
 
export function errorHandler(
  err: Error,
  req: Request,
  res: Response,
  _next: NextFunction,
) {{
  const requestId = (req as any).requestId ?? 'unknown';
 
  if (err instanceof CustomError) {{
    logger.warn({{
      requestId,
      code: err.code,
      message: err.message,
      cause: err.cause,
    }}, 'operational error');
 
    return res.status(err.statusCode).json({{
      errors: err.serializeErrors(),
      requestId,
    }});
  }}
 
  // error de programación: log completo, respuesta opaca
  logger.error({{
    requestId,
    err: {{ name: err.name, message: err.message, stack: err.stack }},
  }}, 'unexpected error');
 
  const fallback = new ServerError();
  return res.status(fallback.statusCode).json({{
    errors: fallback.serializeErrors(),
    requestId,
  }});
}}

Tres consideraciones operativas envuelven este middleware:

  • Correlación: cada request recibe un requestId (generado con nanoid o uuid) y se propaga tanto al log como a la respuesta. Cuando un cliente se queja, basta con que diga «mi requestId era X» para reconstruir el incidente al milímetro.
  • Logging estructurado: nada de console.log. Uso pino por su velocidad y por su salida JSON, fácil de digerir por cualquier log aggregator.
  • Mensaje opaco al cliente: para errores no operativos, devolver el stack es una falla de seguridad y una distracción. El cliente ve «error interno» y el equipo, en sus logs, ve el detalle.

Un controlador que ya no tiene try/catch

Con la convención en su sitio, el controlador deja de manchar la lógica con captura defensiva. Comparemos.

// antes
app.get('/users/:id', async (req, res) => {{
  try {{
    const user = await db.users.findById(req.params.id);
    if (!user) return res.status(404).json({{ message: 'not found' }});
    res.json(user);
  }} catch (err: any) {{
    console.error(err);
    res.status(500).json({{ message: 'server error' }});
  }}
}});
 
// después
import asyncHandler from 'express-async-handler';
import {{ NotFoundError }} from '../errors';
 
app.get('/users/:id', asyncHandler(async (req, res) => {{
  const user = await db.users.findById(req.params.id);
  if (!user) throw new NotFoundError('User', req.params.id);
  res.json(user);
}}));
 
app.use(errorHandler); // SIEMPRE al final

express-async-handler (o, alternativamente, Express 5, que ya soporta promesas de forma nativa) se encarga de canalizar el rechazo de la promesa hacia next(). El controlador vuelve a hacer una sola cosa: el caso feliz.

Integración con Zod y class-validator

Cuando la entrada se valida con Zod, el error nativo de Zod es informativo pero no encaja en la jerarquía. Lo envuelvo:

// errors/requestValidationError.ts
import {{ ZodError }} from 'zod';
import CustomError from './customError';
 
export default class RequestValidationError extends CustomError {{
  readonly statusCode = 400;
  readonly code = 'REQUEST_VALIDATION';
 
  constructor(private readonly zodError: ZodError) {{
    super('Datos de entrada inválidos');
  }}
 
  serializeErrors() {{
    return this.zodError.issues.map(i => ({{
      message: i.message,
      field: i.path.join('.'),
      code: this.code,
    }}));
  }}
}}

Y la lógica de un middleware validador queda en una línea:

const result = UserSchema.safeParse(req.body);
if (!result.success) throw new RequestValidationError(result.error);
req.body = result.data;

Operativos vs de programación: la distinción que lo cambia todo

En el clásico artículo de Joyent sobre Production-grade error handling in Node.js, operativo significa todo error que la aplicación espera y sabe manejar: la conexión cayó, el usuario no existe, la entrada es inválida. De programación significa un bug: una variable que es undefined donde no debía, un invariante roto. La regla operativa es: los primeros se atrapan y se devuelven al cliente; los segundos se loguean con todo detalle, se monitorizan y, en muchos casos, fuerzan un crash controlado del proceso, confiando en que el orquestador (PM2, systemd, Kubernetes) lo reinicie limpio. Mezclar ambos es la receta del zombi: un servidor que sigue de pie pero con estado corrupto.

Hallazgos

  1. Una clase abstracta bien diseñada vale por trescientos if. El compilador te recuerda que cualquier nueva clase debe declarar su statusCode y su code. Eso elimina por construcción una clase entera de bugs.
  2. El try/catch defensivo es ruido. Salvo cuando se quiere transformar un error (envolver un fallo de base de datos en DatabaseError, por ejemplo), la captura local sólo oculta intención.
  3. El requestId es probablemente la mejor inversión de tiempo de toda la observabilidad. Diez líneas de código que ahorran horas de investigación por incidente.
  4. El barrel errors/index.ts es documentación viva. Mirar ese archivo cuenta a un recién llegado, en treinta segundos, qué clase de cosas pueden ir mal en este dominio.