Error Handling

Keywords

error handling, exceptions, result, option, errors as values, checked exceptions, panic, error propagation, error wrapping, null safety, sentinel errors, recoverable errors

Introduction

The payments team shipped a refactor on a Thursday, and the refunds stopped working on Friday — silently. No alert fired. No error reached the dashboards. Customers who requested a refund got a cheerful confirmation page, and nothing happened. The money stayed put. It took two days and an angry support queue to find the line that did it, and when they did, it was almost insultingly small. Somewhere in the new code path a network call to the ledger service was wrapped in a try block, and the catch clause — added years earlier by someone being defensive — did exactly one thing: catch (e) { logger.debug(e); }. The debug log was off in production. The exception that meant “the refund did not post” was caught, demoted to a log line nobody would ever read, and discarded. The happy path continued as if the call had succeeded. The program had been told, clearly and correctly, that it had failed; a single swallowing catch had thrown that knowledge away.

The mirror image of this incident lives in every Go codebase that has ever shipped a data, _ := readFile(path) — the error assigned to the blank identifier, the failure made invisible by a single underscore. And its cousin lives in every Rust prototype where a deadline-pressured .unwrap() turned a recoverable Err back into a crash. Three languages, three philosophies, one identical mistake: the program knew it had failed, and a human threw that knowledge away. Error handling is not, at heart, about syntax. It is about a single question asked of every fallible operation — what does the language force the caller to do when this fails? — and the answers, across six languages, range from “nothing at all” to “the compiler will not let you ignore it.” This chapter is about those answers, what each one buys and costs, and the discipline that no language can supply for you.

The Core Insight

Strip away the keywords and there are exactly three ways a programming language can model failure, distinguished entirely by who is forced to confront it.

  1. Exceptions transport a failure from where it happens to wherever someone remembered to catch it, unwinding the call stack on the way. The error path is the path the code does not mention — it is invisible in the happy-path source, which is both the appeal (clean code) and the danger (a failure you forgot can sail to the top, or be swallowed by a careless catch). Python, Java, C++, and JavaScript all live here.
  2. Errors as values return the failure alongside the result, as an ordinary value you handle right where it occurs. The error path is written out in full, sitting in the source next to the success path, impossible to overlook — and verbose on purpose. Go’s (result, err) and C’s return codes are this model. The compiler almost forces you to look; in Go a blank _ is the escape hatch.
  3. Algebraic Result/Option types encode the failure in the return type itself, so the success value is locked behind the error case: you cannot get at it without first acknowledging that the error might be there. The compiler refuses to let you ignore it. Rust is the pure expression; TypeScript’s discriminated unions and Python’s Optional reach toward it.

The deep point is that these are not three syntaxes for the same thing. They differ in what the type system makes you confront, and that difference is the whole story. Exceptions ask the caller for nothing. Returned values ask politely and can be brushed off. A Result asks at compile time and will not be brushed off. Everything else — wrapping, sentinels, the ? operator, checked exceptions, panic — is a refinement of how a language answers that one question.

A mental model

Picture a failure as a hot potato handed up a line of people, each one a stack frame. The three philosophies are three rules for what happens when someone is handed the potato.

Under exceptions, the potato is thrown, not handed. It flies over everyone’s heads until someone with a catcher’s mitt (catch) reaches up and grabs it. The people it flew past never knew it existed — clean hands, clean code — but if nobody brought a mitt, the potato sails out the back of the room and the building burns down (an uncaught exception crashes the process). Worse, anyone can catch it and quietly drop it in a bin, and the line keeps moving as if all is well.

Under errors as values, the potato is placed in each person’s hands with the instruction “you may not pass anything else along until you decide what to do with this.” Most people glance at it and hand it up with a note attached (return fmt.Errorf("...: %w", err)); the discipline is to write a note, not just pass it. The catch is that a lazy person can set the potato down on the floor (_) and pretend they were never handed it.

Under Result/Option, the potato is welded to the thing the next person wants. To get the prize, you must first take the potato and explicitly deal with it — there is no reaching past it. The compiler is the welder, and it does not permit shortcuts. This is the strongest guarantee and the most ceremony, and the ? operator exists precisely to make “I’ll take the prize or hand the potato straight up” a one-character move.

The corollary that runs through all three: a thrown, returned, or welded failure is for recoverable problems — a file that’s missing, a user not found, a parse that failed. For the genuinely unrecoverable — a violated invariant that means the program’s assumptions are already broken — every language has a separate, louder mechanism: a panic, an abort, an assert. That distinction, recoverable versus unrecoverable, is as important as the three philosophies themselves.

When to use exceptions, values, or Result

The choice is usually made for you by your language, but the framework still matters: it tells you which model your language is emulating when you reach for unions or Optional, and it tells you, when you do have a choice (C++ gives you both; modern C++ adds std::expected), which way to lean. Figure 5.1 lays the three side by side, organized by the single axis that distinguishes them — what the type system forces the caller to confront.

