Codebase Error Utilities
While knowing HTTP status codes and API error response shapes is important, a production-ready application needs code-level tools to implement error handling cleanly. Without structured patterns, your controllers will be cluttered with repeated try-catch blocks, and unhandled database errors might crash your application or leak secrets.
To achieve clean, robust, and maintainable error handling in Node.js/TypeScript codebases, you need 6 Core Utilities.
The Error Handling Lifecycle
1. Custom Application Error Class (AppError)
The native JavaScript Error class lacks context regarding HTTP environments (like status codes). We need a base custom class that distinguishes between:
- Operational Errors: Expected runtime failures (e.g. validation errors, resource not found, unauthorized access). These are handled gracefully and returned to the client.
- Programming / System Errors: Unexpected bugs (e.g., database connection issues, syntax errors, reading properties of undefined). These must trigger logging, alert alerts, and return a generic 500 error.
Implementation (TypeScript)
export class AppError extends Error {
public readonly statusCode: number;
public readonly status: string;
public readonly isOperational: boolean;
public readonly details: any[];
constructor(message: string, statusCode: number, details: any[] = []) {
super(message);
this.statusCode = statusCode;
this.status = `${statusCode}`.startsWith('4') ? 'fail' : 'error';
this.isOperational = true; // Indicates it is a safe, expected error
this.details = details;
Error.captureStackTrace(this, this.constructor);
}
}
// Derived specialized classes for cleaner controller code:
export class NotFoundError extends AppError {
constructor(message = 'Resource not found') {
super(message, 404);
}
}
export class ValidationError extends AppError {
constructor(message = 'Validation failed', details: any[] = []) {
super(message, 400, details);
}
}2. Centralized Error Handling Middleware
Instead of writing custom response codes inside controllers, all caught errors should be forwarded to a single centralized middleware. This acts as the final catchment net in the request-response cycle.
Implementation
import { Request, Response, NextFunction } from 'express';
import { AppError } from './AppError';
export const globalErrorHandler = (
err: any,
req: Request,
res: Response,
next: NextFunction
) => {
err.statusCode = err.statusCode || 500;
err.status = err.status || 'error';
if (process.env.NODE_ENV === 'development') {
// Detailed output for developers
return res.status(err.statusCode).json({
status: err.status,
error: err,
message: err.message,
stack: err.stack,
});
}
// Production response (Clean, secure)
if (err.isOperational) {
// Safe to expose to the client
return res.status(err.statusCode).json({
status: err.status,
message: err.message,
details: err.details,
});
}
// Programming/System error: Don't leak details to clients
console.error('CRITICAL SYSTEM ERROR đź’Ą:', err); // Send to Winston / Sentry
return res.status(500).json({
status: 'error',
message: 'Something went wrong on our side. Please try again later.',
});
};3. Asynchronous Error Wrapper (catchAsync)
In Express/Node frameworks, errors thrown inside asynchronous functions must be passed to the next handler explicitly (e.g. next(error)). If you forget, the request hangs or crashes.
The catchAsync wrapper removes the need for try-catch blocks in every controller.
Implementation
import { Request, Response, NextFunction } from 'express';
// Wraps an async handler and catches any rejected promise, sending it to next()
export const catchAsync = (fn: Function) => {
return (req: Request, res: Response, next: NextFunction) => {
fn(req, res, next).catch(next);
};
};
// Usage Example:
export const getProduct = catchAsync(async (req: Request, res: Response) => {
const product = await DB.findProduct(req.params.id);
if (!product) {
throw new NotFoundError('Product not found with this ID');
}
res.status(200).json({ status: 'success', data: product });
});4. Safe Execution Helper (safe / goTry)
When dealing with functions that might throw exceptions (like file system access, external fetches, or cryptographic operations), we are forced to nest try-catch blocks, hurting code readability.
A Go-style error utility resolves this by returning a tuple containing [error, result].
Implementation
// Wrapper that intercepts promise resolution/rejection and returns a tuple
export async function safe<T, E = Error>(
promise: Promise<T>
): Promise<[E | null, T | null]> {
try {
const data = await promise;
return [null, data];
} catch (error) {
return [error as E, null];
}
}
// Usage Example:
const [fetchError, response] = await safe(fetch('https://api.thirdparty.com/data'));
if (fetchError) {
// Handle connection issue cleanly without a try-catch block
throw new AppError('Third party service is down', 503);
}5. Schema Validation Utility & Middleware
Validating incoming payloads is the first line of defense. A schema validation helper intercepts requests, uses a library like Zod or Joi to validate the body/query/params, and maps any errors cleanly to our custom ValidationError.
Implementation
import { Schema } from 'zod';
export const validateSchema = (schema: Schema) => {
return catchAsync(async (req: Request, res: Response, next: NextFunction) => {
const result = await schema.safeParseAsync(req.body);
if (!result.success) {
// Map Zod validation errors to clean client structures
const details = result.error.errors.map(err => ({
field: err.path.join('.'),
issue: err.message,
}));
throw new ValidationError('Input validation failed', details);
}
req.body = result.data; // Assign sanitized data
next();
});
};6. Logger Wrapper & Telemetry Integration
A basic console.log(err) or console.error(err) is unacceptable in production because:
- They block the main execution thread (synchronous write in many Node versions).
- They do not support structured JSON logging.
- They don't sync automatically to centralized aggregators (Sentry, Datadog, ELK stack).
A logger wrapper abstracts these integrations.
Implementation (Using Winston / Pino)
import winston from 'winston';
const logger = winston.createLogger({
level: 'info',
format: winston.format.combine(
winston.format.timestamp(),
winston.format.json()
),
transports: [
new winston.transports.File({ filename: 'logs/error.log', level: 'error' }),
new winston.transports.Console({
format: winston.format.combine(
winston.format.colorize(),
winston.format.simple()
)
})
]
});
// Logs errors along with HTTP request correlation IDs for distributed tracing
export const logError = (error: Error, metadata: Record<string, any> = {}) => {
logger.error(error.message, {
stack: error.stack,
timestamp: new Date().toISOString(),
...metadata
});
};Summary Checklist
A robust codebase requires all 6 of these utilities to achieve comprehensive error handling:
| Utility Name | Responsibility | Layer |
|---|---|---|
AppError Class | Classifies error types and sets HTTP status codes. | Domain Core |
| Global Error Middleware | Standardizes outgoing JSON responses and hides stacks. | Controller Boundary |
catchAsync Wrapper | Eliminates manual try-catch boilerplate in routing handlers. | Routing / Controller |
safe Tuple Wrapper | Standardizes local execution, removing nested blocks. | Service / Database |
| Schema Validation | Intercepts malformed client requests before processing. | Ingress / Validation |
| Structured Logger | Safely persists and syncs logs to monitoring services. | Infrastructure |