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

how we did rust error handling before anyhow turned up

A look back at the error handling patterns Rust forced on us before anyhow and thiserror made the whole thing pleasant.

A code editor showing Rust source

It's easy to forget how much work error handling used to be in Rust. These days you reach for anyhow in a binary, thiserror in a library, slap ? on everything, and get on with your life. That ecosystem is only a few years old. Before it settled, every project rolled its own approach, and most of them were a bit miserable. I went digging through some old code this week and I'd forgotten quite how much ceremony we used to accept as normal.

the Box era

The earliest pattern most of us landed on was returning Box<dyn Error>. It worked, in the sense that it compiled and you could propagate errors with ? from anything that implemented the Error trait. The function signatures looked like this:

fn load_config(path: &str) -> Result<Config, Box<dyn std::error::Error>> {
    let raw = std::fs::read_to_string(path)?;
    let parsed = parse(&raw)?;
    Ok(parsed)
}

This was fine until you wanted to actually do something with the error other than print it. A Box<dyn Error> is opaque. You couldn't match on it to decide whether a missing file meant "use defaults" or "give up". You ended up downcasting, which was verbose and fragile, or you gave up and treated all errors the same. It also wasn't Send + Sync by default, which bit you the moment you tried to move an error across a thread boundary, and threading was where you most wanted decent errors.

the hand-rolled enum

So the disciplined approach, the one every serious library used, was to define your own error enum and implement the traits by hand. This is what thiserror now generates for you in three lines. Back then you wrote all of it yourself:

#[derive(Debug)]
enum ConfigError {
    Io(std::io::Error),
    Parse(ParseError),
    MissingField(String),
}

impl std::fmt::Display for ConfigError {
    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
        match self {
            ConfigError::Io(e) => write!(f, "io error: {}", e),
            ConfigError::Parse(e) => write!(f, "parse error: {}", e),
            ConfigError::MissingField(name) => write!(f, "missing field: {}", name),
        }
    }
}

impl std::error::Error for ConfigError {
    fn cause(&self) -> Option<&dyn std::error::Error> {
        match self {
            ConfigError::Io(e) => Some(e),
            ConfigError::Parse(e) => Some(e),
            ConfigError::MissingField(_) => None,
        }
    }
}

And then, because ? only works when there's a From impl to convert the inner error into your error type, you wrote a stack of those too:

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

impl From<ParseError> for ConfigError {
    fn from(e: ParseError) -> Self {
        ConfigError::Parse(e)
    }
}

Boilerplate scrolling past in an editor

That's a lot of typing for one error type, and a real codebase had dozens. The Display impl, the Error impl, the From impls, repeated everywhere. People wrote macros to cut it down. Everyone's macro was slightly different and slightly broken. Note also cause rather than source: the source method that returns the underlying error came later, and the older cause had a lifetime that made it awkward, so a lot of code just didn't bother chaining errors at all. You lost the original cause and got a top-level message with no trail back to what really went wrong.

error-chain, the first real attempt

The first library that genuinely helped was error-chain. It gave you a macro that generated the enum, the conversions, and crucially a backtrace and a chain of causes, which was the bit everyone had been skipping. It was a big improvement and a lot of well-known crates adopted it. But it was a macro that built an entire module, and when something went wrong inside it the compiler errors were baffling. You were debugging generated code you couldn't see. It also encouraged a particular structure that didn't always fit, and migrating off it later was a chore.

failure came next and tried to fix the ergonomics, introducing its own Fail trait as a replacement for the standard Error trait. That turned out to be a mistake: having a parallel trait split the ecosystem, so a failure-based error didn't play nicely with code expecting std::error::Error. The lesson the community took from that, and it was the right one, was to build on the standard trait rather than replace it.

what changed

Which is exactly what thiserror and anyhow did. thiserror is just a derive macro that writes the Display, the Error::source, and the From impls I showed above, from annotations on the enum. No new trait, no runtime, no magic module. anyhow::Error is a nicer Box<dyn Error> that's Send + Sync, carries a backtrace, and lets you add context with .context("loading config"). Between them they cover the two cases cleanly: a library wants typed errors callers can match on, a binary just wants to propagate and report.

A finished, tidy bit of error handling code

The reason it took a while to get here is that the standard library deliberately kept its error story minimal and let the ecosystem experiment. That's a feature, even if it was painful at the time. We tried Box<dyn Error>, hand-rolled enums, error-chain, failure, and a dozen bespoke macros, and the patterns that survived got distilled into two small, well-behaved crates that build on the standard trait. The boilerplate I wrote by hand back then is now generated correctly, every time, by code I never have to look at.

If you started with Rust in the last few years you've been spared all of this, and you should be glad. But it's worth knowing the history, because it explains why the modern tools are shaped the way they are. They're not clever. They're just the obvious thing, finally agreed upon, after everyone had a turn doing it the hard way.