Here is the thing nobody tells you when you start writing Rust in anger: the language gives you a glorious type system for errors and then leaves you to do all the plumbing yourself. Result<T, E> is lovely in a function. It is misery the moment you have ten functions that each fail in a different way and you want to thread the lot of them up to main without writing a translation layer for every hop.
I have written the same error enum, by hand, more times than I care to admit. Let me show you the progression, because the way out of it is genuinely satisfying once it clicks, and right now there is no single crate that just does it for you. You assemble it from parts.
the naive version, which everyone writes first
The first thing you reach for is one big enum with a variant per failure mode:
#[derive(Debug)]
enum AppError {
Io(std::io::Error),
Parse(std::num::ParseIntError),
Config(String),
}
Then you implement From for each underlying error so that ? will do the conversion for you:
impl From<std::io::Error> for AppError {
fn from(e: std::io::Error) -> Self {
AppError::Io(e)
}
}
impl From<std::num::ParseIntError> for AppError {
fn from(e: std::num::ParseIntError) -> Self {
AppError::Parse(e)
}
}
And now ? works. You can write let f = File::open(path)?; in a function returning Result<T, AppError> and the conversion happens for free. The first time you see that, it feels like magic. The fifth time you write the boilerplate, it feels like a chore. By the tenth crate you are copy-pasting From impls between projects and quietly wondering whether the borrow checker has a sibling that polices your patience.
the std::error::Error trait, which is the real foundation
The piece people skip over is that the standard library already has a contract for "this is an error": the std::error::Error trait. If your type implements Display and Error, it slots into the wider ecosystem. The trouble is implementing it by hand is tedious, and Error::source() (which lets you walk the chain of causes) is easy to forget entirely.
use std::fmt;
impl fmt::Display for AppError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
AppError::Io(e) => write!(f, "io error: {}", e),
AppError::Parse(e) => write!(f, "parse error: {}", e),
AppError::Config(msg) => write!(f, "config error: {}", msg),
}
}
}
impl std::error::Error for AppError {}
There is a crate that takes the sting out of this part: failure, from the language team, which gives you a derive macro and a Fail trait that is a bit nicer than the std one. I have used it and it is fine. It pulls you slightly off the standard Error trait though, which always felt like a tax I would have to repay later. So for libraries I keep grinding out the enum, and for applications I do something else entirely.
the trick: a boxed trait object for the top level
This is the bit worth the price of admission. In application code, where you do not actually care about matching on the error variant and you just want to print it and exit non-zero, you do not need an enum at all. You return a boxed trait object:
fn run() -> Result<(), Box<dyn std::error::Error>> {
let cfg = std::fs::read_to_string("config.toml")?;
let port: u16 = cfg.trim().parse()?;
serve(port)?;
Ok(())
}
fn main() {
if let Err(e) = run() {
eprintln!("error: {}", e);
std::process::exit(1);
}
}
Because every standard error implements std::error::Error, and there is a blanket From<E: Error + 'static> impl into Box<dyn Error>, the ? operator just works across all of them with no From impls of your own. The io error, the parse error, all of it coerces into the box. You write zero boilerplate and you get a clean message at the top.
The catch is you have lost the ability to match on the specific cause. For an application that is usually fine: you log it, you exit, you move on. For a library it is not fine, because your callers want to handle different failures differently, and handing them a Box<dyn Error> is rude. So the rule I have landed on:
- Libraries get a hand-rolled enum implementing
Error, withFromimpls and a realsource(). Painful but correct. - Binaries get
Box<dyn Error>at the top and stop worrying about it.
That split has saved me a lot of grief. The enum work in libraries is still tedious, and I keep hoping someone packages up "a context-carrying boxed error for applications, and a derive for library enums" into one tidy crate so I can throw away half of this file. Until then, the boxed trait object is the closest thing to a free lunch the language offers here, and I reach for it on every binary I write.
If you take one thing away: implement std::error::Error, not your own ad-hoc trait, because the blanket impls in std are doing the heavy lifting and you want to stay inside their reach. The plumbing gets easier the moment you stop fighting it.