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

error handling before anyhow existed

A look back at how Rust error handling actually felt before anyhow and thiserror, and why the boilerplate we wrote then taught useful habits.

A screen of Rust source code

anyhow and thiserror have become so much the default answer to "how do I do errors in Rust" that it is easy to forget they are recent arrivals. They turned up in 2019 and very quickly ate the world, deservedly. But I wrote Rust for a while before them, and the way we handled errors then was different enough that it is worth remembering, partly out of nostalgia and partly because the old pain explains why the new tools are shaped the way they are.

the world of Box

The first thing every newcomer reached for, then and now, was the trait object:

fn run() -> Result<(), Box<dyn std::error::Error>> {
    let config = std::fs::read_to_string("config.toml")?;
    let parsed: Config = toml::from_str(&config)?;
    do_the_thing(parsed)?;
    Ok(())
}

This worked because of a blanket From impl: any type implementing Error could be coerced into a Box<dyn Error>, so ? Just Worked across wildly different error types. For a binary, for the top of a call stack, for a quick tool, this was and still is perfectly fine. It is essentially what anyhow::Error gives you, only without the backtraces, the context chaining, and the nicer ergonomics.

The trouble started the moment you wanted to do anything other than print the error and exit. Box<dyn Error> is opaque. You cannot match on it to decide whether the failure was "file not found, fall back to defaults" or "config is garbage, refuse to start". You had downcasting, and downcasting by concrete type is exactly as pleasant as it sounds.

A circuit-board pattern standing in for plumbing

so we wrote enums by hand

For libraries, where you actually owe your callers something they can match on, the answer was a hand-rolled enum. And there was a lot of hand to roll.

#[derive(Debug)]
enum ConfigError {
    Io(std::io::Error),
    Parse(toml::de::Error),
    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(s) => write!(f, "missing field: {}", s),
        }
    }
}

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

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

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

That is a lot of typing for three variants, and every new error source meant another From impl and another Display arm. Miss one and ? stops compiling on you with a From not satisfied error that newcomers found baffling. This is precisely the boilerplate that thiserror later generated for you from a few #[error(...)] and #[from] attributes. Look at what thiserror expands to and it is almost exactly the block above, which is the nicest kind of magic: the kind you could have written yourself.

the crates we used to lean on

We were not entirely without help. error-chain was the big one, a macro-heavy framework that built an error type, a Result alias, and a context mechanism for you. It did the job, and plenty of production code shipped on it, but the macro was opaque and the generated types leaked into your public API in ways that were hard to reason about. failure came along as the more modern take, with its Fail trait and a failure::Error catch-all that was the spiritual ancestor of anyhow::Error. For a stretch, failure looked like the future.

What changed everything was the language quietly getting better underneath all of this. Once the Error trait and ? settled down, you no longer needed a whole framework. You needed two small, focused crates: one to generate the enum boilerplate for libraries, one to provide a good opaque type for applications. thiserror and anyhow, both from David Tolnay, drew exactly that line, and the community exhaled.

what the boilerplate taught us

I would not go back. Writing those From and Display impls by hand was tedious and I am glad a macro does it now. But there was a lesson buried in the tedium that the convenient tools make it easy to skip.

Writing the enum forced you to enumerate, literally, every way your function could fail. You had to think about whether a missing field was the same kind of problem as a malformed file, and whether your caller would ever want to tell them apart. That up-front thinking produced better error APIs than the modern reflex of slapping anyhow::Result on everything and moving on.

So my actual advice in 2020 is the boring middle path everyone eventually arrives at. Reach for anyhow at the top of the stack in your binaries, where nobody downstream needs to match on your errors. Reach for thiserror in your libraries, where they do. And when you write that thiserror enum, write it as deliberately as we used to write the whole thing by hand, because the macro saves you the typing, not the thinking.