Start With the End in Mind
Every error handling pattern I've seen fail has one thing in common: it was bolted on after the happy path was done. When you write try/catch as an afterthought, you end up with inconsistent handling, swallowed exceptions, and debugging sessions that make you question your career.
Instead, decide your error strategy before you write the first function. That doesn't mean planning every edge case upfront, but it means agreeing on the shape of errors, who handles them, and how they surface.
The Three Layers of Error Handling
I mentally split error handling into three layers: detection, propagation, and presentation. Each layer has its own job, and mixing them is where things get messy.
Detection is where the error happens. It's the throw or the return of a failure. Propagation is how the error travels up the call stack, either through exceptions, result objects, or callbacks. Presentation is what the user sees or what the log records.
Most codebases fail because they try to do all three in the same place. A function that catches, logs, formats, and rethrows is doing too much.
Exceptions vs. Result Objects
The classic debate: should you throw or return a result? Both work, but they have different trade-offs.
Exceptions are great for truly exceptional conditions: network drops, disk full, programmer bugs. They let you skip straight to an error handler without threading checks through every function. But they're terrible for expected failures like validation errors, because they force callers to remember to catch them.
Result objects (like Either, Result, or a simple { ok, value, error }) make expected failures explicit. The type system reminds you that a function can fail. The downside is verbosity, especially in languages without pattern matching.
My rule of thumb: throw for what you can't prevent, return for what you can. If a user submits a bad email, that's a return. If the database goes down, that's a throw.
The Return Pattern That Works
Here's a simple result pattern in TypeScript that I've used in production:
type Result<T> = { ok: true; value: T } | { ok: false; error: Error };
function parseConfig(raw: string): Result<Config> {
try {
const data = JSON.parse(raw);
return { ok: true, value: data };
} catch (e) {
return { ok: false, error: new Error(`Invalid config: ${e.message}`) };
}
}
function loadConfig(filePath: string): Result<Config> {
const raw = readFile(filePath);
if (!raw.ok) return raw;
return parseConfig(raw.value);
}
Notice how loadConfig doesn't catch anything. It just propagates the error. The caller decides what to do:
const config = loadConfig('./app.json');
if (!config.ok) {
console.error(config.error.message);
process.exit(1);
}
This pattern scales because every function returns a consistent shape. You never have to guess whether a function throws or returns null.
Error Wrapping: Add Context, Not Noise
When an error bubbles up, it's tempting to wrap it in a generic message like "Something went wrong." That's noise. Instead, wrap with context that helps debugging: what operation failed, what were the inputs, what was the state.
def fetch_user(user_id: int) -> User:
try:
response = api.get(f"/users/{user_id}")
response.raise_for_status()
return response.json()
except HTTPError as e:
raise UserFetchError(f"Failed to fetch user {user_id}") from e
The from e in Python preserves the original traceback. In JavaScript, you can set the cause property:
try {
await api.get(`/users/${id}`);
} catch (e) {
throw new Error(`Failed to fetch user ${id}`, { cause: e });
}
Now your logs show the chain: UserFetchError -> HTTPError -> original network error. That's gold when you're paging through logs at 2am.
The Catch-All That Kills
A bare catch {} or except: is almost always wrong. It hides bugs and makes your system look healthy when it's not. If you must catch everything, at least log it and rethrow or convert to a known error type.
// Bad: swallows everything
try {
riskyOperation();
} catch {}
// Better: log and rethrow as a known type
import { logger } from './logger';
try {
riskyOperation();
} catch (e) {
logger.error('riskyOperation failed', e);
throw new AppError('riskyOperation failed', { cause: e });
}
Centralize Error Handling at the Boundary
Don't scatter try/catch in every controller or event handler. Instead, have a single place that handles errors for each layer: an Express middleware, a React error boundary, a Kafka consumer wrapper.
In Express, you can have an error-handling middleware:
app.use((err, req, res, next) => {
if (err instanceof ValidationError) {
return res.status(400).json({ error: err.message });
}
logger.error('Unhandled error', err);
res.status(500).json({ error: 'Internal server error' });
});
This way, your business logic can throw or return errors without worrying about HTTP status codes. The boundary maps errors to responses.
Avoid the Pyramid of Doom
Nested try/catch blocks are a code smell. If you find yourself nesting, extract a function or use a pattern like the result object to flatten the flow.
// Bad: nested
try {
const a = tryA();
try {
const b = tryB(a);
} catch (e) { /* handle B */ }
} catch (e) { /* handle A */ }
// Better: separate functions with their own error handling
function doA() { try { return tryA(); } catch (e) { throw new AError(e); } }
function doB(a) { try { return tryB(a); } catch (e) { throw new BError(e); } }
function main() {
const a = doA();
const b = doB(a);
}
Test Your Error Paths
Errors are code paths too. If you don't test them, they will break in production. Write tests that force failures: mock the API to return 500, simulate a full disk, pass invalid input.
def test_fetch_user_handles_http_error(mocker):
mocker.patch('api.get', side_effect=HTTPError('boom'))
with pytest.raises(UserFetchError):
fetch_user(1)
Keep It Simple
There's no one-size-fits-all pattern. But the principles hold: be explicit, add context, centralize, and test. Start with a simple result object for expected failures, use exceptions for the unexpected, and wrap at boundaries. Your future self will thank you when you're debugging a production issue with a clear error chain instead of a silent catch {}.
Top comments (0)