Reach for exceptions when failures are genuinely exceptional and the happy path dominates the reading. Exceptions keep the common case clean and let an error propagate through many layers that have nothing useful to say about it, to be handled once, far away, by code that does. The cost is that the error path is invisible: you must remember it exists, because the compiler will not remind you. This is the right default in Python and the JVM world, where the entire ecosystem assumes it.

Reach for errors as values when failure is ordinary and expected, and you want it impossible to overlook — when the error path is the program, not an aside. A network service that fails calls constantly, a parser that rejects most of its input, a pipeline where every stage can fault: making each failure a visible, returned value forces every caller to make a decision. This is Go’s bet, and it is a good one for systems where “what happens when this fails” is the central design question.

Reach for Result/Option when you want the compiler to enforce that no failure is ignored and no absent value is dereferenced — when correctness matters more than brevity and you are willing to pay ceremony for a guarantee. Rust makes this the only option; in TypeScript and Python you opt into a weaker version by modeling outcomes as discriminated unions or Optional and turning on the strict flags that make the compiler check them. Use it when a swallowed error or a null dereference is the kind of bug you cannot afford.

Use the unrecoverable channel — panic, abort, assert — sparingly and on purpose, for invariant violations that mean continuing would corrupt state. A missing config file is a value; a null where one was structurally impossible is a bug worth crashing on. Never use the unrecoverable channel for control flow.

What you’ll learn

  • The three error-handling philosophies — exceptions, errors-as-values, and algebraic Result/Option — and the single axis (what the caller is forced to confront) that distinguishes them
  • Why Java’s checked-exceptions experiment was a principled idea that the industry ultimately voted down, and what that verdict teaches
  • How error propagation ergonomics differ — Go’s if err != nil, Rust’s ?, exception auto-propagation — and why ? is the design other languages now copy
  • How to carry a cause across layers in each model: Go’s %w/errors.Is/errors.As, Rust’s anyhow/thiserror, and exception chaining (raise ... from, new X(cause))
  • The difference between recoverable errors and unrecoverable failures, and where panic/abort/uncaught-exception belongs (almost nowhere)
  • How the “billion-dollar mistake” of null is answered by Option/Optional/nullable types, and how each language closes (or fails to close) the hole
  • Where to handle an error versus where to propagate it — the library/application boundary, retries, and translating internal errors at the public edge

Prerequisites

  • Software Engineering Overview — the framing of correctness, failure modes, and why the cost of a bug rises the further it travels from its cause. Error handling is that principle made concrete.
  • Go: Fundamentals — the (value, error) return shape and the if err != nil idiom by sight, plus interfaces (the one-method error interface) and structs with methods (custom error types). The errors-as-values sections lean on all of it.
  • Reading familiarity with at least three of the six languages here. You do not need to write all six, but the comparison only pays off if the syntax doesn’t stop you.

Philosophy one: exceptions and the invisible error path

In an exception-based language, a function that fails does not return failure — it throws, and the throw unwinds the call stack, popping frames until it reaches a matching handler. The defining property is that the intervening frames say nothing. A function ten layers up can catch an error thrown ten layers down, and not one of the eight functions in between needs to mention it. The happy path reads as a clean sequence of operations; the error path is the implicit alternative that fires when any of them throws.

The four exception languages share this shape but differ in the details. Python’s raise/except is the canonical clean version; the community even has a slogan for the style it encourages — EAFP, “easier to ask forgiveness than permission”: attempt the operation and catch the failure, rather than checking preconditions first.

Python:

def load_port(path: str) -> int:
    # No error noise on the happy path; failures unwind to a handler somewhere above.
    with open(path) as f:              # raises FileNotFoundError if missing
        return int(f.read().strip())   # raises ValueError if not an integer

try:
    port = load_port("/etc/app/port")
except (FileNotFoundError, ValueError) as e:
    port = 8080                        # recover, far from where it failed

Java spells the same idea with try/catch/finally and a typed exception hierarchy, and adds the one feature that makes it historically distinctive — but more on that in a moment. C++ throws and catches by type as well, with one critical twist covered in its own section: an exception that unwinds runs destructors on the way, which is the entire basis of RAII-style resource safety (a file or lock closes itself as the stack unwinds, with no finally). JavaScript and TypeScript throw any value at all — there is no Throwable base type — which is both flexible and a trap, since catch (e) binds e as unknown in strict TypeScript precisely because the thing thrown might not even be an Error.

TypeScript:

// The thrown value can be anything; strict mode types the catch binding as unknown.
function loadPort(text: string): number {
  const n = Number.parseInt(text, 10);
  if (Number.isNaN(n)) throw new Error(`not a port: ${text}`);
  return n;
}

try {
  const port = loadPort(raw);
} catch (e: unknown) {
  // You must narrow before you can use it — `e` is not guaranteed to be an Error.
  const msg = e instanceof Error ? e.message : String(e);
}

