Error handling is a critical aspect of writing robust Rust code. Unlike languages that use exceptions, Rust uses the type system to make errors explicit and force you to handle them. In this post, we will explore idiomatic error handling patterns -- from the foundational
Option and
Result types, through custom error types, to the ecosystem crates that make error handling ergonomic at scale.
The Option Type
Option<T>
represents a value that might be absent. It has two variants:
Some(T) when the value is present, and
None when it is not. This is Rust's replacement for
null -- and it is checked at compile time.
fn find_user(id: u32) -> Option<User> { // Returns Some(user) if found, None otherwise users.get(&id).cloned() } // Using the result match find_user(42) { Some(user) => println!("Found: {}", user.name), None => println!("User not found"), }
Good to know: Unlike languages with null, Rust's Option type forces you to handle the absent case at compile time. You cannot accidentally dereference a None value -- the compiler simply will not let you.
The Result Type
Result<T, E>
represents either success (Ok(T)) or failure (Err(E)). This is the primary type used for operations that can fail:
use std::fs::File; use std::io::Error; fn open_file(path: &str) -> Result<File, Error> { File::open(path) } // Using the result match open_file("data.txt") { Ok(file) => println!("File opened successfully"), Err(e) => eprintln!("Error opening file: {}", e), }
Result vs Option: Use Option when a value might simply be absent (no error context needed). Use Result when an operation can fail and you want to communicate why it failed.
The ? Operator
The ? operator makes error propagation concise. Instead of writing verbose
match expressions at every fallible call site, you can append
? to automatically return early on error:
use std::fs; use std::io; fn read_username_from_file() -> Result<String, io::Error> { let mut username = fs::read_to_string("username.txt")?; username = username.trim().to_string(); Ok(username) }
The ? operator does two things:
-
1
If the
ResultisOk, it unwraps and returns the inner value -
2
If the
ResultisErr, it returns the error early from the enclosing function
Good to know: The ? operator also works with Option. When used on an Option, it returns None early if the value is absent. You can even use it in main() by making main return Result<(), Box<dyn Error>>.
Converting Between Option and Result
You can freely convert between these two types using built-in methods:
// Option to Result let option: Option<i32> = Some(42); let result: Result<i32, &str> = option.ok_or("no value"); // Result to Option let result: Result<i32, String> = Ok(42); let option: Option<i32> = result.ok();
The ok_or method converts an
Option into a
Result by providing an error value for the
None case. The
ok method discards the error information and returns an
Option.
Custom Error Types
For complex applications, you will want to define custom error types. A well-designed error enum gives callers the ability to match on specific failure modes and take appropriate action:
use std::fmt; #[derive(Debug)] enum AppError { NotFound, PermissionDenied, InvalidInput(String), } impl fmt::Display for AppError { fn fmt(&self, f: &mut fmt::Formatter) -> 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), } } } impl std::error::Error for AppError {} fn validate_user(username: &str) -> Result<(), AppError> { if username.is_empty() { return Err(AppError::InvalidInput("Username cannot be empty".to_string())); } Ok(()) }
A custom error type needs three things: a
Debug implementation (usually derived), a
Display implementation for human-readable messages, and an
Error trait implementation to integrate with the broader ecosystem.
Using the anyhow Crate
For applications (not libraries), the
anyhow crate simplifies error handling significantly. It provides a single
anyhow::Result type that can hold any error, along with convenient macros for adding context:
use anyhow::{Context, Result}; fn process_file(path: &str) -> Result<String> { let contents = std::fs::read_to_string(path) .context("Failed to read file")?; let processed = contents.trim().to_string(); if processed.is_empty() { anyhow::bail!("File is empty"); } Ok(processed) }
The .context() method wraps the underlying error with a human-readable message, and
bail! creates and returns an error immediately -- similar to an early return with
Err.
Using the thiserror Crate
For libraries, use
thiserror to derive ergonomic custom error types. It generates the
Display and
Error implementations for you:
use thiserror::Error; #[derive(Error, Debug)] enum DataError { #[error("Invalid data format")] InvalidFormat, #[error("Database error: {0}")] DatabaseError(String), #[error(transparent)] IoError(#[from] std::io::Error), } fn load_data() -> Result<Data, DataError> { // Function implementation todo!() }
Good to know: The #[from] attribute on IoError automatically generates a From<std::io::Error> implementation, which means the ? operator will automatically convert I/O errors into your custom error type. The #[error(transparent)] attribute delegates the Display implementation to the inner error.
A common rule of thumb:
use anyhow for applications where you just want to bubble errors up to the user, and
use thiserror for libraries where callers need structured, matchable error types.
Combining Multiple Operations
Chain fallible operations together with
and_then, which passes the success value to the next function only if the previous step succeeded:
fn process_user_data(id: u32) -> Result<ProcessedData, AppError> { find_user(id) .ok_or(AppError::NotFound)? .validate() .and_then(|user| user.process()) .and_then(|data| data.save()) }
Error Recovery
Use or_else for fallback behavior when an operation fails, and
unwrap_or_else to provide a default when all else fails:
fn get_config() -> Config { load_config_from_file() .or_else(|_| load_config_from_env()) .unwrap_or_else(|_| Config::default()) }
This pattern creates a clean fallback chain: try the file first, then the environment, then fall back to defaults. Each step only runs if the previous one failed.
Best Practices
1. Don't Panic in Library Code
⚠ Pitfall
Calling .unwrap() or .expect() in library code will panic and crash the entire program when an error occurs. Library consumers should be able to decide how to handle failures. Always return Result instead.
// Bad: Panics on error fn bad_parse(s: &str) -> i32 { s.parse().unwrap() } // Good: Returns Result fn good_parse(s: &str) -> Result<i32, std::num::ParseIntError> { s.parse() }
2. Use Descriptive Error Messages
Error messages should tell the reader what went wrong and ideally what context produced the failure:
// Bad: Generic error Err("error occurred") // Good: Specific context Err(format!("Failed to parse user ID from '{}'", input))
3. Document Error Conditions
Use the # Errors doc section to tell consumers when and why your function can fail:
/// Loads user configuration from the specified file. /// /// # Errors /// /// Returns `ConfigError::NotFound` if the file doesn't exist. /// Returns `ConfigError::InvalidFormat` if the file format is invalid. fn load_config(path: &Path) -> Result<Config, ConfigError> { // Implementation todo!() }
4. Use Type Aliases for Complex Result Types
When a module consistently uses the same error type, define a type alias to reduce boilerplate:
type AppResult<T> = Result<T, AppError>; fn process() -> AppResult<Data> { // Implementation todo!() }
This pattern is used extensively in the standard library -- for example,
std::io::Result<T> is just a type alias for
Result<T, std::io::Error>.
Common Patterns
Early Return Pattern
Validate inputs up front and return errors early to keep the "happy path" code unindented and easy to follow:
fn validate_and_process(data: &str) -> Result<ProcessedData, ValidationError> { if data.is_empty() { return Err(ValidationError::Empty); } if data.len() > 1000 { return Err(ValidationError::TooLarge); } // Process the valid data Ok(process(data)) }
Map for Transformation
Use .map() to transform the inner value of an
Option or
Result without unwrapping it:
fn get_user_age(id: u32) -> Option<u32> { find_user(id).map(|user| user.age) }
Collecting Results
One of Rust's most powerful patterns: you can
.collect() an iterator of
Result values into a single
Result<Vec<_>, _>. If any item fails, the whole collection returns that error:
fn process_all(items: Vec<Item>) -> Result<Vec<ProcessedItem>, ProcessError> { items.into_iter() .map(|item| process_item(item)) .collect() // Collects into Result<Vec<_>, _> }
Good to know: This works because Result implements FromIterator. The iterator short-circuits on the first error it encounters, making it both ergonomic and efficient.
Conclusion
Rust's error handling might seem verbose at first, but it makes your code more reliable by forcing you to consider all failure cases. The type system ensures that errors cannot be accidentally ignored, leading to more robust software.
Key takeaways:
-
Use
Optionfor values that might be absent -
Use
Resultfor operations that can fail -
Leverage the
?operator for clean error propagation - Define custom error types for complex applications
-
Use
anyhowfor applications,thiserrorfor libraries
Master these patterns, and you will write Rust code that is both safe and maintainable.