Error Handling
Rust has no exceptions. Errors are values you handle explicitly with Result<T, E> and Option<T>. The type system forces you to deal with every failure path.
Result and Option
// Result: operation that can fail
enum Result<T, E> {
Ok(T), // success value
Err(E), // error value
}
// Option: value that might not exist
enum Option<T> {
Some(T), // value present
None, // value absent
}
use std::fs;
// File reading returns Result<String, io::Error>
let content = fs::read_to_string("config.toml");
match content {
Ok(text) => println!("Config: {text}"),
Err(e) => eprintln!("Failed to read config: {e}"),
}
// Vec::get returns Option<&T>
let numbers = vec![10, 20, 30];
match numbers.get(5) {
Some(n) => println!("Found: {n}"),
None => println!("Index out of bounds"),
}
The ? Operator
Propagate errors up the call stack without verbose match blocks. If the value is Err, return early from the function. If Ok, unwrap the value.
use std::fs;
use std::io;
fn read_username() -> Result<String, io::Error> {
let content = fs::read_to_string("username.txt")?; // returns Err early if failed
Ok(content.trim().to_string())
}
? works on Option too:
fn first_even(numbers: &[i32]) -> Option<i32> {
let first = numbers.first()?; // returns None if empty
if first % 2 == 0 {
Some(*first)
} else {
None
}
}
Tip:
?can convert error types automatically if the source type implementsFrom<SourceError> for TargetError. This is howanyhowandthiserrorwork under the hood.
The Golden Rule: Never .unwrap() in Production
// These will panic (crash) if the value is Err/None:
let f = File::open("config.toml").unwrap(); // panic on missing file
let n: i32 = "abc".parse().expect("not a number"); // panic with message
// unwrap() is fine in:
// - Tests
// - Examples and prototypes
// - Cases where you've proven the value exists (with a comment explaining why)
Gotcha:
.unwrap()turns a recoverable error into a program crash. In production code, use?,match, orunwrap_or_default()instead.
Useful Combinators
Methods on Result and Option that avoid verbose matching.
// Provide a default
let port: u16 = env::var("PORT")
.ok() // Result -> Option
.and_then(|s| s.parse().ok()) // parse, converting Result -> Option
.unwrap_or(8080); // default if None
// Transform the success value
let upper: Result<String, io::Error> = fs::read_to_string("name.txt")
.map(|s| s.to_uppercase());
// Chain fallible operations
let config = fs::read_to_string("config.toml")
.and_then(|text| toml::from_str(&text).map_err(|e| io::Error::other(e)));
| Combinator | On Result | On Option |
|---|---|---|
map(f) | Transform Ok(T) to Ok(U) | Transform Some(T) to Some(U) |
and_then(f) | Chain fallible operations | Chain operations returning Option |
unwrap_or(default) | Use default on Err | Use default on None |
unwrap_or_default() | Use Default::default() on Err | Use Default::default() on None |
ok() | Convert Result to Option | N/A |
map_err(f) | Transform the error type | N/A |
thiserror for Libraries
Define structured error types with automatic Display and From implementations.
[dependencies]
thiserror = "2"
use thiserror::Error;
#[derive(Error, Debug)]
enum AppError {
#[error("config file not found: {path}")]
ConfigNotFound { path: String },
#[error("invalid port number: {0}")]
InvalidPort(String),
#[error("database error")]
Database(#[from] sqlx::Error), // auto-converts sqlx::Error via From
#[error("io error")]
Io(#[from] std::io::Error),
}
fn load_config(path: &str) -> Result<Config, AppError> {
let text = std::fs::read_to_string(path)?; // io::Error auto-converts to AppError::Io
// ...
}
anyhow for Applications
When you don’t need structured error types - just propagate and add context.
[dependencies]
anyhow = "1"
use anyhow::{Context, Result};
fn load_config() -> Result<Config> { // anyhow::Result = Result<T, anyhow::Error>
let text = std::fs::read_to_string("config.toml")
.context("failed to read config file")?;
let config: Config = toml::from_str(&text)
.context("failed to parse config")?;
Ok(config)
}
fn main() -> Result<()> {
let config = load_config()?;
println!("Loaded: {:?}", config);
Ok(())
}
When the error prints, you get a chain of context:
Error: failed to read config file
Caused by:
No such file or directory (os error 2)
When to Use Which
| Situation | Use | Why |
|---|---|---|
| Library crate | thiserror | Callers need to match on specific error variants |
| Application binary | anyhow | You just need to report errors with context |
| Quick prototype | .unwrap() / .expect() | Get something working, refine later |
| Known-safe unwrap | .expect("reason") | Document why it can’t fail |
Tip: You can use both in the same project. Define error types with
thiserrorin your library code, and useanyhowinmain.rsand CLI glue code.
Error Wrapping Pattern
Add context as errors propagate up through layers.
use anyhow::{Context, Result};
fn read_user_age(path: &str) -> Result<u32> {
let text = std::fs::read_to_string(path)
.with_context(|| format!("failed to read user file: {path}"))?;
let age: u32 = text.trim().parse()
.with_context(|| format!("invalid age in {path}: {text:?}"))?;
Ok(age)
}
Tip: Use
.with_context(|| ...)over.context(...)when the message includes runtime values. The closure is only evaluated on error, avoiding unnecessary allocations.