Error Handling Without Fear
How to represent, propagate, and respond to errors without panic. Errors are ordinary data and ordinary branches. Treat them that way and the code gets simpler.
Defensive try/catch everywhere is a symptom of not trusting the shape of your own code. An error is not a mystery. It is a value your code produced, at a place you can point to, for a reason you can name.
The goal is not to catch everything as close as possible. The goal is to model expected failures clearly and let each error travel to the layer that can decide what to do.
Errors Are Data #
- An error can be returned, passed, inspected, matched, logged, and tested.
- Expected failures belong in the contract: validation failed, not found, offline, payment declined.
- Unexpected failures are bugs or broken assumptions. Surface them loudly enough that the team fixes them.
Errors as Values vs. Exceptions #
- Exceptions — throw/try/catch; implicit control flow, easy to forget.
- Errors as values — return a
Result/Either; explicit, forces handling.
- Use explicit values for expected failures. Reserve exceptions for bugs, impossible states, or integration points where the language or framework expects them.
Modeling Errors #
- Distinguish expected errors (validation, not-found) from bugs (nulls, invariants).
- Typed / tagged errors vs. stringly-typed errors.
- Error hierarchies and when they become a liability.
A checkout service might return domain outcomes such as CartInvalid, CheckoutUnavailable, PaymentDeclined, PaymentPending, or FulfillmentDelayed. Those are more useful than a generic Error because each one points to a different response.
Propagation #
- Bubble up to a layer that can actually decide.
- Wrap and annotate — add context as an error travels (
while loading user: ...).
- Avoid swallowing errors; avoid double-logging.
- If you can't say what a
catch block is catching, remove it or narrow it.
Boundaries #
- Validate at the edges; trust the core.
- Translate low-level errors into domain errors at each boundary.
- One place per layer that decides: retry, fallback, or fail.
- Where you draw those boundaries is a design decision in its own right — see Boundaries & Transactions.
Recovering vs. Failing Fast #
- Fail fast for programmer errors — crash loudly, fix the bug.
- Recover for operational errors — degrade, retry, or fall back.
- The danger of catch-all handlers that hide both.
Confidence comes from knowing which kind of failure you are handling. A failed schema validation should not enter the retry path. A temporary 503 from a dependency should not be treated like a bug in your domain model.
Security on the Error Path #
The error path is often the least-reviewed path, which makes it a security and privacy surface.
- Fail closed on auth and permission errors. A broken security check should block the action, not allow it.
- Do not put secrets, tokens, full card numbers, internal hostnames, stack traces, or queries in user-facing errors.
- Redact logs by default. Logs are useful, but they are also data exposure.
- Avoid account enumeration: "wrong email or password" is safer than revealing which account exists.
Front End vs. Back End Notes #
- Front end: represent async work as an explicit
loading | error | success value; render the error branch like any other state, not as a panic.
- Back end: return typed errors from your functions; reserve exceptions for truly unexpected bugs, and let a single top-level handler turn them into a clean 500 plus a log line.
Related chapters: Boundaries & Transactions · Do or Do Not. Then Retry. · Writing Insanely Great Error Messages