Here is the problem in one sentence: a function that can fail three different ways has to return one type, and the three failures don't share a type. Your config read gives you an io::Error, your TOML parse gives you a toml::de::Error, and your own validation gives you a string. The compiler is entirely right to refuse all of that through a single Result, and at some point on every Rust project you have to decide how to make them agree.
There is no blessed answer for this yet. Box<dyn Error> exists, ? exists (it landed as the ? operator last year, and it's a genuine relief after the try! macro), and the rest is convention. So this is a tour of how I actually do it in early 2017, warts and all, because I suspect the warts are the interesting part.
the manual way
The honest baseline is to define your own enum and implement From for every error you want to absorb. It's verbose but there is nothing magic about it, and when you're learning it's worth doing once by hand so you know what the macros are hiding.
use std::io;
use std::fmt;
#[derive(Debug)]
enum ConfigError {
Io(io::Error),
Parse(toml::de::Error),
Invalid(String),
}
impl fmt::Display for ConfigError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match *self {
ConfigError::Io(ref e) => write!(f, "io error: {}", e),
ConfigError::Parse(ref e) => write!(f, "parse error: {}", e),
ConfigError::Invalid(ref s) => write!(f, "invalid config: {}", s),
}
}
}
impl std::error::Error for ConfigError {
fn description(&self) -> &str { "config error" }
}
impl From<io::Error> for ConfigError {
fn from(e: io::Error) -> Self { ConfigError::Io(e) }
}
impl From<toml::de::Error> for ConfigError {
fn from(e: toml::de::Error) -> Self { ConfigError::Parse(e) }
}
The payoff is that once those From impls exist, ? does the conversion for you silently:
fn load(path: &str) -> Result<Config, ConfigError> {
let mut s = String::new();
File::open(path)?.read_to_string(&mut s)?; // io::Error -> ConfigError
let cfg: Config = toml::from_str(&s)?; // toml error -> ConfigError
if cfg.workers == 0 {
return Err(ConfigError::Invalid("workers must be > 0".into()));
}
Ok(cfg)
}
That reads cleanly. The cost is everything above it. Every new error variant is a new From, a new Display arm, and a fresh round of boilerplate that has nothing to do with your actual logic. On a small library it's fine. On something with twenty fallible call sites into a dozen crates, it's a chore you come to resent.
There's a subtlety here that bites everyone once. From conversions chain, but they don't compose across two layers automatically. If crate A exposes its own error enum, and you absorb it into yours, you get one From. But if A's error was itself wrapping a io::Error, that inner cause is now buried inside A's Display string and you've lost the ability to ask "was this ultimately a file-not-found?" at your level. The hand-rolled enum gives you a flat list of variants, not a tree, and flattening a deep call stack into a flat enum throws away the nesting that would have told you where the failure actually started. That's the gap the chaining crates exist to fill, and it's why "just write an enum" stops being enough the moment your call graph gets a few layers deep.
box dyn error, the quick way
When I'm writing an application rather than a library, and nobody downstream is going to match on my error variants, I reach for the lazy option and don't apologise for it:
fn load(path: &str) -> Result<Config, Box<dyn Error>> {
let s = std::fs::read_to_string(path)?;
let cfg: Config = toml::from_str(&s)?;
Ok(cfg)
}
? will happily box anything that implements Error, so this just works and costs you nothing. The trade is that the caller gets an opaque Box they can't inspect, and you've thrown away the ability to handle different failures differently. For a CLI that's about to print the error and exit non-zero, who cares. For a library, it's rude: you're forcing your opacity on everyone who depends on you.
error-chain
The current community answer to the boilerplate is the error-chain crate, which generates the enum, the From impls, the Display, and a chaining mechanism from a macro. You write a declaration and it expands into all the tedium above:
error_chain! {
foreign_links {
Io(::std::io::Error);
Toml(::toml::de::Error);
}
errors {
Invalid(msg: String) {
description("invalid config")
display("invalid config: {}", msg)
}
}
}
The thing it adds that the hand-rolled version lacks is a backtrace and a chain of causes, so an error can carry "this failed because that failed because the other failed" rather than just the innermost message. That context is genuinely useful when you're staring at a log line at 2am wondering which of four file reads actually died.
It's not free. The macro generates a lot of code, the error type it produces is large, and the ergonomics around the generated Error/ErrorKind split take a while to internalise. I've used it in anger and mostly been glad of it, but I wouldn't call it pleasant, and the compile-time cost on a big crate is noticeable.
The other thing error-chain gives you, and the one I reach for most, is chain_err, which lets you attach context at the call site as the error bubbles up:
let cfg = load(path)
.chain_err(|| format!("failed to load config from {}", path))?;
The result is an error whose Display, when you walk the cause chain, reads top to bottom like a little story: "failed to load config from /etc/app.toml, caused by: parse error at line 12, caused by: unexpected character". That narrative is the single most useful thing for the person reading the log a fortnight later, and it's the part the bare ? operator can't give you because ? only ever forwards the error, it never annotates it. You get the leaf of the failure and none of the path that led there.
where this leaves me
My current rules of thumb. Libraries get a hand-written enum, because the people depending on me deserve a type they can match on, and the boilerplate is a one-time cost I pay so they don't have to. Applications get Box<dyn Error> or error-chain depending on whether I want the cause chain. And I keep an eye on the wider ecosystem, because this is plainly an area the language hasn't finished, and the pattern that wins will be the one that gives you the cause chain without the macro tax. For now you pick your poison: boilerplate, opacity, or a heavy macro. None of them is wrong, they just hurt in different places.