The strength of all four is real: the happy path stays uncluttered, and a deep failure can travel to a sensible handler without every layer paying attention. The matching weakness is equally real, and it is the refunds incident from the introduction. The error path is invisible, so it is forgettable — you can fail to catch what you should, or catch too broadly and swallow what you shouldn’t. The compiler, in every one of these languages, will happily let an exception you never considered propagate straight to the top. That invisibility is the price of the clean happy path, and the whole exceptions-versus-values debate is an argument about whether the price is worth it.

Philosophy two: errors as values and the visible error path

Go made the opposite bet. There is no throw, no try, no stack-unwinding machinery. A function that can fail returns (result, error), and you check the error before you trust the result, right where the call happens. The error path is not invisible — it is written out in full, one if err != nil at a time, sitting in the source next to the success path where it cannot be overlooked.

Go:

// The error path is as visible as the happy path — that is the entire design.
func loadPort(path string) (int, error) {
    data, err := os.ReadFile(path)
    if err != nil {
        return 0, fmt.Errorf("reading port file %s: %w", path, err)
    }
    port, err := strconv.Atoi(strings.TrimSpace(string(data)))
    if err != nil {
        return 0, fmt.Errorf("parsing port from %s: %w", path, err)
    }
    return port, nil
}

This is verbose, and it is verbose on purpose. The argument is that the error path is exactly the path that fails in production, so making it as visible as the happy path is the point, not a defect. You will write thousands of if err != nil, and that is fine; the craft is not in avoiding them but in two things you do with them. First, wrap with context as the error travels up — the %w verb in fmt.Errorf keeps the underlying error reachable while adding a sentence about what this layer was attempting. A bare return err repeated up a tall stack produces the useless file not found from a thousand 2 a.m. pages; wrapping turns it into starting server: loading config /etc/app/acme.yml: permission denied, a traceable account from intent down to root cause. Second, shape errors so callers can inspect them programmatically rather than parsing English: a package-level sentinel (var ErrNotFound = errors.New("not found")) that callers match with errors.Is, or a custom error type whose fields callers extract with errors.As. Both walk the wrapped chain, so adding context never breaks a caller’s check.

The C heritage Go refines is the bare error code — a function returns -1 or NULL and sets a global errno, and the caller is trusted, with no enforcement whatsoever, to check it. C’s model is errors-as-values with none of the ergonomics: no wrapping, no typed inspection, a single shared errno that the next call clobbers, and a return value that is trivially ignored. Go keeps the “errors are values” core and adds a real error interface, wrapping, and chain-walking inspection — which is why C error codes are the cautionary ancestor and Go is the modern form.

Go’s one genuine weakness mirrors the exception camp’s. Where exceptions can be forgotten, returned errors can be discarded: data, _ := os.ReadFile(path) assigns the error to the blank identifier and marches on, the failure now invisible. The compiler permits it. An errcheck-style linter exists precisely because the language will not catch this for you — the same underscore from the introduction, the same knowledge thrown away.

Philosophy three: Result and Option, checked by the compiler

Rust closes the hole that both other models leave open. A fallible operation returns a Result<T, E> — an enum (a sum type) that is either Ok(value) or Err(error) — and an operation that might find nothing returns an Option<T>, either Some(value) or None. The crucial property is that these are types, and the success value lives inside the Ok/Some variant. You cannot read it without first getting past the error or absence case. There is no reaching around it, and ignoring it is not a lint warning you can disable — it is a compile error.

Rust:

// The success value (u16) is locked inside Ok; you cannot use it without
// confronting the Err case first. Forgetting is a compile error, not a bug.
fn load_port(path: &str) -> Result<u16, std::io::Error> {
    let text = std::fs::read_to_string(path)?;   // Err? return it up the chain.
    let port: u16 = text.trim().parse()
        .map_err(|_| std::io::Error::other("not a valid port"))?;
    Ok(port)
}

Two things make this ergonomic rather than oppressive. The first is exhaustive pattern matching: a match on a Result must handle both arms, or the program won’t compile — forgetting the error case is structurally impossible. The second is the ? operator, the most copied idea in modern error handling. Place ? after any Result-returning expression: if it’s Ok, unwrap it and continue; if it’s Err, return that error immediately from the enclosing function. “Unwrap, or return the error up.” It compresses the entire if err != nil { return err } ritual into one character, and it keeps the compiler’s guarantee — ? only compiles inside a function that itself returns Result (or Option), so you cannot propagate a failure into a place with no way to receive it.

The ? is so obviously good that the rest of the field is converging on it. It is why this is not really “Rust versus Go”: it is “the explicit-failure camp discovering that the verbosity Go embraced can be recovered with a tiny operator, without giving up the guarantee.” TypeScript and Python don’t have Result in the language, but they approximate it — TS with discriminated unions, Python with Optional and increasingly with libraries that model Result — and we’ll see why those approximations are weaker than the real thing.

