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 exceptionsChecked exceptionsResult / EitherError return (Go)
Visible in the signaturenoyesyesyes
Compiler forces handlingnoyesyes (must destructure)no — can assign to _
Cost on the happy pathzerozeroa tag worda second return
Cost on the failure pathstack capture + unwindsamea brancha branch
Propagation boilerplatenonedeclare or wrap at every level? operator3 lines per call
Composes with generics/asyncpoorly across thread boundariesbadly — lambdas cannot throwit is a valueit 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 #

rust
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:

go
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, a Result, or a tryParse, and reserve throwing for the exceptional.
  • If a specific exception type is genuinely part of a hot, expected path, overriding fillInStackTrace() to return this makes 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

  1. docsRust Book — Error Handling: to panic! or not to panic!
  2. docsGo blog — Working with Errors in Go 1.13 (wrapping, errors.Is/As)
  3. docsJava HotSpot — OmitStackTraceInFastThrow and implicit exception optimization
  4. articleAnders Hejlsberg — The Trouble with Checked Exceptions (Artima, 2003)
  5. bookEffective Java, 3rd Edition

Searches titles, aliases, headings, summaries, and full text.↑↓ move ↵ open esc close