Why Your Error Handling Is Wrong

Stop catching all errors blindly. Discover patterns for effective and maintainable error handling.

Error handling is one of those things that seems simple but can make or break your application. Here's why most error handling approaches fail, and how to do it right.

Common Anti-Patterns

1. The Catch-All Approach

// ❌ BAD: Generic error handling
try {
  await processOrder(order);
} catch (error) {
  // What kind of error is it?
  // What should we do about it?
  console.error('Error processing order:', error);
  throw error;
}

2. String-Based Errors

// ❌ BAD: Non-structured errors
class PaymentService {
  async charge(amount: number): Promise<void> {
    if (amount <= 0) {
      throw 'Invalid amount'; // String errors are bad
    }
    if (!this.stripeKey) {
      throw 'Missing API key'; // No error code or structure
    }
  }
}

3. Lost Stack Traces

// ❌ BAD: Losing error context
async function handleRequest(req: Request) {
  try {
    await processRequest(req);
  } catch (error) {
    // Original stack trace is lost
    throw new Error('Failed to process request');
  }
}

Better Approaches

1. Domain-Specific Errors

// ✅ BETTER: Structured error hierarchy
abstract class DomainError extends Error {
  constructor(
    message: string,
    public readonly code: string,
    public readonly status: number,
    public readonly context?: Record<string, any>
  ) {
    super(message);
    this.name = this.constructor.name;
  }

  toJSON() {
    return {
      code: this.code,
      message: this.message,
      context: this.context
    };
  }
}

class PaymentError extends DomainError {
  constructor(
    message: string,
    context?: Record<string, any>
  ) {
    super(message, 'PAYMENT_ERROR', 400, context);
  }
}

class InsufficientFundsError extends PaymentError {
  constructor(available: number, required: number) {
    super('Insufficient funds', {
      available,
      required,
      missing: required - available
    });
  }
}

2. Result Type Pattern

// ✅ BETTER: Explicit error handling
interface Result<T, E = Error> {
  success: boolean;
  data?: T;
  error?: E;
}

class PaymentProcessor {
  async processPayment(
    amount: number
  ): Promise<Result<Payment, PaymentError>> {
    try {
      const payment = await this.stripe.charge(amount);
      return { success: true, data: payment };
    } catch (error) {
      if (error.code === 'card_declined') {
        return {
          success: false,
          error: new PaymentDeclinedError(error.message)
        };
      }
      return {
        success: false,
        error: new PaymentError('Payment processing failed')
      };
    }
  }
}

// Usage
async function handlePayment(amount: number) {
  const result = await paymentProcessor.processPayment(amount);
  
  if (!result.success) {
    if (result.error instanceof PaymentDeclinedError) {
      return { status: 'retry', reason: result.error.message };
    }
    throw result.error;
  }
  
  return { status: 'success', payment: result.data };
}

3. Error Chains

// ✅ BETTER: Preserving error context
class ApplicationError extends Error {
  constructor(
    message: string,
    public readonly cause?: Error
  ) {
    super(message);
    
    // Preserve original stack if possible
    if (cause?.stack) {
      this.stack = `${this.stack}\nCaused by: ${cause.stack}`;
    }
  }

  static wrap(
    error: unknown,
    message: string
  ): ApplicationError {
    if (error instanceof ApplicationError) {
      return error;
    }
    return new ApplicationError(
      message,
      error instanceof Error ? error : undefined
    );
  }
}

async function processOrder(order: Order) {
  try {
    await validateOrder(order);
  } catch (error) {
    throw ApplicationError.wrap(
      error,
      `Failed to validate order ${order.id}`
    );
  }
}

Advanced Patterns

1. Error Recovery

// ✅ BETTER: Graceful degradation
class ResilientService {
  async process(data: Input): Promise<Result> {
    try {
      return await this.normalProcess(data);
    } catch (error) {
      if (this.canRecover(error)) {
        return await this.fallbackProcess(data);
      }
      if (this.canDegrade(error)) {
        return this.degradedResponse(data);
      }
      throw error;
    }
  }

  private canRecover(error: Error): boolean {
    return error instanceof TemporaryError ||
           error instanceof NetworkError;
  }

  private canDegrade(error: Error): boolean {
    return error instanceof ResourceUnavailableError;
  }
}

2. Retry Handling

// ✅ BETTER: Smart retries
class RetryableOperation<T> {
  constructor(
    private operation: () => Promise<T>,
    private options: RetryOptions
  ) {}

  async execute(): Promise<T> {
    let lastError: Error;
    
    for (let attempt = 1; attempt <= this.options.maxAttempts; attempt++) {
      try {
        return await this.operation();
      } catch (error) {
        lastError = error;
        
        if (!this.shouldRetry(error)) {
          throw error;
        }
        
        await this.delay(attempt);
      }
    }
    
    throw new RetryError(
      `Failed after ${this.options.maxAttempts} attempts`,
      lastError
    );
  }

  private shouldRetry(error: Error): boolean {
    return error instanceof NetworkError ||
           error instanceof RateLimitError ||
           error instanceof DatabaseTimeout;
  }

  private delay(attempt: number): Promise<void> {
    const ms = Math.min(
      this.options.baseDelay * Math.pow(2, attempt - 1),
      this.options.maxDelay
    );
    return new Promise(resolve => setTimeout(resolve, ms));
  }
}

3. Error Monitoring

// ✅ BETTER: Structured error tracking
class ErrorMonitor {
  private static instance: ErrorMonitor;

  private constructor(
    private readonly sentryClient: Sentry,
    private readonly logger: Logger
  ) {}

  static getInstance(): ErrorMonitor {
    if (!ErrorMonitor.instance) {
      ErrorMonitor.instance = new ErrorMonitor(
        new Sentry(SENTRY_DSN),
        new Logger()
      );
    }
    return ErrorMonitor.instance;
  }

  captureError(error: Error, context?: Record<string, any>) {
    // Log locally
    this.logger.error(error, context);

    // Track in Sentry
    this.sentryClient.captureException(error, {
      extra: context,
      tags: this.extractTags(error)
    });

    // Track metrics
    metrics.incrementCounter(
      'application_error',
      { type: error.constructor.name }
    );
  }

  private extractTags(error: Error): Record<string, string> {
    if (error instanceof DomainError) {
      return {
        errorType: error.constructor.name,
        errorCode: error.code,
        status: error.status.toString()
      };
    }
    return {
      errorType: 'UnknownError'
    };
  }
}

Best Practices

1. API Error Responses

// ✅ BETTER: Consistent error responses
interface ApiError {
  code: string;
  message: string;
  details?: Record<string, any>;
  traceId?: string;
}

class ErrorHandler {
  handleError(error: unknown, req: Request): ApiError {
    const traceId = req.headers['x-trace-id'];

    if (error instanceof DomainError) {
      return {
        code: error.code,
        message: error.message,
        details: error.context,
        traceId
      };
    }

    // Don't leak internal errors in production
    if (process.env.NODE_ENV === 'production') {
      return {
        code: 'INTERNAL_ERROR',
        message: 'An unexpected error occurred',
        traceId
      };
    }

    return {
      code: 'INTERNAL_ERROR',
      message: error instanceof Error ? error.message : 'Unknown error',
      traceId
    };
  }
}

Conclusion

For better error handling:

  • Create domain-specific error types
  • Use Result types for expected failures
  • Preserve error context and stack traces
  • Implement proper recovery strategies
  • Monitor and track errors effectively

Remember: Good error handling is about making failures manageable, not preventing them entirely.