Rust’s escape hatch is .unwrap() and .expect(), which extract the success value and panic (crash) on Err or None. They exist for prototypes and provably-impossible cases — and they are exactly how the introduction’s Rust team rebuilt the very crash the type system was meant to abolish, only now spelled unwrap. Every .unwrap() is a silent claim that “this can never fail,” and the compiler can’t check that claim.

The checked-exceptions experiment, and its verdict

Java tried something none of the others did, and the result is one of the most instructive natural experiments in language design. Java splits exceptions into two kinds. Unchecked exceptions (subclasses of RuntimeExceptionNullPointerException, IllegalArgumentException) behave like exceptions everywhere else: throw freely, catch optionally, the compiler stays out of it. But checked exceptions (everything else under Exception, like IOException) carry a compiler mandate: any method that can throw one must either catch it or declare it in its signature with throws, and that obligation propagates up the call chain. The compiler refuses to compile a method that ignores a checked exception it might encounter.

Java:

// `throws IOException` is the compiler-enforced contract: every caller of
// loadPort must, in turn, either catch IOException or re-declare `throws`.
static int loadPort(Path path) throws IOException {
    String text = Files.readString(path);          // declares: throws IOException
    return Integer.parseInt(text.strip());          // throws unchecked NumberFormatException
}

The intent was admirable and, on paper, close to Rust’s guarantee: make the failure modes part of the type signature so a caller cannot accidentally ignore them. Notice that Files.readString can fail (a missing file) and the compiler forces loadPort to account for it — but Integer.parseInt throws an unchecked NumberFormatException that the compiler says nothing about. That split is the heart of the problem.

The industry’s verdict, after two decades, is that checked exceptions as Java implemented them failed — and the failure is worth dissecting because it is not the idea that failed but the execution. Three things sank it. First, they don’t compose with generics and lambdas: a Stream.map taking a lambda that throws a checked exception simply doesn’t typecheck, because the functional interfaces don’t declare throws, so every Java codebase is littered with wrappers that catch a checked exception and re-throw it as an unchecked RuntimeException just to get through a stream. Second, the obligation is viral and unergonomic: a low-level throws IOException forces every caller, all the way up, to either handle it or re-declare it, and the path of least resistance — observed in real code everywhere — is the catastrophic catch (IOException e) {}, the empty block that swallows the very error the compiler worked to surface. Third, the checked/unchecked line was drawn in the wrong place and couldn’t move: arithmetic and null errors are unchecked while I/O is checked, a distinction that satisfies no consistent principle.

The verdict is not that compiler-enforced error handling is wrong — Rust’s Result proves the opposite, and it is checked exceptions done right. The difference is that Rust’s mechanism is a value that flows through generics, lambdas, and the ? operator without special cases, whereas Java’s was a side channel bolted onto the type system that fought the rest of the language. Newer JVM languages (Kotlin, Scala) dropped checked exceptions entirely; the lesson the field took is “encode errors in the return type, not in a parallel throws channel.” Java’s experiment was the necessary mistake that taught everyone where the line really is.

War story: the empty catch block that hid a year of data loss

A reporting pipeline at a logistics company ran nightly and, for a year, produced numbers that were quietly low. The bug was a single catch (SQLException e) {} — an empty block a developer had added to make a checked-exception compiler error go away under deadline. One shard of the database occasionally timed out; that one shard’s rows were silently dropped from every report, and because the totals were plausible, no one questioned them. The checked exception had done its job perfectly: the compiler had forced the developer to confront SQLException. The developer confronted it by deleting it. This is the precise failure mode checked exceptions invite — when the obligation is viral and the deadline is real, catch (e) {} is the path of least resistance, and an empty catch is worse than no handler at all, because an unhandled exception at least crashes loudly. The fix was not “remove checked exceptions”; it was a lint rule banning empty catch blocks and a code-review norm that a catch must either recover, re-throw with context, or be explicitly, commented-ly justified. The same rule, restated for Go, is “never assign an error to _ without a comment”; for Rust, “no .unwrap() in a request path.” Every language has this footgun; every team needs this rule.

Propagation ergonomics: the road from if err != nil to ?

How a failure travels from where it’s detected to where it’s handled is where the three philosophies feel most different in daily use, and it is where the field has visibly converged. Exceptions propagate for free — that is their whole appeal; an uncaught exception rises through every frame automatically, with zero code per layer. Errors-as-values propagate manually — every layer writes if err != nil { return err } (or, better, wraps and returns). And Result types propagate with an operator — the ? that gives you free-feeling propagation while keeping the compiler’s guarantee.

The comparison below shows the same operation — “call something fallible, and if it fails, send the failure up to my caller” — in each language, which makes the ergonomics gap concrete:

Language Propagate the failure up one layer What the compiler enforces
Python (nothing — raise propagates automatically) nothing; an uncaught exception unwinds to the top
Java (unchecked) (nothing — propagates automatically) nothing
Java (checked) throws IOException on the signature caller must catch or re-declare
C++ (nothing — throw propagates; destructors run) nothing (unless noexcept is violated)
TypeScript (nothing — throw propagates) nothing; catch binding is unknown
Go if err != nil { return err } nothing — _ can discard it
Rust value? everything — must handle or propagate, or it won’t compile

