Error Handling with Result and Option in Rust
Handle errors idiomatically in Rust — Result, Option, the ? operator, custom error types, and the thiserror/anyhow ecosystem.
What you'll learn
- ✓How Result and Option replace exceptions and null
- ✓The ? operator for concise error propagation
- ✓Custom error types with Display and Error traits
- ✓When to use thiserror vs anyhow
Prerequisites
- •Rust basics (enums, pattern matching, traits)
- •Basic understanding of generics
Rust has no exceptions and no null. Instead, it uses two enums — Result<T, E> and Option<T> — to represent operations that can fail or return nothing. The compiler forces you to handle both cases.
Option: when a value might not exist
enum Option<T> {
Some(T),
None,
}
fn find_user(id: u32) -> Option<&'static str> {
match id {
1 => Some("Alice"),
2 => Some("Bob"),
_ => None,
}
}
let user = find_user(3);
match user {
Some(name) => println!("Found: {name}"),
None => println!("Not found"),
}
Useful Option methods
let x: Option<i32> = Some(42);
x.unwrap(); // 42 — panics if None
x.unwrap_or(0); // 42 — returns 0 if None
x.unwrap_or_default();// 42 — returns T::default() if None
x.map(|n| n * 2); // Some(84)
x.and_then(|n| if n > 0 { Some(n) } else { None }); // Some(42)
x.is_some(); // true
x.is_none(); // false
Result: when an operation can fail
enum Result<T, E> {
Ok(T),
Err(E),
}
use std::fs;
use std::io;
fn read_config() -> Result<String, io::Error> {
fs::read_to_string("config.toml")
}
match read_config() {
Ok(contents) => println!("Config:\n{contents}"),
Err(e) => eprintln!("Failed to read config: {e}"),
}
Useful Result methods
let r: Result<i32, String> = Ok(42);
r.unwrap(); // 42 — panics on Err
r.expect("must work");// 42 — panics with custom message on Err
r.unwrap_or(0); // 42
r.map(|n| n * 2); // Ok(84)
r.map_err(|e| format!("Error: {e}")); // transform the error
r.is_ok(); // true
r.is_err(); // false
r.ok(); // Some(42) — converts to Option
The ? operator
The ? operator propagates errors — if the expression is Err, the function returns early with that error.
fn read_username() -> Result<String, io::Error> {
let contents = fs::read_to_string("username.txt")?;
Ok(contents.trim().to_string())
}
This is equivalent to:
fn read_username() -> Result<String, io::Error> {
let contents = match fs::read_to_string("username.txt") {
Ok(c) => c,
Err(e) => return Err(e),
};
Ok(contents.trim().to_string())
}
Chaining with ?
fn setup_app() -> Result<Config, Box<dyn std::error::Error>> {
let raw = fs::read_to_string("config.toml")?;
let config: Config = toml::from_str(&raw)?;
let db = Database::connect(&config.db_url)?;
Ok(config)
}
Each ? either continues with the success value or returns the error immediately.
Custom error types
The Error trait
use std::fmt;
#[derive(Debug)]
enum AppError {
NotFound(String),
ParseError(String),
IoError(std::io::Error),
}
impl fmt::Display for AppError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
AppError::NotFound(msg) => write!(f, "not found: {msg}"),
AppError::ParseError(msg) => write!(f, "parse error: {msg}"),
AppError::IoError(e) => write!(f, "I/O error: {e}"),
}
}
}
impl std::error::Error for AppError {}
impl From<std::io::Error> for AppError {
fn from(e: std::io::Error) -> Self {
AppError::IoError(e)
}
}
The From impl lets ? automatically convert io::Error into AppError.
thiserror: less boilerplate
The thiserror crate derives Display and Error for you.
use thiserror::Error;
#[derive(Error, Debug)]
enum AppError {
#[error("not found: {0}")]
NotFound(String),
#[error("parse error: {0}")]
ParseError(String),
#[error("I/O error")]
Io(#[from] std::io::Error),
#[error("database error")]
Db(#[from] sqlx::Error),
}
Use thiserror for library code where callers need to inspect error variants.
anyhow: quick and easy
The anyhow crate provides anyhow::Result — a type-erased error that works with any error type.
use anyhow::{Context, Result};
fn setup() -> Result<()> {
let config = fs::read_to_string("config.toml")
.context("failed to read config file")?;
let port: u16 = config
.parse()
.context("invalid port number")?;
Ok(())
}
Use anyhow for application code where you just want to propagate errors with context.
Option to Result conversion
let user: Option<&str> = find_user(42);
// Convert Option to Result
let user = user.ok_or("user not found")?;
let user = user.ok_or_else(|| AppError::NotFound("user 42".into()))?;
When to panic
Use panic!, unwrap(), or expect() only when:
- A bug in your code makes the error impossible (invariant violation).
- You are in tests.
- You are in a prototype.
Never panic in library code on user input.
Summary
Rust replaces exceptions with Result and null with Option. The ? operator makes error propagation concise. Use thiserror for libraries (callers need typed errors) and anyhow for applications (you just want to propagate and display). The compiler ensures you handle every error — nothing slips through silently.
Related articles
- Rust Rust Error Handling with Result and the ? Operator
A practical guide to error handling in Rust covering Result, the ? operator, unwrap and expect, custom error types, and the thiserror and anyhow crates.
- Rust Rust Error Handling with anyhow and thiserror
Master Rust error handling with anyhow for applications and thiserror for libraries. Practical patterns, conversions, and context-rich error reporting.
- Rust Rust Pattern Matching: Advanced Techniques and Guards
Go beyond basic match with pattern guards, bindings, nested patterns, or-patterns, and real-world refactoring strategies in Rust.
- Rust Rust Error Handling with thiserror and anyhow
Learn practical Rust error handling using thiserror for library errors and anyhow for application errors, with real-world patterns and examples.