Skip to content
ExponaBLOG
JAVASCRIPT & TYPESCRIPT · DAY 026 / 100

Error handling patterns: real-world lessons and examples

Errors as part of your design.

Expona Admin10 min read

Most teams I've worked with treat errors as exceptions to be crushed rather than signals to be understood. Error handling patterns are how you design your system to fail gracefully, recover when possible, and surface the truth when you can't—and that design starts long before the first try-catch block. When errors carry meaning through custom classes, preserve context through proper wrapping, and get caught at the right boundaries, your production system becomes debuggable instead of mysterious.

Why error handling is part of your design

I didn't always think about errors strategically. Early in my career, I'd write catch-alls that logged a generic message and moved on. Then I spent three hours tracing a bug in production that turned out to be a database constraint violation hidden inside a meaningless "Operation failed" message. That's when I realized: error handling isn't defensive coding. It's architectural.

When you build e-commerce platforms or trading systems, a failed payment, an inventory mismatch, or a network timeout each needs different handling. The API layer needs to return different status codes. The business logic needs to know whether to retry. The UI needs to show different messages to the user. None of that works if your errors are generic.

Good error handling patterns give every error a job to do: carrying information about what went wrong, why it happened, and what should happen next.

Custom error classes—NotFoundError and ValidationError carry meaning

The foundation is custom error classes. Instead of throwing generic Error objects, I create classes that tell the next handler exactly what kind of problem occurred. The name of the error class itself is semantic information.

typescript
class ValidationError extends Error {
  constructor(public field: string, public message: string) {
    super(`Validation failed on ${field}: ${message}`);
    this.name = 'ValidationError';
    Object.setPrototypeOf(this, ValidationError.prototype);
  }
}

class NotFoundError extends Error {
  constructor(public resource: string, public id: string | number) {
    super(`${resource} with id ${id} not found`);
    this.name = 'NotFoundError';
    Object.setPrototypeOf(this, NotFoundError.prototype);
  }
}

class ConflictError extends Error {
  constructor(public message: string, public conflictingField?: string) {
    super(message);
    this.name = 'ConflictError';
    Object.setPrototypeOf(this, ConflictError.prototype);
  }
}

class ExternalServiceError extends Error {
  constructor(
    public service: string,
    public statusCode: number,
    public originalError?: Error
  ) {
    super(`${service} returned ${statusCode}`);
    this.name = 'ExternalServiceError';
    Object.setPrototypeOf(this, ExternalServiceError.prototype);
  }
}

Now when I catch an error in my handler layer, I can decide what to do based on the actual error type:

typescript
async function createOrder(req: Request, res: Response) {
  try {
    const order = await orderService.create(req.body);
    res.status(201).json(order);
  } catch (e) {
    if (e instanceof ValidationError) {
      res.status(400).json({ 
        error: 'Invalid input', 
        field: e.field, 
        message: e.message 
      });
    } else if (e instanceof ConflictError) {
      res.status(409).json({ error: e.message });
    } else if (e instanceof NotFoundError) {
      res.status(404).json({ error: e.message });
    } else {
      res.status(500).json({ error: 'Internal server error' });
    }
  }
}

Each error class is a contract. When the order service throws NotFoundError, the handler doesn't guess—it knows to return 404. When validation fails, it returns 400 with field details. The business logic stays focused on business rules, and the handler stays focused on HTTP semantics.

In a ride-hailing system I worked on, we had DriverUnavailableError, RideAlreadyAssignedError, and PaymentProcessingError. Each one mapped to a specific user experience: "No drivers available in your area," "Someone else accepted this ride," or "We're processing your payment, try again in 30 seconds." That clarity came entirely from custom error classes.

Error cause—Wrap errors while keeping the original

Here's the trap I fell into repeatedly: catching a low-level error, throwing a new one, and losing the original context. You end up with a stack that starts at your handler and offers zero insight into what actually broke.

The solution is the error cause property, part of the Error constructor since ES2022:

typescript
async function saveOrder(order: Order) {
  try {
    await db.query(
      'INSERT INTO orders (customer_id, total) VALUES ($1, $2)',
      [order.customerId, order.total]
    );
  } catch (e) {
    throw new Error('Saving order failed', { cause: e });
  }
}

async function processPayment(orderId: number) {
  try {
    const response = await paymentGateway.charge(orderId);
    return response;
  } catch (e) {
    throw new ExternalServiceError(
      'PaymentGateway',
      e instanceof Error ? parseInt(e.message) : 500,
      e instanceof Error ? e : undefined
    );
  }
}

When the outer error gets logged, you can access the cause to see the real problem. In a NestJS or Express middleware, I typically wrap the entire request in a try-catch that logs the full chain:

typescript
function errorLoggingMiddleware(
  err: Error,
  req: Request,
  res: Response,
  next: NextFunction
) {
  let current: Error | unknown = err;
  const chain = [];

  while (current instanceof Error) {
    chain.push({
      name: current.name,
      message: current.message,
      stack: current.stack?.split('\n').slice(0, 3).join('\n')
    });
    current = (current as any).cause;
  }

  logger.error({
    path: req.path,
    method: req.method,
    errorChain: chain,
    timestamp: new Date().toISOString()
  });

  if (err instanceof ValidationError) {
    res.status(400).json({ error: err.message, field: err.field });
  } else if (err instanceof NotFoundError) {
    res.status(404).json({ error: err.message });
  } else {
    res.status(500).json({ error: 'Internal server error' });
  }
}