Read the table top to bottom and the trade-off is stark. The exception languages cost zero characters to propagate, and enforce nothing. Go costs a three-line block per call and enforces almost nothing (the _ hole). Rust costs one character and enforces everything. The ? operator is, in this light, the resolution of a decades-old tension: it makes the safe, explicit model nearly as terse as the unsafe, implicit one. This is why Swift adopted try/Result, why Kotlin has runCatching, why Rust’s ? keeps getting cited as the feature other language designers most want — and why, genuinely, this is one place where a clear winner has emerged. If you are designing a new language, you copy ?.

A word on the cost that doesn’t show up in the table: performance. Throwing an exception is expensive in most runtimes — capturing a stack trace and unwinding frames can cost microseconds, thousands of times more than returning a value. This is fine when exceptions are genuinely exceptional and catastrophic when they’re used for control flow (the classic anti-pattern: a parser that throws on every invalid character in a tight loop). Returned values and Result are, by contrast, nearly free — a Result is just a tagged union the same size as its largest variant, with no allocation and no unwinding. If your failure is common, that alone is an argument against exceptions.

Carrying the cause: context, wrapping, and chaining

Every philosophy faces the same second problem once the first is solved: a bare failure is nearly useless for debugging. permission denied with no path, no operation, no request ID is the 2 a.m. log line that costs an hour. The fix is universal in shape — add a sentence of context at each layer as the error travels up, and keep the original cause reachable underneath — and every language has a mechanism for it, though they look different.

In the errors-as-values world, Go’s fmt.Errorf with %w is the model the others are measured against. %w wraps: it formats your context message and keeps the underlying error in the chain, reachable by errors.Is (does this chain contain sentinel X?) and errors.As (extract the typed error of shape Y). The ordinary %v verb merely formats the cause into a string and discards the original — the message looks identical, but the chain is severed and no caller can inspect underneath. The rule is %w by default, %v only when you deliberately want to hide an implementation detail at a boundary.

Go:

// %w builds an inspectable causal chain; errors.Is/As walk it later.
if err != nil {
    return fmt.Errorf("loading tenant config %q: %w", tenant, err)
}
// ... and at the boundary, a caller inspects rather than parses the string:
if errors.Is(err, ErrNotFound)         { return http404() }
var ve *ValidationError
if errors.As(err, &ve)                 { return http400(ve.Field) }

In the Result world, Rust splits the job across two libraries that have become the de-facto standard, and the split itself is the lesson. thiserror is for libraries: it derives a custom error enum — one variant per failure mode, each carrying structured fields and a #[from] to wrap underlying errors — so your callers can match on exactly what went wrong (the typed-error equivalent of Go’s errors.As). anyhow is for applications: a single anyhow::Error that any error converts into, with a .context(...) method that wraps with a message — when you’re at the top of the program and just need a good error message, not a type to match on. The division is sharp and worth memorizing: libraries return typed errors callers can branch on; applications collapse everything into one contextualized error and print it.

Rust:

