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();
}
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
}
}
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);
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);
}
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}`);
}
}
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');
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
});
In Node.js:
process.on('uncaughtException', err => {
console.error('Uncaught exception', err);
// cleanup and exit or restart
process.exit(1);
});
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
AppErrorclass with properties likecode,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;
}
}
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)