Ergonomic Error Handling in Rust with Custom Error Types and the `?` Operator
Why custom errors matter
Rust's Result type forces you to confront failure at compile time, but the ergonomics of propagating errors can still feel clunky when you have many different failure modes. In a recent project I was parsing a TOML configuration file, validating the values, and then constructing a runtime AppConfig struct. The parsing step can fail because of malformed TOML, missing keys, or values that don't satisfy business rules (e.g., a port number out of range). Throwing all of those into a single Box works, but you lose the ability to handle each case differently downstream.
The pattern: an error enum + From implementations
Define an enum that captures every distinct failure you care about. Then implement From for each underlying error type. Once those implementations exist, the ? operator automatically converts the source error into your enum variant, keeping call sites clean.
use std::fmt;
use std::num::ParseIntError;
use toml::de::Error as TomlError;
/// All ways the configuration loading can fail.
#[derive(Debug)]
enum ConfigError {
/// The TOML text couldn't be parsed.
Parse(TomlError),
/// A required key was missing.
MissingKey(&'static str),
/// A numeric value was out of the allowed range.
OutOfRange { key: &'static str, value: i64 },
/// Any other conversion problem (e.g. string → int).
Conversion(ParseIntError),
}
impl fmt::Display for ConfigError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
ConfigError::Parse(e) => write!(f, "TOML parse error: {}", e),
ConfigError::MissingKey(k) => write!(f, "missing required key: {}", k),
ConfigError::OutOfRange { key, value } =>
write!(f, "{} = {} is out of allowed range", key, value),
ConfigError::Conversion(e) => write!(f, "conversion error: {}", e),
}
}
}
impl std::error::Error for ConfigError {}
// Automatic conversion from the two external error types.
impl From for ConfigError {
fn from(e: TomlError) -> Self {
ConfigError::Parse(e)
}
}
impl From for ConfigError {
fn from(e: ParseIntError) -> Self {
ConfigError::Conversion(e)
}
}
Using the enum in a fallible function
With the From impls in place, any function that returns Result can freely use ? on a TomlError or a ParseIntError without manual mapping.
fn load_config(toml_str: &str) -> Result {
// toml::from_str returns Result
let value: toml::Value = toml::from_str(toml_str)?;
// Helper to extract a required integer with range checking.
fn get_port(table: &toml::value::Table, key: &str) -> Result {
let raw = table.get(key).ok_or(ConfigError::MissingKey(key))?;
let as_int: i64 = raw.as_integer().ok_or(ConfigError::MissingKey(key))?;
if !(1..=65535).contains(&as_int) {
return Err(ConfigError::OutOfRange { key, value: as_int });
}
Ok(as_int as u16)
}
let table = value.as_table().ok_or(ConfigError::MissingKey("root"))?;
let port = get_port(table, "server.port")?;
let workers = table
.get("server.workers")
.and_then(|v| v.as_integer())
.unwrap_or(4) as usize;
Ok(AppConfig { port, workers })
}
Why this beats Box
- Exhaustive matching – callers can
matchonConfigErrorand handle each variant differently (e.g., show a user‑friendly message forMissingKeybut log the raw TOML error forParse). - Zero‑cost abstraction – the enum is a plain Rust type; no heap allocation unless you deliberately box it.
- Composable – adding a new failure mode only requires a new variant and a
Fromimpl; existing call sites continue to compile.
Tip: If you have many error enums across crates, consider the
thiserrorcrate. It derivesDisplay,Error, andFromautomatically, reducing boilerplate while keeping the same ergonomics.
Real‑world payoff
In the configuration service I mentioned, the CLI entry point now looks like this:
fn main() -> Result<(), ConfigError> {
let raw = std::fs::read_to_string("config.toml")?;
let cfg = load_config(&raw)?;
run_server(cfg)
}
All error paths bubble up to main, which prints the Display implementation automatically. The binary exits with a non‑zero code, and the user sees a clear message like missing required key: server.port instead of a generic "failed to parse config".
When to reach for this pattern
- You have a bounded set of failure modes that callers might want to distinguish.
- You want to keep the happy path free of
matchnoise. - You prefer compile‑time guarantees over dynamic dispatch.
If the error surface is truly open‑ended (e.g., a plug‑in system where third parties define their own errors), a trait object is still the right tool. But for the vast majority of internal libraries and applications, a custom error enum with From conversions gives you the best of both worlds: ergonomic propagation and precise handling.