Error Handling: Exceptions, Result Types & What They Cost
How errors travel and whether the type system forces you to deal with them — checked exceptions, Result and Either, Go's explicit err — plus the distinction between recoverable errors and bugs, the real cost of throwing, and the anti-patterns that turn a failure into silence.
Two independent questions decide an error-handling design: how does the failure travel — as a return value, as an unwinding exception, as a side channel — and can the caller ignore it. Languages mix and match, and most of the pain in real codebases comes from a third thing neither axis captures: conflating expected outcomes with bugs, and handling both with the same machinery.
Errors versus bugs #
Rust encodes this in the type system: Result<T, E> for errors, panic! for bugs. Go separates error returns from panic. Java nominally does the same with Exception versus Error/RuntimeException, but the convention is weak and routinely ignored — which is why catch (Exception e) around a block that can throw NullPointerException turns a bug into a silently degraded result.
The practical consequence: do not make bugs recoverable. A catch-all that swallows a programming error keeps a corrupted process alive and moves the eventual failure somewhere unrelated, which is strictly worse than crashing at the point of the mistake.
How errors travel #
| Unchecked exceptions | Checked exceptions | Result / Either | Error return (Go) | |
|---|---|---|---|---|
| Visible in the signature | no | yes | yes | yes |
| Compiler forces handling | no | yes | yes (must destructure) | no — can assign to _ |
| Cost on the happy path | zero | zero | a tag word | a second return |
| Cost on the failure path | stack capture + unwind | same | a branch | a branch |
| Propagation boilerplate | none | declare or wrap at every level | ? operator | 3 lines per call |
| Composes with generics/async | poorly across thread boundaries | badly — lambdas cannot throw | it is a value | it is a value |
Checked exceptions: an experiment with a result #
Java is the only mainstream language that made the compiler enforce declared exceptions, and the verdict after twenty-five years is close to unanimous — C#, Kotlin, and Scala all looked at it and declined. Hejlsberg's stated reasons were versioning (adding a throws to a published method breaks every caller) and scaling (the declarations pile up through layers until someone writes throws Exception, which erases the information). Lambdas made it worse: Function<T,R> cannot throw, so every checked exception inside a stream has to be wrapped.
What survives is the idea: failures should be visible in the signature. Result achieves that without the versioning problem, because a new error variant is a change to a data type, not to every caller's signature.
The ? operator is the whole ergonomic difference #
fn load(path: &Path) -> Result<Config, ConfigError> {
let text = fs::read_to_string(path)?; // propagates, converting the error type
let cfg: Config = toml::from_str(&text)?;
cfg.validate()?;
Ok(cfg)
}Explicit propagation with none of Go's three-line ceremony. Go's version is more verbose on purpose — the argument is that seeing every error site makes it harder to skip one — and the counter-evidence is that if err != nil { return err } without added context produces error messages that name no location at all.
Context, and how it gets lost #
An error is useful in proportion to what it tells the person reading the log at 3am. Two mechanisms:
if err := db.Insert(ctx, row); err != nil {
return fmt.Errorf("saving order %s: %w", id, err) // %w preserves the chain
}
// later:
if errors.Is(err, sql.ErrNoRows) { ... }
var dup *DuplicateKeyError
if errors.As(err, &dup) { ... }%w wraps and keeps the cause inspectable; %v formats it into a string and throws the chain away. In Java the equivalent is the two-argument constructor — throw new StorageException("saving order " + id, e) — and dropping e is the single most common way a stack trace disappears.
The cost, when it matters #
On the JVM, throwing is not the expensive part — capturing the stack trace is, and it is proportional to stack depth, typically microseconds for a deep stack versus nanoseconds for a branch. Two consequences:
- Exceptions for control flow — a parse loop that throws once per bad line, a "not found" that throws per lookup — can dominate a profile. Return an
Optional, aResult, or atryParse, and reserve throwing for the exceptional. - If a specific exception type is genuinely part of a hot, expected path, overriding
fillInStackTrace()to returnthismakes it nearly free — at the cost of the trace. Do that only for a type whose meaning is self-evident from where it was caught.
Failure at the boundaries #
A distinction that matters as soon as a network is involved: is the failure transient (timeout, 503, deadlock victim) or permanent (400, constraint violation, malformed input)? Retrying a permanent error wastes work and can amplify an outage; not retrying a transient one loses data that would have succeeded. Errors crossing a service boundary should carry that classification explicitly, and the retry decision belongs with it — together with idempotency, because a retried non-idempotent operation is how double charges happen.
Sources
- docsRust Book — Error Handling: to panic! or not to panic!
- docsGo blog — Working with Errors in Go 1.13 (wrapping, errors.Is/As)
- docsJava HotSpot — OmitStackTraceInFastThrow and implicit exception optimization
- articleAnders Hejlsberg — The Trouble with Checked Exceptions (Artima, 2003)
- bookEffective Java, 3rd Edition