Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Error Handling

The chapter on values and runtime errors split failure in two. A run can get stuck — no rule applies, so there is no value, and the interpreter halts with a RuntimeError the program cannot observe or recover from. Separately, a program can compute a value that stands for a failure and carry on from it. This chapter is about that second kind: the failures a program expects, how a language lets an operation signal one, and how a caller responds. The mechanism a language picks shapes how easy it is to read and understand code. This chapter looks at the history and design choices for how programs handle and recover from errors.

Failures a program expects

Some operations can fail as a normal part of doing their job. Looking up a configuration key that might be absent, parsing an integer from text a user typed, opening a file that might not exist, dividing by a number that might be zero — none of these is a bug in the program. Each is a case the program should anticipate and respond to: try another key, ask the user again, fall back to a default. The failure is part of the operation’s ordinary result, and the caller has a sensible next move.

That makes these failures different from the stuck states the values chapter described. Adding a boolean to an integer has no meaning and no recovery; the interpreter halts. A missing configuration key has a perfectly good meaning — “it is not there” — and a caller who wants to know. The question is how an operation reports such an outcome so that a caller can see it and act on it.

Mechanisms for handling errors

The earliest high-level languages signalled failure the way the hardware did: an operation set a status value, or returned a sentinel — a negative length, a null pointer, a distinguished integer — and the caller was expected to check it before using the result. This costs the language nothing to provide, and it is still how C’s standard library and most operating-system calls report failure. Its weakness is that nothing forces the check: a failed call has the same type as a successful one, so a caller can use its result as though it had succeeded and the compiler will not object, which makes the unchecked return value a classic source of bugs.

The mechanisms modern languages reach for fall into three broad categories. Two of them — exceptions and error values — repair that unchecked return by forcing the failure into the open, in different ways; the third stops the program rather than recovering. The differences show up in where the failure goes, whether the caller is forced to deal with it, and whether the possibility of failure is visible in the operation’s type.

Raising and catching exceptions

An exception moves the failure out of the return value entirely. An operation raises, and control leaves the raising code at once and travels up the call stack until it reaches a handler installed by some enclosing caller. Code between the raise and the handler is skipped. A function that does not care about a particular failure writes nothing about it; the exception passes through on its way to whoever does.

PL/I, in the mid-1960s, is among the first languages to build this into the language itself, with ON-conditions and the ON-units that handle them. The systematic treatment came a decade later: Goodenough’s 1975 paper Exception Handling: Issues and a Proposed Notation named the design questions still argued today. Chief among them is what happens after a handler runs. Under resumption, the handler can repair the situation and hand control back to the point that raised, which continues as if nothing had happened. Under termination, the raising construct is abandoned and control resumes in the handler’s context. CLU (Liskov and colleagues, later in the 1970s) made exceptions part of each procedure’s signature and committed to termination; termination became the model the mainstream languages adopted, and later designs — C++, Java, Python — followed it, while resumption largely fell out of use.

What every exception mechanism shares is that the failure is out of band. A function’s declared return type describes only the success case; whether it can also raise, and what, is either unstated or kept in a separate part of the signature. Java’s checked exceptions are the notable attempt to pull that information back into the type and force callers to account for it — an experiment whose ergonomics have been argued over ever since.

Error values

The functional tradition keeps the failure in the return value, but replaces the unchecked sentinel with a value whose type says failure is possible. ML’s option and Haskell’s Maybe carry “a value or nothing”; Haskell’s Either and Rust’s Result carry “a value or an error.” A function that might fail returns one of these, and a caller cannot reach the success value without first taking apart the wrapper and confronting the failure case.

This is the family the interpreter already uses in Rust: eval returns Result<Value, Control>, and every caller has to decide what an Err means before it can touch a Value. The possibility of failure is written in the type, the compiler checks that the caller handled it, and no separate channel or stack unwinding is involved — the failure is an ordinary value that flows through the program like any other.

Abort: panics

The third answer is to not recover at all. When a failure means the program has hit a state it was built to assume could never happen — a broken invariant, an index the code proved was in range — a language can stop the program outright: Rust’s panic!, C’s abort, a failed assertion. There is no handler and no value; the program immediately ends.

This is exactly the interpreter’s stuck errors from the values chapter, seen from a Bridger program’s side. When eval returns Err(Control::Raise(..)), the run halts and the program cannot intervene — an abort, reported at the top level. The abort family is reserved for failures where continuing would be worse than stopping, which is why it sits apart from the two recoverable mechanisms above.

Error values in Rust

Rust gives a program both Result and Option, defined in the values chapter, and one way to take them apart: match. An operation that can fail returns the wrapper, and the caller matches on it.

fn safe_div(a: i64, b: i64) -> Result<i64, String> {
    if b == 0 {
        Err("divide by zero".to_string())
    } else {
        Ok(a / b)
    }
}

match safe_div(10, 0) {
    Ok(q)  => println!("{q}"),
    Err(m) => println!("error: {m}"),
}