// Library style (thiserror): a typed enum callers can match on, with #[from] wrapping.
#[derive(thiserror::Error, Debug)]
enum ConfigError {
    #[error("reading {path}")]
    Io { path: String, #[source] source: std::io::Error },
    #[error("invalid port: {0}")]
    BadPort(String),
}

// Application style (anyhow): one error type, context added as it propagates.
fn run() -> anyhow::Result<()> {
    let port = load_port("/etc/app/port")
        .context("starting server: could not determine port")?;   // wraps + propagates
    Ok(())
}

In the exceptions world, the same idea is exception chaining: catch the low-level exception, throw a higher-level one, and attach the original as the cause so the full chain survives. Python writes it raise NewError("loading config") from e, which preserves the original traceback under “The above exception was the direct cause of the following exception.” Java passes the cause to a constructor: throw new ConfigException("loading config", e), and the stack trace prints the chained Caused by:. The anti-pattern, in every exception language, is the inverse: catching an exception and throwing a new one without the cause (raise NewError(...) with no from, new X(...) with no cause argument), which is exactly Go’s %v mistake in different syntax — the message survives, the chain is severed, the original traceback is gone.

The comparison table makes the parallel structure unmistakable — every language is doing the same two things (add context, preserve cause), and the only question is whether its mechanism preserves the inspectable chain or just the string:

Language Add context + preserve cause Inspect the cause later
Go fmt.Errorf("ctx: %w", err) errors.Is / errors.As (walk the chain)
Rust (app) err.context("ctx") (anyhow) downcast on anyhow::Error
Rust (lib) typed enum + #[from] (thiserror) match on the variant
Python raise New("ctx") from e e.__cause__, isinstance
Java new Ex("ctx", cause) getCause(), instanceof
C++ std::nested_exception (rare) rethrow_if_nested
TypeScript new Error("ctx", { cause: e }) e.cause, instanceof

The sin that recurs in every row is the same: demoting context to a re-thrown string or a bare re-return, severing the chain. Whatever your language, the discipline is identical — add a sentence, keep the cause.

Sentinel versus typed errors: identity or data

Once errors carry information, callers need to act on them, and there are exactly two things a caller can ask. The first is a question of identity: is this the not-found case? — to which the answer is yes or no, and no data is needed. The second is a question of data: which field failed validation, and what was the bad value? — where the caller needs to pull structured fields out. The two questions call for two different tools, and choosing between them is most of error-type design.

For pure identity, you want a sentinel: one named, package-level value (ErrNotFound, the standard library’s io.EOF and sql.ErrNoRows) that callers match by equality. For data, you want a typed error: a struct or enum carrying the fields, which callers extract by type. The mapping across languages is clean — sentinels are shared constant values; typed errors are classes (Java/Python/TS), enum variants (Rust), or structs implementing error (Go):

Need Go Rust Python / Java / TS
Branch on a known condition (identity) sentinel var Err... = errors.New(...), match with errors.Is a unit enum variant, match with a pattern a dedicated exception subclass, catch by type
Read structured fields (data) custom struct with Error(), extract with errors.As enum variant with fields, destructure in match an exception class with fields, read after catch

The exception languages blur this distinction in a way that’s worth noticing: because you catch by type, a sentinel and a typed error look the same at the call site — except NotFoundError is identity, except ValidationError as e: e.field is data, and the only difference is whether you read fields off the caught object. Rust and Go make the split explicit; Python, Java, and TS fold it into the type hierarchy. The design advice is the same everywhere: prefer the simpler tool. If the caller only needs to know which failure it was, a sentinel or a bare exception subclass is enough; reach for fields only when the caller genuinely needs the data to act.

The billion-dollar mistake: null, nil, None

Tony Hoare called the null reference his “billion-dollar mistake” — he invented it in 1965 because it was easy, and it has caused, by his own estimate, untold crashes, vulnerabilities, and damage in the decades since. The mistake is specific: a type like User silently also permits the absence of a user (null), so every value of that type is a potential NullPointerException / nil dereference / undefined is not a function waiting to fire, and the type system gives you no warning. Absence is smuggled into every reference type for free, and the compiler can’t tell the references that might be null from the ones that can’t.

The fix the field arrived at is to make absence explicit in the type. An Option<T> or Optional<T> is a box that is either present (Some/Just) or absent (None), and crucially you cannot use the inner value without first opening the box and handling the empty case — exactly the Result guarantee, applied to absence instead of failure. The six languages sit at very different points on this spectrum, and the spread is the whole story:

Language Absence is… Compiler stops a null dereference?
Rust Option<T>; there is no null at all Yes — you must match/?/unwrap to get the value
TypeScript (strictNullChecks) T \| null / T \| undefined, a union member Yes — narrowing required before use
Java (modern) Optional<T> by convention; null still legal No — Optional helps, but null is never gone
Python (typed) Optional[T] = T \| None; checked by mypy, not at runtime Only if you run a type checker; the runtime won’t
Go nil for pointers/interfaces/maps/slices; no Option No — a nil deref panics at runtime
C++ std::optional<T>; raw pointers and references still null/dangle Partly — optional helps; raw pointers don’t

Rust sits at the safe end: there is no null in the language, absence is Option<T>, and the compiler enforces handling — the billion-dollar mistake is simply not expressible. TypeScript with strictNullChecks on is nearly as good within typed code: null and undefined become explicit union members the compiler forces you to narrow, turning the classic Cannot read property 'x' of undefined into a compile error. The catch — and it is the recurring catch for the gradually-typed languages — is that the guarantee is only as strong as the boundary: a value off the network typed as User that is actually null slips straight through, because erasure means nothing checks at runtime. Python’s Optional[T] is the same story: real and useful under mypy, invisible to the interpreter.

Java and Go are the cautionary cases. Java added Optional but kept null, so Optional is a convention for return types, not a guarantee — null can still be assigned to any reference, and Optional.get() on an empty optional throws. It reduces nulls where the team is disciplined; it does not eliminate them. Go made a deliberate choice not to have Option at all: nil is the zero value for pointers, interfaces, maps, channels, and slices, and a nil dereference is a runtime panic. Go’s defense is that zero values are usable (a nil slice is an empty slice you can append to, a nil map reads as empty) and that explicit error returns cover most of what Option would — but the nil-interface and nil-pointer panic remains Go’s most common runtime crash, and it is the price of that simplicity. The lesson across the table: eliminating null requires the compiler’s help, and a language that keeps null as an escape hatch keeps the mistake.

Where to handle, where to propagate: the boundary

The last question is architectural, and it is the same in every language: given a failure, do you handle it here or propagate it up? The answer is a principle, not a rule — handle an error only where you have enough context to do something meaningful about it; propagate it everywhere else — and it has a sharp corollary that distinguishes two kinds of code.

A library should almost never handle errors by deciding policy; it should return them (typed, so callers can branch) and let the application decide what to do. A library that catches an error and logs it, or retries on its own, or swallows it, has stolen a decision that belongs to its caller — the application might want to retry with different parameters, or fail fast, or surface the error to a user, and a library that already “handled” it has foreclosed those choices. This is exactly the Rust thiserror-vs-anyhow split made into an architectural rule: libraries return typed errors; applications, at the top, finally decide.

The decisive place where applications do handle is the boundary — the public edge where internal errors meet the outside world, whether that’s an HTTP response, a gRPC status, or a CLI exit code. This is where you translate: a ErrNotFound or a NotFoundError becomes a 404; a validation error becomes a 400 with the offending field; everything else becomes a logged 500 with a sanitized message, because the internal cause (a stack trace, a SQL string, an internal path) must not leak to an untrusted caller. The boundary is also where you stop wrapping with %w/preserving the chain for internal inspection and start deciding what the outside world is allowed to see — the one place a deliberate %v-style severing is correct.

Retries belong at the boundary too, and they hinge on a distinction the error itself must carry: is this failure transient (a timeout, a 503, a dropped connection — retry might succeed) or permanent (a 400, a validation failure — retry will fail identically forever)? Retrying a permanent error is a bug that hammers a failing system; not retrying a transient one is a bug that gives up too early. This is the practical reason typed errors matter: the retry logic asks the error which kind it is (errors.Is(err, ErrTransient), a Retryable trait, an exception subclass) and decides. And retries must be bounded with backoff — a fixed number of attempts with exponentially increasing, jittered delays — or a transient blip becomes a self-inflicted denial of service as every client retries in lockstep.

Build it → Error propagation and translation across a real distributed boundary: Project 13: Service Mesh threads typed errors through proxies and translates them into status codes at the edge, with retries and circuit breaking on transient failures.

Build it → Transient-versus-permanent retry logic in anger: the worker loop in Project 01: Distributed Job Queue classifies job failures, retries the retryable ones with bounded exponential backoff, and dead-letters the rest.

Build it → Recoverable-versus-unrecoverable taken to its extreme: a consensus system must propagate recoverable errors (a follower behind, a network partition) while treating a violated safety invariant as a panic-worthy bug. Project 11: Distributed KV (Raft) draws exactly that line throughout its Rust implementation.


Practical exercise

Difficulty: Level I · Level II · Level III

