Early Rust error handling usually looks like one of two extremes: unwrap() everywhere, or Box<dyn Error> everywhere. Both are fine for prototypes. Neither scales.
Here’s the approach I’ve settled on for production code.
The problem with Box<dyn Error>
fn read_config(path: &str) -> Result<Config, Box<dyn Error>> {
let text = fs::read_to_string(path)?;
let config: Config = serde_json::from_str(&text)?;
Ok(config)
}
This compiles, it propagates errors, and callers can’t do anything useful with the error. You can’t match on it, you can’t recover from specific variants, and the type erases all information.
thiserror for library code
For anything that’s a library (or should behave like one), use thiserror:
use thiserror::Error;
#[derive(Error, Debug)]
pub enum ConfigError {
#[error("config file not found at {path}")]
NotFound { path: String },
#[error("failed to parse config: {0}")]
Parse(#[from] serde_json::Error),
#[error("io error: {0}")]
Io(#[from] std::io::Error),
}
Now callers can match on ConfigError::NotFound and handle it specifically. The #[from] derive gives you automatic ? conversion.
anyhow for application code
For binary-side application code where you’re going to log the error and move on, anyhow is the right call:
use anyhow::{Context, Result};
fn main() -> Result<()> {
let config = read_config("config.json")
.context("failed to load startup config")?;
run(config)?;
Ok(())
}
anyhow::Result is just Result<T, anyhow::Error>. .context() wraps errors with human-readable strings. The error chain prints beautifully.
The rule
- Library:
thiserrorwith typed variants. Callers deserve to match on your errors. - Application:
anyhowat the edges, converting library errors as they cross the boundary. - Never:
Box<dyn Error>in library code,unwrap()outside of tests.
That’s it. No custom error trait impls, no macro wizardry. Two crates, clear rule.
Comments