Ramblings of an aging IT geek
← Ramblings of an aging IT geek
rust

what rust error handling felt like before anyhow turned up

A look back at hand-rolled error enums, Box<dyn Error>, the error-chain and failure crates, and how anyhow and thiserror made the whole question boring in the best way.

Source code on a screen, Rust error handling

Error handling in Rust is a solved problem now, in the sense that you reach for anyhow in your binary and thiserror in your library and you stop thinking about it. That sentence would have read like science fiction to me a few years ago. Getting here took the language and the ecosystem a remarkably long and meandering route, and having lived through most of it, I think the journey is worth remembering, if only so you appreciate how good the current answer is.

The thing that makes Rust error handling distinctive is that there are no exceptions. A function that can fail returns Result<T, E>, and you, the caller, have to deal with the E somehow. This is the right design. It makes failure visible in the type signature, it makes you confront it at compile time, and it means there is no invisible control-flow path that unwinds the stack from somewhere three frames down. The catch is the E. What type goes there? That single question is the whole story.

the hand-rolled enum era

In the beginning, and for a long time after the beginning, the answer was: you write the enum yourself. Every library that could fail defined its own error type, usually an enum with a variant per failure mode, and you implemented the traits by hand.

use std::fmt;

#[derive(Debug)]
enum MyError {
    Io(std::io::Error),
    Parse(std::num::ParseIntError),
    NotFound(String),
}

impl fmt::Display for MyError {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        match self {
            MyError::Io(e) => write!(f, "io error: {}", e),
            MyError::Parse(e) => write!(f, "parse error: {}", e),
            MyError::NotFound(s) => write!(f, "not found: {}", s),
        }
    }
}

impl std::error::Error for MyError {}

impl From<std::io::Error> for MyError {
    fn from(e: std::io::Error) -> Self {
        MyError::Io(e)
    }
}

impl From<std::num::ParseIntError> for MyError {
    fn from(e: std::num::ParseIntError) -> Self {
        MyError::Parse(e)
    }
}

That is a lot of boilerplate, and I have only shown you two source error types. The From implementations are the load-bearing part, because they are what let the ? operator work. When you write let n: i32 = s.parse()? inside a function returning Result<_, MyError>, the ? quietly calls From::from to convert the ParseIntError into your MyError. That conversion is genuinely lovely. Writing all the From impls by hand to enable it is genuinely tedious. You spent your afternoons typing match arms that did nothing but rename one error into another.

the Box shortcut, and why it wasn't enough

If you did not want to write all that, there was an escape hatch: return Box<dyn std::error::Error>. Any error type implements the Error trait, so you can box it up and the ? operator will happily convert anything into it. For a quick binary or a script, this was fine and still is.

fn run() -> Result<(), Box<dyn std::error::Error>> {
    let contents = std::fs::read_to_string("config.toml")?;
    let port: u16 = contents.trim().parse()?;
    println!("port is {}", port);
    Ok(())
}

The problem with Box<dyn Error> was always the same two things. First, you lose the type. The caller gets an opaque error and can no longer match on it to decide what to do, which is fine for a main that just prints and exits but useless for a library. Second, and this one stung daily, the trait object did not carry a backtrace, and the default Display gave you the innermost message with none of the context around it. You would get No such file or directory and have absolutely no idea which file, from where, in service of what.

Programming, an editor with diagnostics

error-chain, and then failure

So the community tried to fix the ergonomics. The first serious attempt that I used in anger was error-chain, a crate built around a macro that generated your error enum, the conversions, and a chaining mechanism so an error could carry the lower-level error that caused it. It was clever and it was a real improvement, and it was also a macro of such density that when something went wrong inside it the compiler errors were close to unreadable. You were debugging the macro's idea of your code rather than your code.

Then came failure, which was the great hope for a while. It introduced a Fail trait as a cleaner replacement for the standard Error trait, it had a Backtrace type, and it had the bail! macro for early returns. A lot of well-known projects adopted it. The problem was that it was a parallel universe: it did not use the standard library's std::error::Error trait, it used its own Fail. That meant interoperating with code that used standard errors required adapters in both directions, and you ended up with a project where half the errors spoke one dialect and half spoke the other. It solved the ergonomics and fractured the ecosystem to do it.

A keyboard and editor, mid-refactor

the split that finally worked

The thing that fixed it, around 2019, was realising the question had two different answers depending on who you are.

If you are writing an application, the top of a binary, you do not care about the precise type of the error. You want to attach context as it bubbles up, you want a backtrace, and at the end you want to print the whole chain and exit. You want one error type that can hold anything. That is anyhow.

use anyhow::{Context, Result};

fn load_config(path: &str) -> Result<Config> {
    let text = std::fs::read_to_string(path)
        .with_context(|| format!("failed to read config from {}", path))?;
    let config: Config = toml::from_str(&text)
        .context("config file was not valid TOML")?;
    Ok(config)
}

That with_context is the whole point. When this fails you do not get No such file or directory, you get failed to read config from /etc/app.toml, caused by No such file or directory, with the chain printed in order. The thing I spent years missing is a one-liner.

If you are writing a library, you do care about the type, because your callers need to match on your errors and decide what to do. You want a real enum, with named variants, that implements the standard Error trait so it interoperates with everything. You just do not want to write the boilerplate. That is thiserror.

use thiserror::Error;

#[derive(Error, Debug)]
pub enum ConfigError {
    #[error("could not read config file")]
    Io(#[from] std::io::Error),
    #[error("config was not valid TOML")]
    Parse(#[from] toml::de::Error),
    #[error("missing required field: {0}")]
    Missing(String),
}

That derive generates the Display impl from the #[error("...")] strings, the Error impl, and the From conversions from the #[from] attributes. It is exactly the hand-rolled enum from the top of this post, with every line of boilerplate generated for you, and crucially it produces standard std::error::Error types. No parallel trait, no dialect split. A library using thiserror and a binary using anyhow compose perfectly, because anyhow::Error can absorb anything implementing the standard trait.

why the boring ending is the good one

What strikes me looking back is that the winning design did not invent anything new. It did not add a new trait or a new universe. It leaned into the standard library's existing Error trait, which had been sitting there the whole time, and split the tooling along the one axis that actually mattered: do you need to inspect the error type, or just propagate it? thiserror for yes, anyhow for no. Both are thin, both are obvious in hindsight, and both got out of the way.

The earlier crates were not wrong to exist. error-chain and failure were people feeling out the shape of the problem in public, and the eventual answer is better for the dead ends they explored. But it is worth holding onto the memory of the boilerplate, because the next time you derive Error and it just works, you should know that someone spent a real chunk of their life typing those From impls by hand so you would never have to.