Start With the Problem
Error handling is one of those things that seems trivial until it bites you. Early in my career, I wrote code that checked for errors at every step but handled them inconsistently. Some functions returned null, others threw exceptions, and a few just logged and continued. The result? A debugging nightmare where the real issue was buried under layers of defensive checks.
After years of maintaining and building systems, I've settled on a few patterns that make error handling predictable and scalable. Here's what works.
Pattern 1: Fail Fast, But Fail Loudly
The worst errors are silent ones. A function that returns undefined when something goes wrong often leads to a cryptic error later, far from the actual cause. Instead, validate inputs early and throw descriptive exceptions immediately.
function getUser(id) {
if (!id || typeof id !== 'string') {
throw new Error(`Invalid user ID: ${id}`);
}
// ... rest of logic
}
This pattern forces the caller to handle the error at the point of failure, not five stack frames down. It also makes your function's contract explicit: if you pass bad data, you get an immediate, clear signal.
Pattern 2: Use Result Objects for Expected Failures
Not all errors are exceptional. Network requests can fail, files might not exist, and user input can be invalid. For these expected failures, exceptions are overkill. They interrupt control flow and make the happy path hard to follow.
Instead, use a result object that explicitly carries either a value or an error.
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 (err) {
return { ok: false, error: err as Error };
}
}
The caller must check .ok before using the value. This makes the potential failure visible in the type signature and prevents accidental unhandled errors.
Pattern 3: Centralized Error Handling in Async Flows
In async code, errors can slip through if you don't catch them everywhere. Instead of wrapping every await in a try/catch, centralize error handling at the boundary of your application or service.
async function handleRequest(req, res) {
try {
const data = await fetchData(req.params.id);
res.json(data);
} catch (err) {
// Log with context, then send a generic response
console.error('Request failed:', req.path, err);
res.status(500).json({ message: 'Internal server error' });
}
}
If you're using Express, you can even use an async wrapper to avoid repeating try/catch in every route.
const asyncHandler = (fn) => (req, res, next) => {
Promise.resolve(fn(req, res, next)).catch(next);
};
app.get('/user/:id', asyncHandler(async (req, res) => {
const user = await getUser(req.params.id);
res.json(user);
}));
Then a single error-handling middleware takes care of logging and responding consistently.
Pattern 4: Preserve Error Context
When you catch an error and rethrow a new one, don't lose the original stack trace. Wrap it properly.
try {
await db.query(...);
} catch (err) {
throw new Error(`Failed to query database for user ${userId}`, { cause: err });
}
Modern JavaScript supports the cause option, which preserves the original error. This gives you both a high-level description and the low-level details when debugging.
Pattern 5: Don't Swallow Errors Silently
Empty catch blocks are a code smell. If you catch an error, do something with it: log it, rethrow it, or convert it to a meaningful response. Swallowing errors hides bugs and makes systems appear healthy when they're not.
// Bad
catch (err) {}
// Better
catch (err) {
logger.warn('Non-critical operation failed', { error: err.message });
// continue with fallback
}
Putting It All Together
A pragmatic approach is to classify errors:
- Programmer errors: bugs like null dereferences. Fail fast, crash the process, fix the code.
- Operational errors: things like network timeouts. Use result objects or retries.
- User input errors: validation failures. Return early with a clear message.
By separating these, you avoid over-engineering while keeping your code resilient. Start with one or two of these patterns in your next project and see how much easier debugging becomes.
Error handling isn't glamorous, but it's the difference between a system that's a joy to maintain and one that keeps you up at night. Choose patterns that make failure visible, explicit, and easy to trace.
Top comments (0)