  1. Level I — The same failure, three philosophies. Write load_port — read a file, parse its contents as a port number — in three languages from the three camps: Python (exceptions), Go (errors as values), and Rust (Result). Force each of the two failures (missing file, non-numeric contents) and capture the error message each produces with no extra context added. Then add context at each layer: chain the exception with raise ... from, wrap with fmt.Errorf("...: %w", err), and add .context(...) with anyhow. Write the before/after messages side by side and, in a paragraph, say which model made adding context easiest and which made forgetting to add it hardest — they are not the same model.

  2. Level II — Make the compiler your ally, then defeat it. In Rust, write a function that returns a typed error enum with thiserror, with one variant carrying a structured field (the bad value). Write a caller that matches on the variant to recover. Now do the equivalent in TypeScript using a discriminated union ({ ok: true; value: T } | { ok: false; error: E }) instead of throw, with strictNullChecks on, and confirm the compiler forces you to handle the ok: false case before reading value. Finally, defeat each safety net deliberately: add a .unwrap() in Rust and a result.value! non-null assertion in TS, and explain in two sentences exactly what guarantee you just discarded and why a linter — not the compiler — is now your only defense.

  3. Level III — Design the error strategy for a layered service. For a service with handler, service, and repository layers, write the design (prose plus illustrative signatures in the language of your choice) for how errors flow end to end. Specify: which failures are sentinels callers branch on versus typed errors carrying fields; what each layer wraps and with what context; the boundary that translates internal errors into sanitized responses (ErrNotFound to 404, validation to 400, everything else to a logged 500 that does not leak internals); where transient-versus- permanent classification lives and how the retry layer reads it; and the one or two places — if any — where an unrecoverable panic/abort is correct rather than a returned error. Defend, explicitly, the single line you drew between “handle here” and “propagate up,” and the single line between “recoverable value” and “panic-worthy bug.”

Summary

Error handling reduces to one question asked of every fallible operation: what does the language force the caller to do when this fails? There are three answers. Exceptions (Python, Java, C++, JS) unwind the stack invisibly — clean happy path, forgettable error path, and a missed catch that sails to the top or, worse, an empty catch that swallows the failure. Errors as values (Go, C codes) return failure alongside the result — verbose on purpose, visible in the source, with a _ escape hatch the compiler won’t close. Result/Option (Rust, approximated by TS unions and Python Optional) encode failure in the type so the success value is locked behind it — the strongest guarantee, made ergonomic by the ? operator, which is the one feature the whole field is converging on. Java’s checked exceptions were a principled attempt at the Rust guarantee that failed in execution — viral, generics-hostile, inviting the empty catch — and taught the field to encode errors in the return type, not a parallel throws channel. Across all three philosophies the same disciplines recur: add a sentence of context per layer and keep the cause inspectable; choose sentinels for identity and typed errors for data; make absence explicit (Option/Optional/nullable) to defuse the billion-dollar mistake; handle errors only where you have the context to, propagate them everywhere else, and translate them at the boundary.

Key takeaways

