ainError';
this.statusCode = statusCode;
this.isOperational = isOperational;
Error.captureStackTrace(this, this.constructor);
}
}
**Architecture decision:** The `isOperational` flag creates a hard boundary between expected business failures (e.g., missing parameters, resource not found) and unexpected system crashes (e.g., database connection drops, memory leaks). This distinction prevents accidental exposure of internal stack traces to external clients. `Error.captureStackTrace` ensures V8 optimizes stack generation and excludes the constructor from the trace, keeping logs clean.
### Step 2: Wire the Centralized Dispatcher
Express identifies error middleware exclusively by its parameter count. A function with exactly four arguments `(err, req, res, next)` is automatically routed to when an error is thrown or passed downstream. Express checks `fn.length === 4` internally to switch from normal routing to error-handling mode.
```typescript
import { Request, Response, NextFunction } from 'express';
import { DomainError } from '../errors/DomainError';
export const errorHandler = (
err: Error,
_req: Request,
res: Response,
_next: NextFunction
): void => {
const statusCode = (err as DomainError).statusCode || 500;
const isOperational = (err as DomainError).isOperational ?? false;
// Structured logging for observability
console.error(`[API_ERROR] ${err.message}`, {
statusCode,
isOperational,
stack: err.stack,
timestamp: new Date().toISOString()
});
const responsePayload = {
status: 'error',
statusCode,
message: isOperational ? err.message : 'An unexpected error occurred',
...(process.env.NODE_ENV !== 'production' && { stack: err.stack })
};
res.status(statusCode).json(responsePayload);
};
Architecture decision: We default to 500 when statusCode is undefined, ensuring unhandled exceptions never return 200 or undefined. The conditional stack trace injection guarantees developers get debugging context locally while production clients receive sanitized responses. Structured logging inside the handler centralizes observability, making it trivial to integrate with Winston, Pino, or APM tools later.
Step 3: Propagate Failures via next()
Express middleware executes sequentially. To trigger the error dispatcher, you must pass an error object to next(). This bypasses all subsequent route handlers and jumps directly to the error middleware.
import { Router, Request, Response, NextFunction } from 'express';
import { DomainError } from '../errors/DomainError';
const router = Router();
router.get('/v1/inventory/:sku', (req: Request, res: Response, next: NextFunction) => {
const item = inventory.find(i => i.sku === req.params.sku);
if (!item) {
return next(new DomainError('Inventory item not found', 404));
}
res.json(item);
});
Why this works: Calling next(error) signals the Express router to halt normal execution and switch to error-handling mode. This eliminates repetitive if (!data) return res.status(404)... blocks and ensures every failure follows the same response contract.
Step 4: Async Safety & Registration Order
Express does not automatically catch rejected promises. Unhandled async errors will crash the Node process or hang indefinitely. Wrap async route logic to safely forward rejections:
import { Request, Response, NextFunction } from 'express';
export const asyncHandler = (fn: Function) => (req: Request, res: Response, next: NextFunction) => {
Promise.resolve(fn(req, res, next)).catch(next);
};
Finally, register the error handler after all routes and standard middleware. Express processes the middleware stack top-to-bottom. Placing it early will cause it to intercept requests before they reach their intended handlers.
app.use('/v1', router);
app.use(errorHandler); // Must be last
Pitfall Guide
Even experienced developers encounter subtle traps when implementing centralized error handling. Below are the most common failures and their production-grade fixes.
1. The Three-Argument Mirage
Explanation: Writing (req, res, next) instead of (err, req, res, next). Express treats it as standard middleware and never routes errors to it.
Fix: Always use exactly four parameters for error handlers. TypeScript will catch signature mismatches at compile time.
2. Async Promise Black Hole
Explanation: Forgetting to catch rejected promises in async route handlers. Express won't see the error, causing unhandled promise rejections that crash the process.
Fix: Use an asyncHandler wrapper or explicit try/catch blocks that call next(err). Never leave async route logic unwrapped.
3. Parameter Order Inversion
Explanation: Swapping req and err positions. Express matches middleware by parameter position, not name. next will receive the request object, breaking the chain.
Fix: Strictly follow (err, req, res, next). Use IDE linting rules to enforce parameter ordering.
4. Premature Middleware Registration
Explanation: Placing the error handler before route definitions. It will never catch downstream errors because Express hasn't registered the routes yet.
Fix: Register app.use(errorHandler) as the final middleware call before app.listen().
5. Leaking Internal State
Explanation: Returning err.stack or raw error objects in production. Attackers can extract file paths, dependency versions, and internal logic.
Fix: Use environment checks or a dedicated logging service to separate client responses from server logs. Always sanitize isOperational === false errors.
6. Silent next() Calls
Explanation: Invoking next() without arguments inside an error path. This continues normal flow instead of triggering error handling, leading to double-response errors.
Fix: Always pass the error object: next(err). Use ESLint rules to flag bare next() calls in error branches.
7. Ignoring Validation Frameworks
Explanation: Manually checking fields instead of delegating to a validator. Manual checks are error-prone and bypass the centralized handler.
Fix: Integrate zod or joi and convert validation failures into DomainError instances automatically. Example: if (!schema.safeParse(body).success) next(new DomainError('Invalid payload', 400)).
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Small prototype (<10 routes) | Inline res.status().json() | Low overhead, fast iteration | Negligible |
| Mid-scale API (10-50 routes) | Centralized Express error middleware | Enforces consistency, reduces duplication | Low (initial setup) |
| Enterprise microservice | Framework-level error boundaries (NestJS/TSRPC) | Built-in DI, decorators, auto-validation | High (learning curve) |
| Legacy codebase migration | Gradual asyncHandler + middleware adoption | Zero downtime, incremental refactoring | Medium (refactor time) |
Configuration Template
Copy this structure into your project. It provides a complete, type-safe foundation for centralized error handling.
// src/errors/DomainError.ts
export class DomainError extends Error {
public readonly statusCode: number;
public readonly isOperational: boolean;
constructor(message: string, statusCode: number, isOperational = true) {
super(message);
this.name = 'DomainError';
this.statusCode = statusCode;
this.isOperational = isOperational;
Error.captureStackTrace(this, this.constructor);
}
}
// src/middleware/errorHandler.ts
import { Request, Response, NextFunction } from 'express';
import { DomainError } from '../errors/DomainError';
export const errorHandler = (err: Error, _req: Request, res: Response, _next: NextFunction): void => {
const statusCode = (err as DomainError).statusCode || 500;
const isOperational = (err as DomainError).isOperational ?? false;
console.error(`[API_ERROR] ${err.message}`, { statusCode, isOperational, stack: err.stack });
res.status(statusCode).json({
status: 'error',
statusCode,
message: isOperational ? err.message : 'An unexpected error occurred',
...(process.env.NODE_ENV !== 'production' && { stack: err.stack })
});
};
// src/middleware/asyncHandler.ts
import { Request, Response, NextFunction } from 'express';
export const asyncHandler = (fn: Function) => (req: Request, res: Response, next: NextFunction) => {
Promise.resolve(fn(req, res, next)).catch(next);
};
// src/app.ts
import express from 'express';
import { errorHandler } from './middleware/errorHandler';
import { asyncHandler } from './middleware/asyncHandler';
import { DomainError } from './errors/DomainError';
const app = express();
app.use(express.json());
app.get('/api/v1/users/:id', asyncHandler(async (req, res) => {
const user = await findUserById(req.params.id);
if (!user) throw new DomainError('User not found', 404);
res.json(user);
}));
app.use(errorHandler);
export default app;
Quick Start Guide
- Install dependencies:
npm i express @types/express typescript ts-node
- Create
DomainError.ts with statusCode and isOperational properties
- Build
errorHandler.ts with the 4-argument signature and environment-aware response logic
- Wrap all async route handlers with
asyncHandler to forward promise rejections
- Register
app.use(errorHandler) as the final middleware call before app.listen()