Error Handling Patterns
Rust error handling with Result, the ? operator, custom error types, thiserror, anyhow, and common patterns
Rust has no exceptions. Errors are values returned via Result<T, E>. The compiler ensures you handle them. This reference covers the patterns you’ll use every day.
The two error types
// Option<T> — something may be absent
fn find_user(id: u32) -> Option<User> {
users.iter().find(|u| u.id == id).cloned()
}
// Result<T, E> — an operation may fail
fn read_config(path: &str) -> Result<Config, std::io::Error> {
let data = std::fs::read_to_string(path)?;
Ok(parse_config(&data))
}Guideline: Use Option when absence is expected (search, lookup). Use Result when failure is unexpected or carries information.
The ? operator
The ? operator unwraps Ok or early-returns Err. It’s the idiomatic way to chain fallible operations.
// Without ? — verbose
fn load_user(path: &str) -> Result<User, std::io::Error> {
let data = match std::fs::read_to_string(path) {
Ok(d) => d,
Err(e) => return Err(e),
};
let user: User = match serde_json::from_str(&data) {
Ok(u) => u,
Err(e) => return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData, e)),
};
Ok(user)
}
// With ? — concise
fn load_user(path: &str) -> Result<User, Box<dyn std::error::Error>> {
let data = std::fs::read_to_string(path)?;
let user: User = serde_json::from_str(&data)?;
Ok(user)
}The ? operator also performs automatic conversion via the From trait. If the function returns Result<_, MyError>, any Err type that implements From<OriginalError> will be converted automatically.
Custom error types
For libraries, define your own error enum:
use std::fmt;
#[derive(Debug)]
pub enum AppError {
Io(std::io::Error),
Parse(serde_json::Error),
NotFound(String),
InvalidInput { field: String, reason: String },
}
impl std::fmt::Display for AppError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
AppError::Io(e) => write!(f, "IO error: {}", e),
AppError::Parse(e) => write!(f, "Parse error: {}", e),
AppError::NotFound(s) => write!(f, "Not found: {}", s),
AppError::InvalidInput { field, reason } => {
write!(f, "Invalid {}: {}", field, reason)
}
}
}
}
impl std::error::Error for AppError {}
// From implementations for automatic ? conversion
impl From<std::io::Error> for AppError {
fn from(e: std::io::Error) -> Self { AppError::Io(e) }
}
impl From<serde_json::Error> for AppError {
fn from(e: serde_json::Error) -> Self { AppError::Parse(e) }
}
// Now ? works seamlessly
fn load_user(path: &str) -> Result<User, AppError> {
let data = std::fs::read_to_string(path)?; // io::Error -> AppError
let user: User = serde_json::from_str(&data)?; // json::Error -> AppError
Ok(user)
}
fn find_user(id: u32) -> Result<User, AppError> {
users.iter().find(|u| u.id == id)
.cloned()
.ok_or(AppError::NotFound(format!("User {}", id)))
}thiserror — derive Error
The thiserror crate eliminates boilerplate for custom error types:
use thiserror::Error;
#[derive(Error, Debug)]
pub enum AppError {
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
#[error("Parse error: {0}")]
Parse(#[from] serde_json::Error),
#[error("Not found: {0}")]
NotFound(String),
#[error("Invalid {field}: {reason}")]
InvalidInput { field: String, reason: String },
#[error("Permission denied for {resource}")]
PermissionDenied { resource: String },
}#[from] automatically generates the From implementation, enabling ? conversion.
anyhow — application error handling
For applications (not libraries), anyhow simplifies error handling:
use anyhow::{Context, Result, bail};
fn load_config(path: &str) -> Result<Config> {
let data = std::fs::read_to_string(path)
.context(format!("Failed to read {}", path))?;
let config: Config = toml::from_str(&data)
.context("Failed to parse config")?;
if config.port == 0 {
bail!("Invalid port number: 0");
}
Ok(config)
}
fn main() -> Result<()> {
let config = load_config("config.toml")?;
run(config)?;
Ok(())
}Guideline: Use thiserror for library crates (callers need to match on error variants). Use anyhow for application code (you mainly need to log/display errors).
Common patterns
ok_or — convert Option to Result
fn get_user(id: u32) -> Result<User, AppError> {
users.iter().find(|u| u.id == id)
.cloned()
.ok_or(AppError::NotFound(format!("User {}", id)))
}map_err — transform the error
fn read_data(path: &str) -> Result<Data, AppError> {
std::fs::read_to_string(path)
.map_err(|e| AppError::Io(e))?;
// With From impl, just use ? instead:
let data = std::fs::read_to_string(path)?;
Ok(parse(data))
}unwrap and expect — for prototyping and tests
// unwrap — panics on Err/None, use in tests only
let data = std::fs::read_to_string("test.txt").unwrap();
// expect — panics with a message
let data = std::fs::read_to_string("test.txt")
.expect("test file should exist");
// In production code, use ? or proper error handling
Combining Results
// Chain with and_then
fn process(input: &str) -> Result<Output, AppError> {
parse_input(input)
.and_then(validate)
.and_then(transform)
}
// Combine multiple results
fn load_all() -> Result<(Config, Data, Cache), AppError> {
let config = load_config("config.toml")?;
let data = load_data("data.bin")?;
let cache = load_cache("cache.db")?;
Ok((config, data, cache))
}Fallible constructors
struct Email(String);
impl Email {
fn new(address: &str) -> Result<Self, AppError> {
if address.contains('@') {
Ok(Email(address.to_string()))
} else {
Err(AppError::InvalidInput {
field: "email".into(),
reason: "must contain @".into(),
})
}
}
}
let email = Email::new("[email protected]")?;The “try” block pattern (Rust 2024+)
// In nightly/2024 edition, try blocks evaluate to Result:
// let result: Result<i32, _> = try {
// let data = std::fs::read_to_string("file.txt")?;
// let num: i32 = data.trim().parse()?;
// num * 2
// };
Choosing the right approach
| Situation | Use |
|---|---|
| Value may be absent | Option<T> |
| Operation may fail | Result<T, E> |
| Library with typed errors | Custom error enum + thiserror |
| Application error handling | anyhow |
| Tests and prototyping | .unwrap() / .expect() |
| Production code | ? operator + proper error types |
| Need to attach context | .context() (anyhow) |
| Need to match on errors | Custom enum (thiserror) |