The Result<i64, String> in the signature is the whole point: a caller reads it and knows the call can fail, and the match will not compile unless it covers Err. The failure is in the open, and handling it is not optional.

When failures come in sequence

Handling one failure reads well. The difficulty this family is known for appears when several fallible operations run in sequence, each depending on the one before. Consider building a configuration from three lookups, each of which can fail:

fn read_config() -> Result<Config, String> {
    match lookup("host") {
        Err(e) => Err(e),
        Ok(host) => match lookup("port") {
            Err(e) => Err(e),
            Ok(port) => match lookup("user") {
                Err(e) => Err(e),
                Ok(user) => Ok(Config { host, port, user }),
            },
        },
    }
}

Each step continues inside the Ok arm of the step before, so the real work moves one level to the right for every operation, and the actual result — building the Config — ends up buried at the bottom of the staircase. This rightward drift is the shape critics of error values point to: the same three lookups written with exceptions would sit flat, one after another, because a failure would simply leave on its own. Handled naively, error values put their least flattering foot forward.

The repair is to let a failure leave early instead of nesting. An early return exits the function the moment a step fails, so the success value can continue at the outer level rather than inside an arm:

fn read_config() -> Result<Config, String> {
    let host = match lookup("host") {
        Ok(v) => v,
        Err(e) => return Err(e),
    };
    let port = match lookup("port") {
        Ok(v) => v,
        Err(e) => return Err(e),
    };
    let user = match lookup("user") {
        Ok(v) => v,
        Err(e) => return Err(e),
    };
    Ok(Config { host, port, user })
}

The staircase is gone — the steps are back in a line — but each one is still four lines of the same match-and-return. That repeated shape is exactly what Rust’s ? operator abbreviates. Writing e? on a Result unwraps an Ok to its value and, on an Err, returns it from the enclosing function at once:

fn read_config() -> Result<Config, String> {
    let host = lookup("host")?;
    let port = lookup("port")?;
    let user = lookup("user")?;
    Ok(Config { host, port, user })
}

Each ? stands for the four-line match above it. The code now reads as the three steps it is, the failure path is a single character, and the type still says the function can fail. This is the same ? the eval walk used to thread a Control outward: one operator, propagating the error channel so that ordinary code does not have to. With return and ? in hand, error values read as directly as the exception version, and keep the failure in the type.

How Bridger handles errors

Bridger makes the same choice, one level up: recoverable failure is a value. Its prelude defines Option and Result as ordinary algebraic data types, taken apart with match, and a Bridger program handles a failing operation the way the Rust above does:

fn safe_div(a: Int, b: Int) -> Result<Int, String> =
    if b == 0 { Err("divide by zero") } else { Ok(a / b) };

fn main() {
    match safe_div(10, 0) {
        Ok(q)  => println(q),
        Err(m) => println(m),
    }
}

The two flattening tools carry over as well. return is part of Bridger’s core from the start, introduced with control flow; ? arrives later as surface sugar over match and return, once ADTs give the language the constructors it works on. Bridger’s Option and Result are its own, defined in the prelude, and separate from the Rust Option and Result the interpreter is written in — the values chapter drew that boundary, and it holds here: a Bridger Err("...") is a value the program computes with, while the interpreter getting stuck is an abort the program never sees.

One detail differs from Rust when ? first arrives, and readers who know Rust will trip on it. Rust’s ? quietly inserts a From::from conversion on the error, so an Err of one error type can propagate out of a function whose error type is another. Bridger’s ? begins without that conversion: in a function returning Result<T, E>, every e? requires e to be a Result<_, E> with that same E, and a mismatch is reported as a type error naming two error types that a Rust programmer would expect to be compatible. The conversion needs trait-directed dispatch, which the language gains after ?. Once traits arrive, the prelude’s From<T> trait supplies it, and the desugaring of e? grows one call:

match e { Ok(v) => v, Err(err) => return Err(E.from(err)) }

The from resolves from the enclosing function’s error type — the expected type drives the choice, as it does for an unannotated lambda parameter — and when that type already matches the error, ? needs no conversion and inserts no from call, so code whose error types agree is unchanged. Supplying impl From<IoError> for ConfigError is what makes a Result<String, IoError> propagate out of a function returning Result<Config, ConfigError>. The conversion Rust performs silently is an ordinary trait here, with an impl a program can read and write.

Bridger has no exceptions. They are the contrast in this chapter rather than a feature: the one piece of non-local control in the core is return, and the abort family is the interpreter’s stuck errors. A program signals a recoverable failure by returning a value, and every place that might fail says so in its type.

Choosing a mechanism

No one of the three families is the plain best, and current languages divide over them. Exceptions keep the common path uncluttered — code that does not handle a failure says nothing about it — at the cost of a failure edge that is invisible at the call site and a control flow that leaves normal order. Error values make every failure visible in the type and checked by the compiler, at the cost of the drift that return and ? exist to answer. Aborting trades all recovery for the guarantee that a broken assumption stops the program instead of corrupting it.

