Introduction
Rust's error handling is one of its most distinctive features. Unlike exceptions in other languages, Rust makes errors explicit through the type system, forcing developers to handle failure cases. This approach leads to more robust software but requires understanding Result types, error conversion, and the ecosystem of error-handling crates.
The Result Type
Basic Result Usage
fn divide(a: f64, b: f64) -> Result<f64, String> {
if b == 0.0 {
Err("Cannot divide by zero".to_string())
} else {
Ok(a / b)
}
}
fn main() {
match divide(10.0, 2.0) {
Ok(result) => println!("Result: {}", result),
Err(e) => println!("Error: {}", e),
}
// Use if let for simple cases.
if let Ok(result) = divide(10.0, 0.0) {
println!("Result: {}", result);
} else {
println!("Division failed");
}
}
The ? Operator
fn read_config() -> Result<Config, std::io::Error> {
let contents = std::fs::read_to_string("config.json")?; // Returns early on error.
let config: Config = serde_json::from_str(&contents)?; // Requires a From impl.
Ok(config)
}
unwrap and expect
// unwrap: Panic on error (avoid in production).
let file = std::fs::read_to_string("config.json").unwrap();
// expect: Panic with a message.
let file = std::fs::read_to_string("config.json")
.expect("Failed to read config file");
// Use only when:
// - You know the error is impossible.
// - The error would indicate a bug.
// - In examples/prototypes.
Custom Error Types
Simple Enum Errors
#[derive(Debug)]
pub enum AppError {
NotFound,
PermissionDenied,
InvalidInput(String),
DatabaseError(String),
}
impl std::fmt::Display for AppError {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
match self {
AppError::NotFound => write!(f, "Resource not found"),
AppError::PermissionDenied => write!(f, "Permission denied"),
AppError::InvalidInput(msg) => write!(f, "Invalid input: {}", msg),
AppError::DatabaseError(msg) => write!(f, "Database error: {}", msg),
}
}
}
impl std::error::Error for AppError {}
Using thiserror
The thiserror crate simplifies custom error definitions:
use thiserror::Error;
#[derive(Error, Debug)]
pub enum AppError {
#[error("Resource not found: {0}")]
NotFound(String),
#[error("Permission denied")]
PermissionDenied,
#[error("Invalid input: {message}")]
InvalidInput { message: String },
#[error("Database error")]
Database(#[from] sqlx::Error),
#[error("IO error")]
Io(#[from] std::io::Error),
#[error("Configuration error: {0}")]
Config(#[from] config::ConfigError),
}
// Now you can use ? with automatic conversion.
fn get_user(id: i32) -> Result<User, AppError> {
let user = db.query("SELECT * FROM users WHERE id = ?", id)?; // sqlx::Error -> AppError
user.ok_or_else(|| AppError::NotFound(format!("User {}", id)))
}
Using anyhow for Applications
For application code where you don't need specific error types:
use anyhow::{Context, Result, bail, ensure};
fn process_file(path: &str) -> Result<Data> {
let contents = std::fs::read_to_string(path)
.context("Failed to read input file")?;
let data: Data = serde_json::from_str(&contents)
.context("Failed to parse JSON")?;
// Conditional error.
ensure!(!data.items.is_empty(), "Data must have at least one item");
// Early return with error.
if data.version < 2 {
bail!("Unsupported data version: {}", data.version);
}
Ok(data)
}
fn main() {
match process_file("data.json") {
Ok(data) => println!("Processed: {:?}", data),
Err(e) => {
// Print error with chain.
eprintln!("Error: {:?}", e);
// Or just the message.
eprintln!("Error: {}", e);
}
}
}
Error Conversion
From Trait Implementation
use std::num::ParseIntError;
#[derive(Debug)]
enum MyError {
Parse(ParseIntError),
Negative,
}
impl From<ParseIntError> for MyError {
fn from(err: ParseIntError) -> Self {
MyError::Parse(err)
}
}
fn parse_positive(s: &str) -> Result<u32, MyError> {
let num: i32 = s.parse()?; // ParseIntError converted to MyError.
if num < 0 {
Err(MyError::Negative)
} else {
Ok(num as u32)
}
}
Converting Between Error Types
// Map error type.
let result = some_operation()
.map_err(|e| AppError::Operation(e.to_string()))?;
// With context using anyhow.
let result = some_operation()
.map_err(|e| anyhow::anyhow!("Operation failed: {}", e))?;
Option vs Result
// Option: Value might not exist (absence is not an error).
fn find_user(id: i32) -> Option<User> {
users.iter().find(|u| u.id == id).cloned()
}
// Result: Operation can fail (absence is an error).
fn get_user(id: i32) -> Result<User, AppError> {
users.iter()
.find(|u| u.id == id)
.cloned()
.ok_or(AppError::NotFound)
}
// Convert Option to Result.
let user = find_user(123).ok_or(AppError::NotFound)?;
Handling Multiple Error Types
Box<dyn Error>
use std::error::Error;
fn process() -> Result<(), Box<dyn Error>> {
let file = std::fs::read_to_string("config.json")?;
let num: i32 = file.trim().parse()?;
println!("Number: {}", num);
Ok(())
}
Custom Error with Source
use thiserror::Error;
#[derive(Error, Debug)]
pub enum AppError {
#[error("Failed to read configuration")]
ConfigRead(#[source] std::io::Error),
#[error("Failed to parse configuration")]
ConfigParse {
#[source]
source: serde_json::Error,
path: String,
},
}
// Access the source.
fn handle_error(e: AppError) {
eprintln!("Error: {}", e);
if let Some(source) = e.source() {
eprintln!("Caused by: {}", source);
}
}
Error Handling Patterns
Result Combinators
// map: Transform Ok value.
let result = "42".parse::<i32>().map(|n| n * 2);
// map_err: Transform Err value.
let result = operation().map_err(|e| AppError::from(e));
// and_then: Chain operations.
let result = "42".parse::<i32>()
.and_then(|n| {
if n > 0 { Ok(n) }
else { Err("must be positive".parse().unwrap()) }
});
// or_else: Handle error and possibly recover.
let result = operation()
.or_else(|_| fallback_operation());
// unwrap_or: Default value on error.
let value = "invalid".parse::<i32>().unwrap_or(0);
// unwrap_or_else: Computed default.
let value = "invalid".parse::<i32>()
.unwrap_or_else(|_| calculate_default());
Collecting Results
// Stop at the first error.
let results: Result<Vec<i32>, _> = strings
.iter()
.map(|s| s.parse::<i32>())
.collect();
// Collect successes; ignore errors.
let values: Vec<i32> = strings
.iter()
.filter_map(|s| s.parse().ok())
.collect();
// Partition into successes and errors.
let (successes, errors): (Vec<_>, Vec<_>) = strings
.iter()
.map(|s| s.parse::<i32>())
.partition(Result::is_ok);
let values: Vec<i32> = successes.into_iter().map(Result::unwrap).collect();
Async Error Handling
use anyhow::Result;
async fn fetch_data(url: &str) -> Result<Data> {
let response = reqwest::get(url)
.await
.context("Failed to fetch URL")?;
let data = response
.json::<Data>()
.await
.context("Failed to parse response")?;
Ok(data)
}
#[tokio::main]
async fn main() -> Result<()> {
let data = fetch_data("https://api.example.com/data").await?;
println!("{:?}", data);
Ok(())
}
Logging Errors
use tracing::{error, warn, info};
fn process_request(req: Request) -> Result<Response, AppError> {
let user = get_user(req.user_id).map_err(|e| {
error!(user_id = req.user_id, error = ?e, "Failed to get user");
e
})?;
// Or with anyhow and tracing.
let data = fetch_data(&req.url)
.inspect_err(|e| error!(url = %req.url, error = ?e, "Fetch failed"))?;
Ok(Response::new(data))
}
Best Practices
Library vs Application Errors
Libraries: Use specific error types with thiserror.
// Good for libraries: Callers can match on variants.
#[derive(Error, Debug)]
pub enum ParseError {
#[error("Invalid format at position {0}")]
InvalidFormat(usize),
#[error("Unexpected end of input")]
UnexpectedEof,
}
Applications: Use anyhow for convenience.
// Good for applications: Easy to add context.
fn main() -> anyhow::Result<()> {
let config = load_config().context("Failed to load configuration")?;
run_server(config).context("Server error")?;
Ok(())
}
Don't Panic in Libraries
// Bad: Panics on invalid input.
pub fn process(data: &str) -> Output {
let parsed = data.parse().unwrap(); // Don't do this.
// ...
}
// Good: Return Result.
pub fn process(data: &str) -> Result<Output, ParseError> {
let parsed = data.parse()?;
// ...
}
Add Context to Errors
// Bad: Just propagate.
let file = std::fs::read_to_string(path)?;
// Good: Add context.
let file = std::fs::read_to_string(path)
.with_context(|| format!("Failed to read file: {}", path))?;
Advanced Memory Management and Errors
Understanding how errors interact with Rust's ownership model is crucial for senior engineers.
Lifetimes in Error Types
When error types hold references, you must annotate lifetimes:
#[derive(Error, Debug)]
pub enum ParseError<'a> {
#[error("Invalid token at: {0}")]
InvalidToken(&'a str), // Reference to input string
#[error("Unexpected end of input")]
UnexpectedEof,
}
fn parse<'a>(input: &'a str) -> Result<Data, ParseError<'a>> {
// The returned error can reference the input.
if input.is_empty() {
return Err(ParseError::UnexpectedEof);
}
// ...
}
Interior Mutability with Errors
Sometimes you need to accumulate errors while maintaining an immutable reference:
use std::cell::RefCell;
struct Validator {
errors: RefCell<Vec<String>>,
}
impl Validator {
fn validate(&self, data: &Data) {
// We have &self (immutable), but we can add errors.
if data.name.is_empty() {
self.errors.borrow_mut().push("Name is required".to_string());
}
}
fn into_result(self) -> Result<(), Vec<String>> {
let errors = self.errors.into_inner();
if errors.is_empty() {
Ok(())
} else {
Err(errors)
}
}
}
Smart Pointers in Error Handling
For errors that need to be shared across threads:
use std::sync::{Arc, Mutex};
#[derive(Clone)]
struct ErrorCollector {
errors: Arc<Mutex<Vec<AppError>>>,
}
impl ErrorCollector {
fn add_error(&self, error: AppError) {
let mut errors = self.errors.lock().unwrap();
errors.push(error);
}
fn has_errors(&self) -> bool {
let errors = self.errors.lock().unwrap();
!errors.is_empty()
}
}
// Safe to share across threads.
async fn process_batch(collector: ErrorCollector, items: Vec<Item>) {
let handles: Vec<_> = items.into_iter().map(|item| {
let collector = collector.clone();
tokio::spawn(async move {
if let Err(e) = process_item(&item).await {
collector.add_error(e);
}
})
}).collect();
futures::future::join_all(handles).await;
}
The Borrow Checker and Error Propagation
A common mistake is trying to return references to local data in errors:
// BAD: Won't compile—returning a reference to a local.
fn validate_bad(input: String) -> Result<Data, &str> {
if input.is_empty() {
return Err("Input is empty"); // This &str outlives input.
}
// ...
}
// GOOD: Own the error data.
fn validate_good(input: String) -> Result<Data, String> {
if input.is_empty() {
return Err("Input is empty".to_string()); // Owned String.
}
// ...
}
Conclusion
Rust's error handling enforces explicit handling of failure cases, leading to more robust code. Use thiserror for library error types and anyhow for application code. Always add context to errors, and prefer Result over panics. Understanding how errors interact with lifetimes, ownership, and smart pointers is essential for writing senior-level Rust code. The patterns in this guide help you write error-handling code that is both safe and ergonomic.