  • The three philosophies differ in what the type system forces the caller to confront: exceptions ask nothing, returned values ask politely, Result asks at compile time.
  • The ? operator is the resolved tension — explicit, compiler-checked error handling made nearly as terse as silent exception propagation. If you design a language, copy it.
  • Checked exceptions weren’t wrong — they were checked exceptions done wrong; Result is the same idea as a value that flows through generics, lambdas, and ? without special cases.
  • Every model needs context: wrap and keep the cause inspectable (%w/errors.Is, anyhow/thiserror, raise ... from). The universal sin is severing the chain by re-throwing a bare string.
  • Null is the billion-dollar mistake; only a compiler that has no null (Rust) or forces you to narrow it (TS strict) actually closes the hole — Optional beside null (Java, Go’s nil) merely reduces it.
  • Recoverable failures are values; reserve panic/abort/uncaught-exception for invariant violations. Handle errors where you have context, propagate elsewhere, translate at the boundary, and retry only the transient ones with bounded backoff.

Connections to other chapters

  • Software Engineering Overview (prerequisite): this chapter is the concrete expression of that chapter’s principle that a bug costs more the further it travels from its cause — context-wrapping and compiler-enforced handling are both bets on catching failure close to where it happens, which is the whole game.
  • Go: Fundamentals (prerequisite): the errors-as-values model is Go’s no-magic philosophy — (result, error) is the multiple-return idiom, error is a one-method interface, and a custom error type is a struct with a method. This chapter is where those fundamentals earn their keep.
  • Type Systems and Generics (sibling): Result<T, E> and Option<T> are sum types, and the ? operator’s “must return Result to use it” rule is a type-level constraint — this chapter is one of the most consequential payoffs of the algebraic-types machinery that chapter develops. The billion-dollar-mistake section is null-safety as a type-system feature.
  • Concurrency and Parallelism Models (sibling): errors and cancellation travel together across goroutines, tasks, and futures — a failed operation in one task must propagate to its siblings, and context.Context / CancellationToken / structured concurrency carry an error alongside a cancellation signal. The propagation discipline here is what makes concurrent error handling tractable.
  • Testing and Quality (extension): the failures this chapter prevents are the ones testing must provokepytest.raises, table-driven error cases, fault injection. A test suite that only exercises the happy path is blind to exactly the error paths this chapter is about; the war stories here are all bugs that shipped past green tests.
  • Rust: Fundamentals (field guide): the deep dive on Result, Option, exhaustive match, and the ? operator — the pure form of philosophy three that the rest of the field is converging toward.
  • Go: Web Services (extension): the “translate internal errors at the boundary” idea is where this chapter meets the wire — a wrapped internal error becoming an HTTP status or gRPC code, the public edge where you stop preserving the chain and start deciding what the outside world may see.
  • Observability (extension): a wrapped, context-rich error is the highest-signal log line and trace span you can emit; the structured-error discipline here is what makes errors queryable rather than grep-able prose at 2 a.m.

Further reading

Essential

  • The Go Blog — “Working with Errors in Go 1.13” — the canonical introduction to %w, errors.Is, and errors.As; the clearest statement of the wrap-and-inspect model.
  • The Rust Book — “Error Handling” chapter, plus the anyhow and thiserror crate docs — the pure Result/Option model and the library-versus-application split made practical.

Deep dives

  • Rob Pike, “Errors are values” (The Go Blog) — the short, foundational essay on treating errors as ordinary values you can program with, not exceptional events.
  • “The Trouble with Checked Exceptions” — Bruce Eckel’s interview with Anders Hejlsberg (the C# designer’s reasoning for omitting checked exceptions), the clearest articulation of why the experiment was judged a failure and what it cost.
  • Joe Duffy, “The Error Model” (2016) — an exhaustive language-designer’s survey of exceptions versus return codes versus typed errors, from the architect of Midori; the single best long read on the whole design space.

Historical context

  • C. A. R. Hoare, “Null References: The Billion Dollar Mistake” (QCon 2009) — the inventor of null recanting; the origin of the phrase and the motivation for Option.
  • John Goodenough, “Exception Handling: Issues and a Proposed Notation” (CACM, 1975) — the foundational paper that introduced structured exception handling, the ancestor of every try/catch in this chapter.
  • The Go and Rust design discussions on error handling (proposal archives) — the long-running arguments over verbosity, ?, and whether explicit values or exceptions better serve large, long-lived codebases; reading them explains why each language chose as it did.