Understanding Rust Error Handling
A practical guide to error handling in Rust — from Result and Option to anyhow and thiserror, with real-world patterns.
Understanding Rust Error Handling#
Error handling is one of Rust's greatest strengths. No exceptions, no null pointer surprises — just explicit types that force you to handle every failure path. Once it clicks, you'll wonder why other languages ever thought exceptions were a good idea.
The basics: Result and Option#
Everything starts with two enums:
enum Result<T, E> {
Ok(T),
Err(E),
}
enum Option<T> {
Some(T),
None,
}Result is for operations that can fail. Option is for values that might not exist. Simple, composable, and type-checked at compile time.
The ? operator#
The question mark operator is syntactic sugar for early returns on errors:
// Without `?`
fn read_config() -> Result<Config, io::Error> {
let content = match fs::read_to_string("config.toml") {
Ok(c) => c,
Err(e) => return Err(e),
};
// ...parse content
}
// With `?` — same behavior, less noise
fn read_config() -> Result<Config, io::Error> {
let content = fs::read_to_string("config.toml")?;
// ...parse content
}Custom error types with thiserror#
For libraries, define explicit error types:
use thiserror::Error;
#[derive(Error, Debug)]
enum AppError {
#[error("Failed to read config: {0}")]
ConfigRead(#[from] io::Error),
#[error("Invalid config format: {0}")]
ConfigParse(#[from] toml::de::Error),
#[error("Missing required field: {field}")]
MissingField { field: String },
}thiserror generates the Display and From implementations automatically. Your callers get precise, matchable error variants.
Application errors with anyhow#
For applications (not libraries), anyhow simplifies everything:
use anyhow::{Context, Result};
fn load_and_process() -> Result<Output> {
let config = fs::read_to_string("config.toml")
.context("Failed to read config file")?;
let parsed: Config = toml::from_str(&config)
.context("Failed to parse config")?;
process(parsed)
.context("Processing failed")
}The .context() method adds human-readable messages to the error chain. When the error is printed, you get a full trace:
Error: Processing failed
Caused by:
0: Failed to validate input
1: missing required field "name"
Pattern: converting between error types#
Use map_err when you need to convert one error type to another:
fn fetch_data(url: &str) -> Result<Data, AppError> {
reqwest::blocking::get(url)
.map_err(|e| AppError::Network(e.to_string()))?
.json::<Data>()
.map_err(|e| AppError::Parse(e.to_string()))
}When to use which#
| Situation | Use |
|-----------|-----|
| Library code | thiserror with custom error enum |
| Application code | anyhow with .context() |
| Quick prototype | anyhow everywhere |
| Performance-critical path | Custom error type (no heap allocation) |
| Optional values | Option<T> with .ok_or() to convert to Result |
The golden rule#
Make invalid states unrepresentable.
If a function can fail, return Result. If a value might not exist, use Option. Never panic in library code. Let the compiler be your safety net.
Rust's error handling takes more upfront effort than try/catch, but the payoff is enormous: every error is visible in the type signature, and the compiler won't let you forget to handle it.