I was helping someone untangle their error types last week and realised I take the modern Rust error story completely for granted. You reach for thiserror in a library, anyhow in an application, and you barely think about it. It was not always this tidy. Rust error handling has been through a few genuinely awkward years, and remembering them is the best way to understand why the current setup is shaped the way it is.
The bare std era
In the early days you had the standard library and not much else. The Error trait existed, the ? operator did not yet (it was try! for a long time), and the common move for a function that could fail in several different ways was to return a boxed trait object:
fn load_config(path: &Path) -> Result<Config, Box<dyn std::error::Error>> {
let text = std::fs::read_to_string(path)?;
let cfg = toml::from_str(&text)?;
Ok(cfg)
}
Box<dyn Error> works because any concrete error that implements the Error trait can be coerced into it, and ? will do that coercion for you. For a quick program this is fine, and honestly it is still fine for a quick program today. The problem is what you lose. The caller gets back an opaque box. They cannot match on it to handle one kind of failure differently from another without downcasting, which is ugly and fragile. You have thrown away the type information at exactly the moment a caller might have wanted it.
The hand-rolled enum era
So the disciplined approach, the one every serious library converged on, was to define your own error enum with a variant per failure mode. This is still the right idea. It is the boilerplate that was the problem.
You wrote the enum, then you wrote a Display impl by hand, then you implemented the Error trait, then you wrote a From impl for every underlying error type so that ? would convert into your enum automatically. For a module with four or five error sources that is a wall of mechanical code that does nothing interesting:
enum ConfigError {
Io(std::io::Error),
Parse(toml::de::Error),
}
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),
}
}
}
impl std::error::Error for ConfigError {}
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 the correct, idiomatic, type-preserving way to do it, and look at how much of it is typing. Every new error source meant another variant, another match arm, another From. People got it subtly wrong constantly: forgetting to forward the source error so the cause chain broke, or writing Display text that did not match the variant.
Failure, the bridge that did not quite last
Around 2018 a crate called failure appeared and was, for a while, the recommended answer. It gave you a Fail trait and a derive to cut the boilerplate, plus an Error type that could wrap anything and carried a backtrace. For a year or two it was everywhere.
It had a flaw, though: it introduced its own Fail trait that sat alongside the standard std::error::Error rather than building on it. That meant a slightly awkward seam between code using failure and code using the standard trait, and as the standard Error trait itself improved, that parallel universe started to feel like the wrong bet. failure did its job as a bridge and was then quietly superseded, which is honestly the best fate a stopgap crate can hope for.
Why the modern split is right
What replaced all of it is two crates that each do one half of the job, and the reason it works is that it finally took seriously a distinction the earlier approaches blurred: libraries and applications want different things from an error.
thiserror is for libraries. It derives all that Display, Error and From boilerplate from a tidy annotated enum, so you still get a precise, matchable, type-preserving error, the correct thing, without writing the wall of code by hand:
#[derive(thiserror::Error, Debug)]
enum ConfigError {
#[error("io error")]
Io(#[from] std::io::Error),
#[error("parse error")]
Parse(#[from] toml::de::Error),
}
anyhow is for applications. At the top of a program you usually do not want to match on errors, you want to propagate them, attach context, and print something useful when they finally surface. anyhow::Error is a smart boxed error that any failure converts into, with a .context() method to add a human breadcrumb at each layer, and a backtrace when you ask for one.
The insight that makes this the durable answer is that it is not one tool, it is two, aimed at two genuinely different callers. A library should hand back a precise type the caller can reason about. An application should be free to throw that precision away in favour of context and convenience, because nothing above it is going to match on the error anyway, it is going to log it and exit. The old single-bucket approaches forced everyone to pick one of those and suffer the other. The split lets each side have what it actually wants.
So when I see Box<dyn Error> in someone's application code today I do not correct it, it is fine. But understanding the road that got us from there to the thiserror-and-anyhow split is the difference between cargo-culting two crate names and knowing why they exist. They are not magic. They are the boilerplate we all used to write by hand, finally automated, and split along the one line that actually matters.