Where languages land often reflects what they are protecting. Java, Python, and C++ center on exceptions, with Java’s checked exceptions an attempt to make the failure edge visible that programs frequently route around. Go returns an error value alongside the result and leans on convention to check it. Rust draws the line by kind of failure: Result and Option for the failures a caller should handle, panic! for the bugs it should not — recoverable failures as values, unrecoverable ones as aborts. Bridger follows that division, with the recoverable side modeled as values and the unrecoverable side its stuck errors, and leaves exceptions to the languages that build on them.

Further reading

The design questions, first stated: John Goodenough’s Exception Handling: Issues and a Proposed Notation (CACM, 1975) set out the vocabulary — including the termination-versus-resumption choice — that later designs argued within. Barbara Liskov and Alan Snyder’s Exception Handling in CLU (IEEE Transactions on Software Engineering, 1979) reports the reasoning behind putting exceptions in a procedure’s signature and choosing termination over resumption, from the experience of building the mechanism into CLU.

Error values in practice: the Rust Book’s Error Handling chapter works through panic!, Result, and the ? operator, and draws the recoverable-versus-unrecoverable line this chapter’s closing section describes. Rob Pike’s Errors are values (2015) makes the case for Go’s convention of returning an error alongside the result, and shows patterns that keep the repeated checks from piling up.

Concept checks

A configuration lookup can fail because the key is absent. Why model that with Result or Option rather than the stuck runtime errors of the values chapter?

Because a missing key is a failure the program expects and can act on, and a stuck error is one it cannot. A stuck error — adding a boolean to an integer — has no meaning and no recovery; the interpreter halts and the program never runs again from that point. A missing key has a clear meaning (“not present”) and a caller with a next move (try a default, ask again), so it should be an ordinary value the program inspects and continues from. Reaching for a stuck error here would halt the whole run over a case that was never a bug, and give the caller no way to respond.

An exception and an error value both report a failure. What can a caller tell from a function's type under each?

Under error values, the type states the failure: a function returning Result<Config, String> announces at the call site that it can fail and with what, and the compiler will not let the caller use the result without handling the Err case. Under exceptions, the return type usually describes only success; whether the function can raise, and what, is either unstated or kept in a separate part of the signature, so the caller can compile without accounting for it. The trade is visibility and forced handling against uncluttered code on the path that does not care about the failure — the axis languages divide on. (Java’s checked exceptions are the attempt to move exceptions toward the error-value end of this axis.)

Rewrite this staircase so the failures leave early, then say what ? abbreviates.
match f() { Err(e) => Err(e), Ok(a) => match g(a) { Err(e) => Err(e), Ok(b) => Ok(h(b)) } }

With early returns the two steps come back into a line:

let a = match f()  { Ok(v) => v, Err(e) => return Err(e) };
let b = match g(a) { Ok(v) => v, Err(e) => return Err(e) };
Ok(h(b))

Each ? abbreviates exactly one of those match expressions: f()? evaluates f(), yields the inner value on Ok, and on Err returns it from the enclosing function. So the whole thing becomes let a = f()?; let b = g(a)?; Ok(h(b)). The drift came from continuing inside each Ok arm; return removes the nesting, and ? removes the repetition, leaving the failure path a single character while it stays present in the type.

A Rust programmer writes e? in a Bridger function returning Result<Config, ConfigError>, where e has type Result<String, IoError>. It type-errors. Why, when the same code would compile in Rust?

Rust’s ? inserts a From::from conversion on the error, so an IoError can be turned into a ConfigError on its way out, provided that conversion exists. When ? first arrives, Bridger’s performs no conversion: in a function returning Result<T, E>, every e? requires e to be a Result<_, E> with that same E, so a Result<String, IoError> propagated out of a Result<Config, ConfigError> function is a type error naming the two error types. The conversion needs trait-directed dispatch, which the language gains after ?. Once traits arrive, the prelude’s From<T> trait supplies it: e? desugars to Err(err) => return Err(E.from(err)) for the enclosing function’s error type E, and the program compiles as soon as it provides impl From<IoError> for ConfigError. What was an invisible coercion in Rust is a trait impl the program writes.

Rust offers both Result and panic!. Why would a language keep an abort mechanism at all, when error values can report any failure?

Because some failures mean an assumption the program was built on has broken, and there is no honest value to return. If code has already established that an index is in range or that an invariant holds, a failure of that assumption is a bug in the program, not a case a caller should be asked to handle; threading a Result through it would force every caller to write handling for a situation that should never arise, and would invite them to paper over it. An abort stops the run at the point the assumption failed, which is both the most informative place to report it and safer than continuing from a state the code cannot describe. The division is by kind of failure: values for what a caller should recover from, aborts for what it should not — the line Rust draws with Result against panic!, and Bridger with values against its stuck errors.