face ErrorContext {
[key: string]: string | number | boolean | null;
}
export class AppError extends Error {
public readonly statusCode: number;
public readonly code: ErrorCode;
public readonly context?: ErrorContext;
public readonly isOperational: boolean;
constructor(
code: ErrorCode,
message: string,
statusCode: number,
context?: ErrorContext,
cause?: Error
) {
super(message);
this.code = code;
this.statusCode = statusCode;
this.context = context;
this.isOperational = statusCode < 500; // 5xx are usually system errors
this.cause = cause;
// Capture stack trace excluding constructor
Error.captureStackTrace(this, this.constructor);
}
}
### 2. Contextual Enrichment
Errors must carry context to be actionable. When throwing an error, attach relevant metadata (e.g., `userId`, `transactionId`, `resourceId`). This context is logged but sanitized before reaching the client.
```typescript
// src/services/OrderService.ts
import { AppError, ErrorCode } from '../errors/AppError';
export class OrderService {
async processPayment(orderId: string, amount: number) {
try {
const order = await this.repo.findById(orderId);
if (!order) {
throw new AppError(
ErrorCode.RESOURCE_NOT_FOUND,
'Order not found.',
404,
{ orderId }
);
}
if (order.balance < amount) {
throw new AppError(
ErrorCode.INSUFFICIENT_BALANCE,
'Insufficient funds for transaction.',
402,
{ orderId, requestedAmount: amount, currentBalance: order.balance }
);
}
// ... payment logic
} catch (error) {
if (error instanceof AppError) throw error;
// Wrap unknown errors to maintain domain contract
throw new AppError(
ErrorCode.INTERNAL_SERVER_ERROR,
'Failed to process payment.',
500,
{ orderId },
error instanceof Error ? error : undefined
);
}
}
}
3. Centralized Error Middleware
The middleware acts as the sanitization boundary. It maps domain errors to HTTP responses, logs structured data, and ensures unknown errors are masked.
// src/middleware/errorHandler.ts
import { Request, Response, NextFunction } from 'express';
import { AppError, ErrorCode } from '../errors/AppError';
import { logger } from '../utils/logger'; // Assume pino/winston setup
export const errorHandler = (
err: Error,
req: Request,
res: Response,
next: NextFunction
) => {
// 1. Identify AppError
if (err instanceof AppError) {
// Log with context for ops
logger.error({
err,
code: err.code,
context: err.context,
correlationId: req.headers['x-correlation-id'],
}, err.message);
// Respond with sanitized payload
res.status(err.statusCode).json({
error: {
code: err.code,
message: err.message,
// Never expose context or stack to client
},
});
return;
}
// 2. Handle Validation Errors (e.g., Zod)
if (err.name === 'ZodError') {
logger.warn({ err, correlationId: req.headers['x-correlation-id'] });
res.status(400).json({
error: {
code: ErrorCode.VALIDATION_ERROR,
message: 'Validation failed.',
details: err.issues, // Safe to expose validation details
},
});
return;
}
// 3. Fallback for Unknown Errors
logger.error({
err,
correlationId: req.headers['x-correlation-id'],
stack: err.stack,
}, 'Unhandled exception');
res.status(500).json({
error: {
code: ErrorCode.INTERNAL_SERVER_ERROR,
message: 'An unexpected error occurred.',
},
});
};
4. Architecture Decisions
- Exceptions vs. Result Types: We use exceptions for control flow within the service layer but enforce a strict boundary at the API layer. This balances developer ergonomics with safety. Result types (
Result<T, E>) are recommended for critical pure functions, but exceptions reduce boilerplate in I/O-heavy backend paths.
- Sanitization Boundary: The middleware is the only place where errors are transformed into HTTP responses. This guarantees that no service-layer leak can bypass sanitization.
- Correlation IDs: Every error log must include a
correlationId to trace requests across distributed services.
Pitfall Guide
-
Leaking Stack Traces and Internal Details:
- Mistake: Returning
err.stack or database query strings in the response.
- Impact: Attackers can map your infrastructure, identify library versions for CVE exploitation, and understand business logic.
- Fix: The middleware must strip all non-essential fields. Only
code, message, and safe details (like validation errors) reach the client.
-
Swallowing Errors in Catch Blocks:
- Mistake: Empty
catch blocks or logging without re-throwing.
- Impact: Silent failures. The system appears healthy while data is corrupted or operations are incomplete.
- Fix: Always re-throw
AppError or wrap unknown errors. If you catch to add context, re-throw immediately.
-
Using Exceptions for Control Flow:
- Mistake: Throwing exceptions for expected business conditions (e.g., "User not found" during login).
- Impact: Performance degradation due to stack trace generation; obscures the distinction between bugs and expected states.
- Fix: Use exceptions only for exceptional states. For expected branches, use conditional returns or Result types. Reserve
AppError for error states that should halt execution and trigger the error handler.
-
Inconsistent HTTP Status Codes:
- Mistake: Returning
500 for validation errors or 400 for database timeouts.
- Impact: Clients cannot implement reliable retry logic or UI feedback.
- Fix: Map
AppError.statusCode strictly. 4xx for client/input errors, 5xx for system failures. Use specific codes: 404 for missing resources, 409 for conflicts, 422 for semantic validation errors.
-
Ignoring Async Error Propagation:
- Mistake: Forgetting to
await promises or missing .catch() in promise chains.
- Impact: Unhandled Promise Rejections crash the Node.js process or leave requests hanging.
- Fix: Use
async/await consistently. Ensure all route handlers are wrapped or use a wrapper like express-async-errors to forward rejections to the middleware.
-
Missing Error Context in Logs:
- Mistake: Logging only the error message without request metadata.
- Impact: High MTTR. Engineers cannot reproduce the issue without the specific user ID, payload, or timestamp.
- Fix: Enrich logs with
context from AppError and request headers. Use structured logging (JSON) to enable filtering in observability tools.
-
Over-Engineering Error Hierarchies:
- Mistake: Creating hundreds of specific error classes (
UserNotFoundError, PaymentDeclinedError).
- Impact: Maintenance burden; duplication of logic.
- Fix: Prefer a single
AppError class with a discriminated ErrorCode enum. This keeps the error domain flat, serializable, and easier to manage.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| High-Volume Public API | Structured Domain Errors + Strict Sanitization | Clients need stable contracts; security is paramount; observability reduces support costs. | High initial dev cost; Low operational cost. |
| Internal Microservice | Typed Errors + Contextual Logging | Service-to-service calls benefit from machine-readable codes; context aids distributed tracing. | Medium dev cost; Low debug cost. |
| Rapid Prototype / MVP | Generic Error Handler + Basic Logging | Speed is priority; structured patterns add boilerplate. | Low dev cost; High risk of technical debt accumulation. |
| Critical Financial Transaction | Result Types (Result<T, E>) + Audit Logging | Exceptions for control flow are discouraged; explicit error handling ensures auditability. | High dev cost; Zero ambiguity cost. |
Configuration Template
src/errors/error.config.ts
import { ErrorCode } from './AppError';
// Map internal codes to safe client messages
export const ERROR_RESPONSE_MAP: Record<ErrorCode, string> = {
[ErrorCode.VALIDATION_ERROR]: 'Invalid input provided.',
[ErrorCode.INSUFFICIENT_BALANCE]: 'Transaction declined due to insufficient funds.',
[ErrorCode.RESOURCE_NOT_FOUND]: 'The requested resource could not be found.',
[ErrorCode.INTERNAL_SERVER_ERROR]: 'A system error occurred. Please try again later.',
[ErrorCode.DATABASE_CONNECTION_FAILED]: 'Service temporarily unavailable.',
};
// Codes that allow client retry
export const RETRYABLE_CODES = [
ErrorCode.INTERNAL_SERVER_ERROR,
ErrorCode.DATABASE_CONNECTION_FAILED,
];
Usage in Middleware:
const safeMessage = ERROR_RESPONSE_MAP[err.code] || 'An error occurred.';
res.status(err.statusCode).json({
error: { code: err.code, message: safeMessage }
});
Quick Start Guide
- Initialize Error Domain:
Copy
AppError.ts into your project. Define your ErrorCode enum based on your domain needs.
- Add Middleware:
Import
errorHandler.ts and register it as the last middleware in your Express/Fastify app: app.use(errorHandler);.
- Refactor Critical Path:
Identify one high-traffic service method. Replace generic throws with
AppError including context.
- Verify Logging and Response:
Trigger the error. Check that the log contains the context and correlation ID, and the HTTP response contains only the code and safe message.
- Scale:
Apply the pattern across all services. Add integration tests to assert error codes.
Codcompass 2.0: Engineering knowledge that scales.