DEV Community

Code Atlas
Code Atlas

Posted on

Error Handling Patterns That Actually Scale

Start with the fundamentals

Every codebase eventually faces the question: how do we handle errors consistently? The answer isn't a silver bullet, but a set of patterns that fit different contexts. I've seen teams over-engineer this, and I've seen teams ignore it until production screams. Here's what works.

The classic: try/catch

JavaScript's try/catch is the baseline. It's imperative, local, and easy to reason about for small blocks.

try {
  const data = JSON.parse(raw);
  process(data);
} catch (err) {
  console.error('Parsing failed', err);
  fallback();
}
Enter fullscreen mode Exit fullscreen mode

But it has a problem: it catches everything in the block, including bugs you didn't anticipate. Mixing expected failures (like invalid input) with unexpected ones (like a typo in your code) makes debugging harder. A common improvement is to rethrow unexpected errors:

try {
  riskyOperation();
} catch (err) {
  if (err instanceof ValidationError) {
    handleValidation(err);
  } else {
    throw err; // let it bubble up
  }
}
Enter fullscreen mode Exit fullscreen mode

That's better, but it gets verbose fast. Which leads to the next pattern.

Result objects: no surprises

Instead of throwing, return a value that explicitly represents success or failure. This is common in Go, Rust, and increasingly in TypeScript with discriminated unions.

type Result<T> = { ok: true; value: T } | { ok: false; error: Error };

function parseJson(raw: string): Result<unknown> {
  try {
    return { ok: true, value: JSON.parse(raw) };
  } catch (e) {
    return { ok: false, error: e as Error };
  }
}

const result = parseJson(input);
if (!result.ok) {
  console.error(result.error);
  return;
}
console.log(result.value);
Enter fullscreen mode Exit fullscreen mode

The caller must check ok before using value. That's a feature: errors become part of the function's contract. No hidden throw, no forgotten catch. The downside is verbosity, but you can mitigate with helper functions or a library like neverthrow (well-known, but you can roll your own).

The Either monad: functional flavor

If you're comfortable with functional programming, Either is a common abstraction. It's like a result object but with more combinators.

// Simplified Either
type Either<L, R> = { kind: 'left'; left: L } | { kind: 'right'; right: R };

function divide(a: number, b: number): Either<string, number> {
  if (b === 0) return { kind: 'left', left: 'Division by zero' };
  return { kind: 'right', right: a / b };
}

const outcome = divide(10, 0);
if (outcome.kind === 'left') {
  console.error(outcome.left);
} else {
  console.log(outcome.right);
}
Enter fullscreen mode Exit fullscreen mode

This pattern shines in pipelines where you chain operations and want to short-circuit on the first error without nested ifs. But it requires discipline and can be overkill for small apps.

Async errors: the promise way

For asynchronous code, async/await with try/catch is standard. However, a common mistake is wrapping every await in its own try/catch, leading to deeply nested code. Instead, group related operations and handle errors at the boundary.

async function fetchUser(id) {
  try {
    const response = await fetch(`/users/${id}`);
    if (!response.ok) throw new Error(`HTTP ${response.status}`);
    return await response.json();
  } catch (err) {
    // Log and rethrow a domain-specific error
    throw new Error(`Failed to fetch user ${id}: ${err.message}`);
  }
}
Enter fullscreen mode Exit fullscreen mode

For multiple independent async calls, Promise.allSettled is your friend. It doesn't throw on the first rejection; it returns results for all promises.

const results = await Promise.allSettled([fetchA(), fetchB(), fetchC()]);
const successful = results.filter(r => r.status === 'fulfilled');
const failed = results.filter(r => r.status === 'rejected');
Enter fullscreen mode Exit fullscreen mode

Global error handling

No matter how careful you are, some errors slip through. At the application level, set up global handlers to log and recover gracefully.

In browsers:

window.addEventListener('unhandledrejection', event => {
  console.error('Unhandled promise rejection', event.reason);
  // optionally show a user-friendly message
});
Enter fullscreen mode Exit fullscreen mode

In Node.js:

process.on('uncaughtException', err => {
  console.error('Uncaught exception', err);
  // cleanup and exit or restart
  process.exit(1);
});
Enter fullscreen mode Exit fullscreen mode

But be careful: uncaughtException should be a last resort. It can leave your app in an inconsistent state. Prefer handling errors as close to the source as possible.

Practical advice

  • Be explicit: Prefer result objects or discriminated unions for expected failures (validation, network errors). Use exceptions for truly unexpected bugs.
  • Don't swallow errors: An empty catch {} is a code smell. At least log.
  • Wrap third-party errors: Convert library errors into your own domain errors to keep your codebase decoupled.
  • Use a consistent error type: Create a base AppError class with properties like code, message, details. This makes error handling uniform across your app.
class AppError extends Error {
  constructor(code, message, details = {}) {
    super(message);
    this.code = code;
    this.details = details;
  }
}
Enter fullscreen mode Exit fullscreen mode

Conclusion

There's no one-size-fits-all. Start with try/catch for simple cases, adopt result objects for functions where failure is a normal outcome, and use Promise.allSettled for parallel async work. The key is consistency. Pick a pattern for a given context and stick to it. Your future self (and your team) will thank you.

For deeper reading, the MDN guide on error handling is a solid reference.

Top comments (0)