11. Advanced Error Handling
📋 Jump to Takeaways🎁 You know how to return Result and use ? for a single error type. But what happens when one function can fail in two completely different ways, and callers need to know which one happened?
Multiple Error Types
What happens when one function can fail in two different ways? You can't return Result<T, io::Error> when another call produces a parse error. Box<dyn Error> is the catch-all that accepts either one.
use std::error::Error;
use std::fs;
fn parse_port() -> Result<u16, Box<dyn Error>> {
let contents = fs::read_to_string("port.txt")?; // io::Error
let port = contents.trim().parse::<u16>()?; // ParseIntError
Ok(port) // success: u16 wrapped in Ok
}Let's break down Box<dyn Error>:
Error— a trait (like a Go interface). Any type that implements it qualifies.dyn— "dynamic dispatch." The concrete type is resolved at runtime, not compile time. Withoutdyn, Rust needs to know the exact type and its size at compile time.Box<T>— a smart pointer that allocates on the heap. Different error types have different sizes, and Rust needs fixed-size return types.Boxgives you a fixed-size pointer to any error on the heap.Ok(port)wraps the parsedu16in the success variant ofResult<u16, Box<dyn Error>>. It's the function's return value on the happy path, the same asreturn port, nilin Go.
So Box<dyn Error> means "a heap-allocated value of any type that implements the Error trait." It's Rust's equivalent of Go's error interface — a single return type that accepts any error.
You lose specific type info, but gain flexibility. This is fine for application code.
Custom Error Types
Box<dyn Error> is convenient but you lose type information — callers can't match on which error happened. For libraries or services where callers need to handle different failures differently, define your own error enum.
The idea: one enum with a variant for each kind of failure your function can produce. This is similar to how you'd define a custom error type in Go with a type switch, but the compiler enforces exhaustive handling.
Before we define it, let's clarify the types we'll use:
io::Error— a struct fromstd::io. It's what file and network operations return when they fail (file not found, permission denied, connection refused). Think of it like Go's*os.PathError.ParseIntError— a struct fromstd::num. It's what.parse::<u16>()returns when the string isn't a valid number. Like Go'sstrconv.ErrSyntax.String— you already know this one. We'll use it for custom messages.
Each of these becomes a variant in our error enum:
use std::fmt;
use std::num::ParseIntError;
use std::io;
#[derive(Debug)]
enum AppError {
Io(io::Error), // wraps a file/network error
Parse(ParseIntError), // wraps a number parsing error
Invalid(String), // your own custom variant (validation failure)
}To make this work as an error type, you need three things:
1. Implement Display so the error can be printed with {}:
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::Invalid(msg) => write!(f, "Invalid: {}", msg),
}
}
}2. Implement the Error trait (it requires Display + Debug, which we already have):
impl std::error::Error for AppError {}3. Implement From for each error type you want to convert automatically. This is what makes ? work — when ? encounters an io::Error, it calls From to convert it into your AppError:
impl From<io::Error> for AppError {
fn from(e: io::Error) -> Self {
AppError::Io(e)
}
}
impl From<ParseIntError> for AppError {
fn from(e: ParseIntError) -> Self {
AppError::Parse(e)
}
}Now ? automatically converts errors into your custom type:
fn load_config() -> Result<u16, AppError> {
let contents = fs::read_to_string("port.txt")?; // io::Error → AppError::Io
let port = contents.trim().parse::<u16>()?; // ParseIntError → AppError::Parse
Ok(port)
}And callers can match on specific failures:
match load_config() {
Ok(port) => println!("Port: {}", port),
Err(AppError::Invalid(msg)) => println!("Validation: {}", msg),
Err(AppError::Io(e)) => println!("File error: {}", e),
Err(AppError::Parse(e)) => println!("Bad format: {}", e),
}Error Propagation Chain
Here's how errors flow through a real application:
fn read_port() -> Result<u16, AppError> {
let text = fs::read_to_string("port.txt")?;
let port = text.trim().parse::<u16>()?;
if port < 1024 {
return Err(AppError::Invalid("port must be >= 1024".into()));
}
Ok(port)
}
fn start_server() -> Result<(), AppError> {
let port = read_port()?;
println!("Starting on port {}", port);
Ok(())
}
fn main() {
match start_server() {
Ok(()) => println!("Server running"),
Err(e) => eprintln!("Failed to start: {}", e),
}
}
// If port.txt is missing: "Failed to start: IO error: No such file or directory"
// If port.txt has "abc": "Failed to start: Parse error: invalid digit found in string"
// If port.txt has "80": "Failed to start: Invalid: port must be >= 1024"Each layer adds context. The top-level main makes one decision: print success or print the error.
Reminder: Ok and Err are just the two variants of Result<T, E>, no import needed. Ok(value) carries the success case, Err(error) carries the failure case. Every function above returns one or the other.
anyhow and thiserror
Writing all those From impls by hand gets tedious. The ecosystem has two crates that handle it:
thiserror, for libraries. Generates Display and From impls via derive macros. Add it first:
cargo add thiserrorThat adds it to Cargo.toml so the derive macros below are available:
use thiserror::Error;
#[derive(Debug, Error)]
enum AppError {
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
#[error("Parse error: {0}")]
Parse(#[from] std::num::ParseIntError),
#[error("Invalid: {0}")]
Invalid(String),
}That's it. All the boilerplate from before, gone.
anyhow, for applications. When you don't need callers to match on specific error types. Add it the same way:
cargo add anyhowWith that in place, .context() and anyhow::Result are ready to use:
use anyhow::{Context, Result};
fn read_port() -> Result<u16> {
let text = std::fs::read_to_string("port.txt")
.context("failed to read port.txt")?;
let port = text.trim().parse::<u16>()
.context("port.txt must contain a valid port number")?;
Ok(port)
}
fn main() -> Result<()> {
let port = read_port()?;
println!("Starting on port {}", port);
Ok(())
}
// If port.txt is missing:
// Error: failed to read port.txt
// Caused by: No such file or directoryanyhow::Result<T> is shorthand for Result<T, anyhow::Error>. The .context() method adds human-readable messages to the error chain. Use thiserror when you're writing a library others consume. Use anyhow when you're writing an application and just need good error messages.
Key Takeaways
Box<dyn Error>accepts any error type — quick and flexible for application code- Custom error enums give callers precise control over which failure happened
- Implementing
Display,Error, andFrommakes your custom type work with? thiserrorgenerates all the boilerplate with derive macros (use for libraries)anyhowsimplifies error handling with.context()(use for applications)- Error propagation with
?andFromlets errors flow up the call stack cleanly
🎁 What happens when you return a reference from a function? How does the compiler know the reference is still valid? Next up: lifetimes give the compiler proof that a reference won't outlive the data it points to.