Now when something breaks in production, you see not just "Saving order failed" but the PostgreSQL constraint violation underneath it. The original error is preserved, the new error adds context, and logging captures the full chain. That's the difference between a 30-minute incident and a 3-hour rabbit hole.

Boundaries—Handle errors at API handlers and UI boundaries

One of the hardest lessons I learned is that error handling needs to happen at specific places: the edges of your system. That's where you translate internal errors into external responses.

In a backend, the boundaries are your API route handlers. That's where business logic becomes HTTP. In a React app, boundaries are the components that call APIs and the components that render the results. Trying to handle every error everywhere creates inconsistency.

typescript
// Service layer—throws meaningful errors
class UserService {
  async getById(id: number) {
    const user = await db.query('SELECT * FROM users WHERE id = $1', [id]);
    if (!user) {
      throw new NotFoundError('User', id);
    }
    return user;
  }

  async updateEmail(id: number, email: string) {
    if (!this.isValidEmail(email)) {
      throw new ValidationError('email', 'Invalid email format');
    }

    const existing = await db.query(
      'SELECT * FROM users WHERE email = $1 AND id != $2',
      [email, id]
    );
    if (existing) {
      throw new ConflictError('Email already in use');
    }

    await db.query('UPDATE users SET email = $1 WHERE id = $2', [email, id]);
  }
}

// Handler layer—catches and translates
async function updateUserEmail(req: Request, res: Response) {
  try {
    const user = await userService.updateEmail(
      req.params.id,
      req.body.email
    );
    res.json(user);
  } catch (e) {
    if (e instanceof ValidationError) {
      res.status(400).json({ error: e.message, field: e.field });
    } else if (e instanceof ConflictError) {
      res.status(409).json({ error: e.message });
    } else if (e instanceof NotFoundError) {
      res.status(404).json({ error: e.message });
    } else {
      res.status(500).json({ error: 'Internal server error' });
    }
  }
}

In React, the boundary is the component that fetches and displays:

typescript
function UserProfile({ userId }: { userId: number }) {
  const [user, setUser] = React.useState(null);
  const [error, setError] = React.useState(null);

  React.useEffect(() => {
    async function load() {
      try {
        const response = await fetch(`/api/users/${userId}`);
        if (!response.ok) {
          const errorData = await response.json();
          setError(errorData.error);
          return;
        }
        setUser(await response.json());
      } catch (e) {
        setError('Network error. Please check your connection.');
      }
    }
    load();
  }, [userId]);

  if (error) return <div>{error}</div>;
  if (!user) return <div>Loading...</div>;
  return <div>{user.name}</div>;
}

The service throws custom errors. The handler catches them and returns appropriate status codes. The UI catches HTTP errors and network failures. Each boundary knows its job. Errors don't leak through unprepared layers.

Never swallow—Empty catch blocks hide production bugs

I've seen so many empty catch blocks that I could write a sermon about them. The most dangerous line of code is:

typescript
try {
  await someOperation();
} catch (e) {
  // Ignore
}

That catches everything—network timeouts, database failures, logic errors, out-of-memory exceptions—and pretends nothing happened. Your application moves forward silently broken.

If you're tempted to write an empty catch block, ask yourself: is this error actually okay? If yes, be explicit about it. Name the specific error you expect and handle it consciously. If no, log it and re-throw it:

typescript
// Bad
try {
  await cache.set(key, value);
} catch (e) {
  // Ignore cache failures
}

// Better—explicit about what's okay
try {
  await cache.set(key, value);
} catch (e) {
  // Cache is optional, continue without it
  logger.warn('Cache write failed', { key, error: e });
}

// Also better—re-throw if unexpected
try {
  await cache.set(key, value);
} catch (e) {
  if (e instanceof CacheConnectionError) {
    logger.warn('Cache unavailable, continuing');
  } else {
    throw e;
  }
}

In production systems, I've found that a "swallow but log" approach works for optional operations (cache, analytics, non-critical side effects) but anything core to the flow should fail loudly. A silent failure in a trading system or payment processor is a catastrophe waiting to happen.

Common mistakes

  • Generic error messages—"Something went wrong" tells you nothing. Always include what operation failed and what the specific problem was. Use custom error classes to encode this semantically.
  • Losing the original error—Catch an error, throw a new one without cause, and you've destroyed the debugging trail. Always use the cause property to chain errors.
  • Handling errors in the wrong layer—A service shouldn't decide what HTTP status to return. A React component shouldn't try to recover from database errors. Handle errors where you can take meaningful action.
  • Catching Error instead of specific types—Catching Error will catch programming mistakes (undefined.property) alongside business errors (user not found). Catch specific errors and let others bubble up.
  • Not testing error paths—Happy path tests pass, but error handling never gets exercised. In my last project review, I found that half of error handlers had never been executed in production. Write tests that mock failures and verify the response.

Key takeaways

  • Custom error classes give errors semantic meaning. NotFoundError, ValidationError, ConflictError each tell a story about what went wrong and how to respond.
  • Use the error cause property to chain errors without losing the original context. The full chain is invaluable for debugging.
  • Handle errors at system boundaries: API handlers, fetch calls, database operations. Don't try to handle every error everywhere.
  • Never swallow errors silently. Empty catch blocks hide production bugs. Be explicit about which errors are acceptable and log or re-throw the rest.
  • Error handling is architecture, not afterthought. Design it early, test it deliberately, and treat error paths as part of your contract with the next developer or your future self.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles