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

what rust error handling felt like before the crates showed up

A look back at the boilerplate of Rust error handling in the failure and error-chain era, and why the ergonomics still felt worth it.

Rust source with a long match expression on a Result

If you started writing Rust recently you might assume error handling has always been three lines of import and a ? operator that just works across every error type in your program. It hasn't. For a good while, getting a clean error story in a real application was a genuine chore, and I want to write down what it actually felt like before I forget.

the problem with the standard library alone

std::error::Error is a trait, and Box<dyn Error> will hold anything that implements it. That's fine for a quick script. The ? operator converts errors on the way up via From, so as long as every error converts into your function's return type, propagation is tidy.

The pain started the moment you wanted your own error type for a library, with proper variants, source chaining, and useful Display output. You ended up writing this sort of thing by hand:

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

impl std::fmt::Display for AppError {
    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
        match self {
            AppError::Io(e) => write!(f, "io error: {}", e),
            AppError::Parse(e) => write!(f, "parse error: {}", e),
            AppError::Config(s) => write!(f, "config error: {}", s),
        }
    }
}

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

impl From<std::io::Error> for AppError {
    fn from(e: std::io::Error) -> Self { AppError::Io(e) }
}
// ...and another From for every error you wanted ? to swallow.

Multiply that by every dependency that has its own error type, and a medium-sized program grew a whole module that did nothing but plumb errors into one enum. It worked, and it was honest about every failure path, but it was a lot of typing for something that felt like it should be free.

the crates that papered over it

Two contenders took the sting out, and both predate this post.

A diagram of error variants converging into one application error type

error-chain came first and leaned on a macro that generated the enum, the From impls and a backtrace-carrying type for you. It was clever and a bit magical, and when it broke, the macro errors were not fun to read. Then failure arrived with a cleaner design: a Fail trait, a Error type that worked rather like a boxed trait object with context, and #[derive(Fail)] so your variants stayed declarative. For application code where you mostly wanted "give me something printable and let me attach context", failure was a real step up.

Neither was perfect. failure introduced its own trait that didn't quite line up with std::error::Error, which caused friction at the boundaries with crates that used the standard one. You always felt slightly between two worlds.

why it was still worth it

The thing I keep coming back to is that even at its most verbose, Rust never let me pretend an error couldn't happen. Every ? was a decision I'd signed off on, every variant was a failure mode I'd named. Coming from languages where an exception could erupt from anywhere four frames down, that was a trade I'd take again, boilerplate and all.

I suspect the ergonomics will keep improving and at some point the hand-written From impls will look quaint. Good. But it's worth remembering that the safety was there first, and the convenience caught up to it, not the other way round.