Rust Error Handling
Custom Errors with thiserror
thiserror generates Display and Error impls from derive macros.
[dependencies]
thiserror = "2"
use thiserror::Error;
#[derive(Debug, Error)]
pub enum AppError {
#[error("user {id} not found")]
UserNotFound { id: u64 },
#[error("database error: {0}")]
Database(#[from] sqlx::Error),
#[error("validation failed on field '{field}': {message}")]
Validation { field: String, message: String },
#[error("external service '{service}' returned {status}")]
ExternalService { service: String, status: u16 },
#[error(transparent)]
Unexpected(#[from] anyhow::Error),
}
Application Errors with anyhow
anyhow is ideal for application code where you need rich context but don't care about the exact type.
[dependencies]
anyhow = "1"
use anyhow::{Context, Result, bail, ensure};
fn load_config(path: &str) -> Result<Config> {
let content = std::fs::read_to_string(path)
.with_context(|| format!("failed to read config from {path}"))?;
let config: Config = toml::from_str(&content)
.context("failed to parse config as TOML")?;
ensure!(config.port > 1024, "port must be > 1024, got {}", config.port);
if config.workers == 0 {
bail!("workers must be at least 1");
}
Ok(config)
}
The ? Operator
? returns early on error, applying From conversion if needed.
fn process() -> Result<Output, AppError> {
let data = read_file("input.txt")?;
let parsed = parse_json(&data)?;
let result = transform(parsed)?;
Ok(result)
}
// ? works with Option via .ok_or / .ok_or_else
fn find_setting(name: &str) -> Result<String, AppError> {
settings()
.get(name)
.cloned()
.ok_or_else(|| AppError::Validation {
field: name.to_string(),
message: "setting not found".into(),
})
}
Error Hierarchy (Library vs. Application)
// Library crates: use thiserror for typed, structured errors
#[derive(Debug, thiserror::Error)]
pub enum MyLibError {
#[error("invalid input: {0}")]
InvalidInput(String),
#[error(transparent)]
Io(#[from] std::io::Error),
}
// Application code: use anyhow for ergonomic context chains
fn run() -> anyhow::Result<()> {
my_lib::process("data.txt")
.context("processing step failed")?;
Ok(())
}
Converting Between Error Types
// map_err for targeted conversion
fn parse_id(s: &str) -> Result<u64, AppError> {
s.parse::<u64>().map_err(|e| AppError::Validation {
field: "id".into(),
message: e.to_string(),
})
}
Error Handling in Main
fn main() -> anyhow::Result<()> {
let config = load_config("config.toml")?;
run(config)?;
Ok(())
}
// With custom exit codes
fn main() {
if let Err(e) = run() {
eprintln!("Error: {e:#}"); // {:#} prints the full error chain
std::process::exit(1);
}
}
Common Anti-Patterns
- Using
Box<dyn Error>in library APIs — prevents callers from matching on specific errors unwrap()/expect()in production code — always propagate or handle errors explicitly- Losing error context with bare
?— use.context()fromanyhowto add what operation failed - Returning
Stringas error type — hard for callers to programmatically handle - Swallowing errors in closures — log or propagate; never silently discard