05 · Error Handling Advanced¶
Level 1 covered Option, Result,
and a first taste of ?. That's enough for small scripts, but a real
program usually has several things that can fail — file I/O, parsing,
network calls — each with its own error type. This module covers how to
design a custom error type that unifies them, implement the standard
Error trait so your errors play nicely with the rest of the ecosystem, and
use ? across functions that return different error types.
The problem: ? requires matching error types¶
use std::num::ParseIntError;
fn double_from_str(input: &str) -> Result<i32, ParseIntError> {
let n = input.parse::<i32>()?;
Ok(n * 2)
}
fn main() {
match double_from_str("21") {
Ok(n) => println!("doubled: {n}"),
Err(e) => println!("error: {e}"),
}
}
That works for one error type. But the moment a function needs to use ?
on two different fallible operations with different error types — say,
parsing a number and then reading a file — the compiler stops you, because
? converts the error type via From, and there's no automatic conversion
between two unrelated error types.
use std::num::ParseIntError;
use std::fs;
use std::io;
fn read_and_double(path: &str) -> Result<i32, ParseIntError> {
let contents = fs::read_to_string(path)?;
// ERROR: `?` couldn't convert `io::Error` into `ParseIntError`
// the trait `From<io::Error>` is not implemented for `ParseIntError`
let n = contents.trim().parse::<i32>()?;
Ok(n * 2)
}
Defining a custom error type¶
The fix is a single enum that can represent any error the function might
produce, with a From conversion for each source error type:
use std::fmt;
use std::fs;
use std::num::ParseIntError;
#[derive(Debug)]
enum AppError {
Io(std::io::Error),
Parse(ParseIntError),
}
// Display -- the user-facing error message
impl fmt::Display for AppError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
AppError::Io(e) => write!(f, "I/O error: {e}"),
AppError::Parse(e) => write!(f, "parse error: {e}"),
}
}
}
// From impls -- these are what let `?` auto-convert into AppError
impl From<std::io::Error> for AppError {
fn from(e: std::io::Error) -> Self {
AppError::Io(e)
}
}
impl From<ParseIntError> for AppError {
fn from(e: ParseIntError) -> Self {
AppError::Parse(e)
}
}
fn read_and_double(path: &str) -> Result<i32, AppError> {
let contents = fs::read_to_string(path)?; // io::Error -> AppError via From
let n = contents.trim().parse::<i32>()?; // ParseIntError -> AppError via From
Ok(n * 2)
}
fn main() {
match read_and_double("does_not_exist.txt") {
Ok(n) => println!("doubled: {n}"),
Err(e) => println!("failed: {e}"),
}
}
This is the core pattern of real-world Rust error handling: one enum per
"unit of fallible work" (often per module or per binary), a From impl per
source error, and ? doing the conversion silently at every call site.
Implementing std::error::Error¶
Implementing the standard Error trait (on top of Debug and Display,
both of which it requires) makes your error type interoperable with the rest
of the ecosystem — things like Box<dyn Error> and libraries that expect
"any standard error" will accept it.
use std::error::Error;
use std::fmt;
#[derive(Debug)]
struct ConfigError {
message: String,
}
impl fmt::Display for ConfigError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "config error: {}", self.message)
}
}
impl Error for ConfigError {} // Debug + Display are the only requirements
fn load_config(value: &str) -> Result<i32, ConfigError> {
value.trim().parse::<i32>().map_err(|_| ConfigError {
message: format!("'{value}' is not a valid number"),
})
}
fn main() {
match load_config("not a number") {
Ok(n) => println!("config value: {n}"),
Err(e) => println!("{e}"),
}
}
Box<dyn Error> — the quick, flexible option¶
Writing a full custom enum for every function is sometimes overkill,
especially in main or small utilities. Box<dyn Error> lets a function
return "any error type at all," at the cost of losing the ability to
match on specific variants — you get a trait object instead of a concrete
enum:
use std::error::Error;
use std::fs;
fn read_and_double(path: &str) -> Result<i32, Box<dyn Error>> {
let contents = fs::read_to_string(path)?; // io::Error auto-boxes via `?`
let n = contents.trim().parse::<i32>()?; // ParseIntError auto-boxes too
Ok(n * 2)
}
fn main() -> Result<(), Box<dyn Error>> {
// `main` itself can return `Result` -- a returned `Err` prints via
// `Debug` and exits with a non-zero status, which is handy for CLIs.
match read_and_double("missing.txt") {
Ok(n) => println!("doubled: {n}"),
Err(e) => println!("failed: {e}"),
}
Ok(())
}
Rule of thumb: use Box<dyn Error> for application code (binaries, main,
one-off scripts) where callers just want to print and exit on failure; use a
custom enum for library code, where callers might want to match on
which error happened and react differently.
.map_err() for one-off conversions¶
When you don't want a whole custom type, .map_err() transforms the error
variant of a Result inline — useful for adding context or converting to a
simpler type at a single call site:
fn parse_port(input: &str) -> Result<u16, String> {
input
.trim()
.parse::<u16>()
.map_err(|e| format!("invalid port '{input}': {e}"))
}
fn main() {
match parse_port("70000") {
Ok(port) => println!("port: {port}"),
Err(e) => println!("{e}"),
}
}
Panicking vs returning Result¶
Not every failure should be a Result — the two tools serve different
purposes:
| Situation | Use |
|---|---|
| Caller can reasonably recover (bad input, missing file, network hiccup) | Result<T, E> |
| A bug/invariant violation that should never happen in correct code | panic! / .expect("why") |
| Library code, uncertain how the caller wants to react | Result<T, E> (let the caller decide) |
| Prototype/example code where clarity matters more than robustness | .unwrap() is acceptable |
panic! unwinds the stack and (by default) terminates the thread — it's for
"this program has a bug," not "this input was invalid." A library that
panics on bad user input instead of returning Err takes away the caller's
ability to handle it gracefully.
How It Actually Works¶
panic! triggers stack unwinding by default on most platforms: it walks
back up the call stack frame by frame, running each frame's destructors
(Drop impls) as it goes, exactly the way a normal function return would,
until it reaches either a catch_unwind boundary or the top of the thread,
which then terminates. This is why a panic doesn't necessarily leak memory
or leave files half-written — your Drop implementations (closing a file,
unlocking a mutex, freeing a buffer) still run during the unwind, the same
cleanup code that would run on normal scope exit. You can switch a binary to
panic = "abort" in Cargo.toml, which skips unwinding entirely and kills
the process immediately — smaller binary, no unwind tables, but no cleanup
either; that's a deliberate size/behavior tradeoff, not a bug.
impl From<X> for AppError plus ? is the mechanism, not just convention:
recall from Module 7 that ? desugars to return Err(From::from(e)), so
writing From impls for each underlying error type (io::Error,
ParseIntError, ...) is literally what lets a single ? at each call site
convert into your unified AppError without you writing .map_err(...)
everywhere. The trait dispatch here is resolved entirely at compile
time — the compiler picks the concrete From impl matching the error type in
scope, so there's no runtime cost to this error-unification pattern beyond
constructing the enum variant itself.
Cheat sheet¶
| Tool | Use when |
|---|---|
enum AppError { ... } + impl From<X> for AppError |
Multiple error sources, want to match on which one |
impl std::error::Error for E |
Making your error type interoperate with the ecosystem |
Box<dyn Error> |
Application code, don't need to distinguish error variants |
.map_err(\|e\| ...) |
One-off conversion or adding context at a single call site |
? |
Propagate any error convertible via From into the function's return type |
panic! / .expect() |
Unrecoverable bugs, not user-facing failures |
Exercise¶
Define enum CalcError { DivByZero, NegativeSqrt } implementing Debug,
Display, and std::error::Error. Write
fn safe_divide(a: f64, b: f64) -> Result<f64, CalcError> and
fn safe_sqrt(n: f64) -> Result<f64, CalcError> (returning NegativeSqrt
for n < 0.0, otherwise Ok(n.sqrt())). Write a function
fn compute(a: f64, b: f64) -> Result<f64, CalcError> that divides a by
b with ? and then takes the square root of the result with ?, and call
it with a few inputs in main, printing either the result or the error.