Effective Error Handling Patterns
Practical error handling patterns that make code more robust — from custom error hierarchies and Result types to error boundaries and retry strategies.
Most error handling code falls into two camps: ignoring errors entirely, or catching Exception and hoping for the best. Both approaches lead to systems that silently corrupt data or crash without useful information. Good error handling is a design decision, not an afterthought.
The Problem with Generic Errors
// ❌ What went wrong? No one knows.
try {
await processOrder(order);
} catch (error) {
console.log('Something went wrong');
}
This catches everything — network failures, validation errors, programming bugs, out-of-memory errors — and treats them all the same. You can’t retry a validation error, and you shouldn’t silently swallow a null pointer exception.
Pattern 1: Custom Error Hierarchies
Create error classes that carry semantic meaning:
// Base application error
class AppError extends Error {
constructor(
message: string,
public readonly code: string,
public readonly statusCode: number = 500,
public readonly isOperational: boolean = true,
) {
super(message);
this.name = this.constructor.name;
Error.captureStackTrace(this, this.constructor);
}
}
// Specific error types
class ValidationError extends AppError {
constructor(
message: string,
public readonly field?: string,
) {
super(message, 'VALIDATION_ERROR', 400);
}
}
class NotFoundError extends AppError {
constructor(resource: string, id: string) {
super(`${resource} with id "${id}" not found`, 'NOT_FOUND', 404);
}
}
class ConflictError extends AppError {
constructor(message: string) {
super(message, 'CONFLICT', 409);
}
}
class ExternalServiceError extends AppError {
constructor(
service: string,
public readonly originalError: Error,
) {
super(`${service} service failed: ${originalError.message}`, 'EXTERNAL_SERVICE_ERROR', 502);
}
}
Now your error handler can make intelligent decisions:
function handleError(error: unknown): Response {
if (error instanceof ValidationError) {
return { status: 400, body: { message: error.message, field: error.field } };
}
if (error instanceof NotFoundError) {
return { status: 404, body: { message: error.message } };
}
if (error instanceof ExternalServiceError) {
logger.warn('External service failure', { service: error.code, original: error.originalError });
return { status: 502, body: { message: 'Service temporarily unavailable' } };
}
// Unexpected error — this is a bug
logger.error('Unexpected error', { error });
return { status: 500, body: { message: 'Internal server error' } };
}
Pattern 2: The Result Type
Instead of throwing exceptions, return a value that represents success or failure:
type Result<T, E = Error> =
| { ok: true; value: T }
| { ok: false; error: E };
function ok<T>(value: T): Result<T, never> {
return { ok: true, value };
}
function err<E>(error: E): Result<never, E> {
return { ok: false, error };
}
Using the Result type:
type OrderError =
| { type: 'validation'; message: string; field: string }
| { type: 'insufficient_stock'; productId: string; available: number }
| { type: 'payment_failed'; reason: string };
function validateOrder(data: unknown): Result<Order, OrderError> {
if (!data || typeof data !== 'object') {
return err({ type: 'validation', message: 'Invalid data', field: 'root' });
}
const { items, customerId } = data as any;
if (!items?.length) {
return err({ type: 'validation', message: 'Order must have items', field: 'items' });
}
return ok(new Order(customerId, items));
}
async function placeOrder(data: unknown): Promise<Result<Order, OrderError>> {
const orderResult = validateOrder(data);
if (!orderResult.ok) return orderResult;
const stockResult = await checkStock(orderResult.value.items);
if (!stockResult.ok) return stockResult;
const paymentResult = await processPayment(orderResult.value);
if (!paymentResult.ok) return paymentResult;
return ok(orderResult.value);
}
// Usage — the caller is forced to handle both cases
const result = await placeOrder(requestBody);
if (!result.ok) {
switch (result.error.type) {
case 'validation':
return res.status(400).json({ field: result.error.field, message: result.error.message });
case 'insufficient_stock':
return res.status(409).json({ message: `Only ${result.error.available} units available` });
case 'payment_failed':
return res.status(402).json({ message: result.error.reason });
}
}
return res.status(201).json(result.value);
Why Result over exceptions?
- The type system forces you to handle errors
- No hidden control flow (exceptions are invisible
gotos) - Error types are explicit in the function signature
- Easy to compose — chain Results together
Pattern 3: Error Boundaries
Centralize error handling at architectural boundaries:
// Express error boundary middleware
function errorBoundary(
handler: (req: Request, res: Response) => Promise<void>,
): RequestHandler {
return async (req, res, next) => {
try {
await handler(req, res);
} catch (error) {
if (error instanceof AppError && error.isOperational) {
res.status(error.statusCode).json({
error: error.code,
message: error.message,
});
} else {
// Programming error — log and return generic message
logger.error('Unhandled error', {
error,
path: req.path,
method: req.method,
});
res.status(500).json({
error: 'INTERNAL_ERROR',
message: 'An unexpected error occurred',
});
}
}
};
}
// Usage — route handlers don't need try/catch
app.post('/orders', errorBoundary(async (req, res) => {
const order = await orderService.create(req.body);
res.status(201).json(order);
// If orderService.create throws, errorBoundary handles it
}));
The Operational vs. Programming Error Distinction
Operational errors are expected failure modes:
- User sends invalid input →
ValidationError - Database connection drops →
DatabaseError - External API times out →
ExternalServiceError
Programming errors are bugs:
TypeError: Cannot read property of undefinedRangeError: Maximum call stack size exceeded- Accessing an array out of bounds
Operational errors should be handled gracefully. Programming errors should crash loud and be fixed.
Pattern 4: Retry with Backoff
For transient failures (network timeouts, rate limits):
interface RetryConfig {
maxRetries: number;
baseDelayMs: number;
maxDelayMs: number;
retryableErrors: string[];
}
async function withRetry<T>(
fn: () => Promise<T>,
config: RetryConfig,
): Promise<T> {
let lastError: Error | undefined;
for (let attempt = 0; attempt <= config.maxRetries; attempt++) {
try {
return await fn();
} catch (error) {
lastError = error as Error;
const isRetryable = config.retryableErrors.some(
(code) => error instanceof AppError && error.code === code,
);
if (!isRetryable || attempt === config.maxRetries) {
throw error;
}
// Exponential backoff with jitter
const delay = Math.min(
config.baseDelayMs * Math.pow(2, attempt) + Math.random() * 1000,
config.maxDelayMs,
);
console.log(`Retry ${attempt + 1}/${config.maxRetries} after ${delay}ms`);
await new Promise((resolve) => setTimeout(resolve, delay));
}
}
throw lastError;
}
// Usage
const data = await withRetry(
() => fetchFromExternalAPI('/data'),
{
maxRetries: 3,
baseDelayMs: 1000,
maxDelayMs: 10000,
retryableErrors: ['EXTERNAL_SERVICE_ERROR', 'TIMEOUT'],
},
);
Pattern 5: Error Context Enrichment
Add context as errors propagate up the call stack:
class ErrorContext:
"""Wraps errors with additional context without losing the original."""
def __init__(self, message: str, cause: Exception, **metadata):
self.message = message
self.cause = cause
self.metadata = metadata
def __str__(self):
parts = [self.message]
if self.metadata:
parts.append(f"context={self.metadata}")
parts.append(f"caused by: {self.cause}")
return " | ".join(parts)
def process_line_item(item: dict, line_number: int) -> float:
try:
return item["price"] * item["quantity"]
except (KeyError, TypeError) as e:
raise ErrorContext(
"Failed to process line item",
cause=e,
line_number=line_number,
item_id=item.get("id", "unknown"),
) from e
def calculate_invoice(items: list[dict]) -> float:
total = 0.0
for i, item in enumerate(items):
try:
total += process_line_item(item, i)
except ErrorContext as e:
raise ErrorContext(
"Invoice calculation failed",
cause=e,
total_items=len(items),
failed_at=i,
) from e
return total
When something fails, you get the full story:
Invoice calculation failed | context={'total_items': 5, 'failed_at': 2}
caused by: Failed to process line item | context={'line_number': 2, 'item_id': 'SKU-99'}
caused by: KeyError('price')
Anti-Patterns to Avoid
1. Catch and Ignore
// ❌ Never do this
try { await riskyOperation(); } catch {}
2. Catch Everything
# ❌ Catches SystemExit, KeyboardInterrupt, MemoryError...
try:
do_something()
except Exception:
pass
3. Using Errors for Flow Control
// ❌ Exceptions are not if/else
function findUser(id: string): User {
try {
return db.getUser(id);
} catch {
return createDefaultUser(id); // Don't use exceptions for expected cases
}
}
// ✅ Use explicit checks
function findUser(id: string): User {
const user = db.getUser(id);
return user ?? createDefaultUser(id);
}
4. Logging and Rethrowing Without Context
// ❌ Duplicates log entries, loses context
try {
await process();
} catch (error) {
logger.error(error); // Logged here
throw error; // And logged again in the error boundary
}
Key Takeaways
- Create a custom error hierarchy — semantic errors enable intelligent handling
- Consider Result types for business logic — make error paths explicit
- Use error boundaries at architectural seams — don’t scatter try/catch everywhere
- Distinguish operational from programming errors — handle the first, fix the second
- Retry transient failures with exponential backoff and jitter
- Enrich errors with context as they propagate — debugging is 10x easier
- Never silently swallow errors — if you catch it, deal with it properly