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 undefined
  • RangeError: 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

  1. Create a custom error hierarchy — semantic errors enable intelligent handling
  2. Consider Result types for business logic — make error paths explicit
  3. Use error boundaries at architectural seams — don’t scatter try/catch everywhere
  4. Distinguish operational from programming errors — handle the first, fix the second
  5. Retry transient failures with exponential backoff and jitter
  6. Enrich errors with context as they propagate — debugging is 10x easier
  7. Never silently swallow errors — if you catch it, deal with it properly