How to Handle Multiple Error Types in Rust
• Rust's `?` operator requires all errors in a function to be the same type, but real applications combine libraries with different error types—use `Box<dyn Error>` for quick solutions or custom...
Key Insights
• Rust’s ? operator requires all errors in a function to be the same type, but real applications combine libraries with different error types—use Box<dyn Error> for quick solutions or custom enums for type safety
• The thiserror crate eliminates boilerplate when building libraries with custom error types, while anyhow provides ergonomic error handling for applications where callers don’t need to match on specific error variants
• Choose your error handling strategy based on context: libraries should expose structured errors (custom enums or thiserror), while applications benefit from anyhow’s flexibility and context chaining
The Multiple Error Type Problem
Every non-trivial Rust application eventually hits this wall: you’re writing a function that reads a file, parses its contents, and makes a network request. Each operation returns a different error type—std::io::Error, ParseIntError, reqwest::Error. The ? operator, which makes error propagation elegant, suddenly refuses to compile.
Here’s what that looks like:
use std::fs::File;
use std::io::Read;
fn process_config(path: &str) -> Result<u32, ???> {
let mut file = File::open(path)?; // Returns io::Error
let mut contents = String::new();
file.read_to_string(&mut contents)?; // Returns io::Error
let value: u32 = contents.trim().parse()?; // Returns ParseIntError
Ok(value)
}
What type goes in that Result<u32, ???>? The ? operator needs all errors to coerce to the same type, but io::Error and ParseIntError are completely different types. This is where most Rust beginners get stuck.
Understanding Result and the ? Operator
The Result<T, E> type is Rust’s primary error handling mechanism. The ? operator is syntactic sugar that propagates errors up the call stack. When you write some_operation()?, Rust essentially does this:
match some_operation() {
Ok(value) => value,
Err(e) => return Err(e.into()),
}
That .into() call is crucial—it attempts to convert the error into the function’s return error type using the From trait. This works seamlessly when all operations return the same error type:
use std::fs::File;
use std::io::{self, Read};
fn read_file(path: &str) -> Result<String, io::Error> {
let mut file = File::open(path)?; // io::Error
let mut contents = String::new();
file.read_to_string(&mut contents)?; // io::Error
Ok(contents)
}
But introduce a different error type, and the compiler balks. You need a way to unify these disparate error types.
The Quick Fix: Box
For applications and prototypes, Box<dyn std::error::Error> is the fastest path forward. This creates a trait object that can hold any error type implementing the Error trait:
use std::fs::File;
use std::io::Read;
use std::error::Error;
fn process_config(path: &str) -> Result<u32, Box<dyn Error>> {
let mut file = File::open(path)?;
let mut contents = String::new();
file.read_to_string(&mut contents)?;
let value: u32 = contents.trim().parse()?;
Ok(value)
}
This works because both io::Error and ParseIntError implement Error, and Rust provides automatic conversions. The downside? You’ve erased type information. Callers can’t match on specific error variants—they just get an opaque error box.
For quick scripts and application code where you’re logging errors rather than handling them programmatically, this is perfectly acceptable. For libraries where consumers need to handle specific errors differently, you need more structure.
Custom Error Enums: Full Control
Creating a custom error enum gives you complete control and type safety. You define variants for each error type you want to handle:
use std::io;
use std::num::ParseIntError;
use std::fmt;
#[derive(Debug)]
enum AppError {
Io(io::Error),
Parse(ParseIntError),
}
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),
}
}
}
impl std::error::Error for AppError {}
impl From<io::Error> for AppError {
fn from(err: io::Error) -> Self {
AppError::Io(err)
}
}
impl From<ParseIntError> for AppError {
fn from(err: ParseIntError) -> Self {
AppError::Parse(err)
}
}
Now the ? operator automatically converts errors through the From implementations:
fn process_config(path: &str) -> Result<u32, AppError> {
let mut file = File::open(path)?; // Converts io::Error via From
let mut contents = String::new();
file.read_to_string(&mut contents)?;
let value: u32 = contents.trim().parse()?; // Converts ParseIntError via From
Ok(value)
}
This approach is verbose but explicit. Callers can match on AppError::Io versus AppError::Parse and handle each differently. That’s valuable for libraries but tedious for applications.
Reducing Boilerplate with thiserror
The thiserror crate eliminates the boilerplate of custom error enums. Add it to Cargo.toml:
[dependencies]
thiserror = "1.0"
Then derive the Error trait and use attributes to generate implementations:
use thiserror::Error;
use std::io;
use std::num::ParseIntError;
#[derive(Error, Debug)]
enum AppError {
#[error("I/O error: {0}")]
Io(#[from] io::Error),
#[error("Failed to parse number: {0}")]
Parse(#[from] ParseIntError),
#[error("Invalid configuration: {0}")]
InvalidConfig(String),
}
The #[error("...")] attribute defines the Display implementation. The #[from] attribute generates the From trait implementations automatically. You get all the benefits of custom enums with a fraction of the code.
This is the gold standard for library error types. Your consumers get structured errors they can match on, and you don’t drown in boilerplate.
Application-Level Error Handling with anyhow
For applications where you’re primarily logging or displaying errors to users rather than programmatically handling specific variants, anyhow is the pragmatic choice.
Add it to your dependencies:
[dependencies]
anyhow = "1.0"
Then use anyhow::Result as your return type:
use anyhow::{Result, Context};
use std::fs::File;
use std::io::Read;
fn process_config(path: &str) -> Result<u32> {
let mut file = File::open(path)
.context("Failed to open config file")?;
let mut contents = String::new();
file.read_to_string(&mut contents)
.context("Failed to read config file")?;
let value: u32 = contents.trim().parse()
.context("Config value is not a valid number")?;
Ok(value)
}
The anyhow::Result<T> type is an alias for Result<T, anyhow::Error>. The anyhow::Error type can hold any error, similar to Box<dyn Error>, but with added features like context chaining.
The .context() method adds human-readable context to errors, creating a chain that gets displayed when you print the error. This is invaluable for debugging:
fn main() {
if let Err(e) = process_config("config.txt") {
eprintln!("Error: {:?}", e);
// Prints the full error chain with context
}
}
You can also use .with_context() with a closure for lazy evaluation:
let value: u32 = contents.trim().parse()
.with_context(|| format!("Invalid number in config file: {}", path))?;
Choosing Your Error Strategy
Here’s when to use each approach:
Box<dyn Error>: Quick scripts, prototypes, or when you’re genuinely treating all errors the same way. Minimal ceremony, maximum flexibility.
Custom Error Enums: Libraries where consumers need to handle specific error cases differently. Maximum type safety, maximum boilerplate.
thiserror: Libraries where you want the benefits of custom enums without the boilerplate. The professional choice for library authors.
anyhow: Applications where you’re logging or displaying errors rather than programmatically handling variants. Excellent for CLIs, web servers, and other end-user applications.
Here’s the same error handling with different approaches:
// Box<dyn Error> - simple but opaque
fn process_v1(path: &str) -> Result<u32, Box<dyn std::error::Error>> {
let mut file = File::open(path)?;
// ... rest of implementation
}
// thiserror - structured for libraries
#[derive(Error, Debug)]
enum AppError {
#[error("I/O error: {0}")]
Io(#[from] io::Error),
}
fn process_v2(path: &str) -> Result<u32, AppError> {
let mut file = File::open(path)?;
// ... rest of implementation
}
// anyhow - ergonomic for applications
fn process_v3(path: &str) -> anyhow::Result<u32> {
let mut file = File::open(path)
.context("Failed to open config")?;
// ... rest of implementation
}
The rule of thumb: use thiserror for libraries, anyhow for applications, and Box<dyn Error> when you’re just getting started or genuinely don’t need structured errors.
Don’t mix anyhow and thiserror in the same crate boundary. Libraries should expose concrete error types (via thiserror), and applications should consume them with anyhow. This separation keeps library APIs stable while giving applications maximum flexibility.