Error Handling
Result, Option, ?, panic vs Err, From conversions.
Error Handling
Rust does not have exceptions. Failure is a value, returned through the type system, threaded explicitly through every potentially-failing call site. After the initial shock — "wait, I have to look at every error?" — this becomes one of Rust's most loved features. Failure modes can't hide.
Two enums do almost everything
enum Option<T> {
Some(T),
None,
}
enum Result<T, E> {
Ok(T),
Err(E),
}
Option<T> represents absence. Result<T, E> represents failure with information. The two cover everything that would be null, undefined, throw, or "errno" in another language.
Rule of thumb: Option for "might not be there", Result for "might have gone wrong". If a lookup that misses isn't an error in your domain, return Option. If the user typed a malformed URL, return Result.
The ? operator
The single best ergonomic improvement Rust ever shipped. At the end of a fallible expression, ? says: "if this is Err, return the error from this function; otherwise unwrap the Ok":
use std::fs;
use std::io;
fn read_config() -> Result<String, io::Error> {
let s = fs::read_to_string("/etc/app.conf")?; // returns Err if read fails
Ok(s.trim().to_string())
}
? desugars to roughly:
let s = match fs::read_to_string("/etc/app.conf") {
Ok(v) => v,
Err(e) => return Err(e.into()), // ← that .into() is doing a lot
};
Two crucial details: it short-circuits the function (so all subsequent code only runs on success), and it calls .into() on the error — which leans on the From trait to convert.
From conversions and error types
That .into() lets you mix error types:
use std::fs;
use std::num::ParseIntError;
#[derive(Debug)]
enum AppError {
Io(std::io::Error),
Parse(ParseIntError),
}
impl From<std::io::Error> for AppError {
fn from(e: std::io::Error) -> Self { AppError::Io(e) }
}
impl From<ParseIntError> for AppError {
fn from(e: ParseIntError) -> Self { AppError::Parse(e) }
}
fn parse_port() -> Result<u16, AppError> {
let s = fs::read_to_string("/etc/port")?; // io::Error → AppError::Io
let n: u16 = s.trim().parse()?; // ParseIntError → AppError::Parse
Ok(n)
}
Two ? operators, two different source error types, both flow into one AppError. The From impls are the glue. In practice you'd use thiserror to derive these:
#[derive(Debug, thiserror::Error)]
enum AppError {
#[error("io error: {0}")]
Io(#[from] std::io::Error),
#[error("parse error: {0}")]
Parse(#[from] std::num::ParseIntError),
}
#[from] generates the From impl for you.
Option ergonomics
Option<T> has a similar suite of methods:
let users: Vec<&str> = vec!["a", "b", "c"];
// .first() returns Option<&&str>
let first = users.first().copied().unwrap_or("default");
// Chain transformations:
let upper = users.first()
.map(|s| s.to_uppercase()) // Option<String>
.unwrap_or_else(|| String::from("?"));
// Convert Option to Result with .ok_or:
fn lookup(id: u64) -> Result<User, AppError> {
db.get(&id).ok_or(AppError::NotFound)
}
Key methods to memorise: unwrap_or, unwrap_or_else, map, and_then, ok_or, is_some, is_none. They compose into pipelines that read top-to-bottom.
panic! — for unrecoverable bugs only
let x: i32 = -1;
assert!(x >= 0, "x must be non-negative, got {}", x); // panics if false
if some_invariant_violated() {
panic!("expected sorted slice, found unsorted");
}
When to panic:
- An invariant has been violated that means the program is in a bad state.
- The condition can never legitimately occur (e.g.
vec![1].first().unwrap()). - You're prototyping and don't yet care about error UX.
When not to panic:
- I/O failure (the disk fills up, the network drops).
- Bad user input (someone types
not-a-number). - Optional file or env var missing.
The line between "bug" and "expected failure" is the line between panic! and Result. Library code should almost never panic on user input.
unwrap / expect
Two sharp tools for Option and Result:
let n: i32 = "42".parse().unwrap(); // panic on Err
let n: i32 = "42".parse().expect("must be valid u8");// panic with message
Both panic on the failure variant. expect lets you supply a message — use it during development; replace with ? or proper handling before shipping. clippy will flag both in production paths if you ask.
Error type design
Two patterns dominate:
Library code: enum-per-domain.
#[derive(Debug, thiserror::Error)]
pub enum ParseError {
#[error("unexpected token at byte {0}")]
UnexpectedToken(usize),
#[error("unterminated string starting at byte {0}")]
UnterminatedString(usize),
}
Callers can match on variants and react differently to each.
Application code: a single boxed dyn Error.
fn main() -> anyhow::Result<()> {
let cfg = read_config()?;
let server = build_server(cfg)?;
server.run()?;
Ok(())
}
anyhow::Result<T> is Result<T, anyhow::Error> — a wrapper around Box<dyn Error + Send + Sync> plus context APIs (.with_context(|| "loading config")). You lose the ability to match on specific variants, but you save a lot of typing and errors get clean stack-trace-like reports.
A pragma worth knowing
fn main() -> Result<(), Box<dyn std::error::Error>> {
let s = std::fs::read_to_string("config.txt")?;
println!("{}", s);
Ok(())
}
main can return Result. Any ? inside propagates. The runtime prints the error using Debug and exits with status 1. Saves you from writing a custom error printer for CLIs.
Summary table
| Situation | Use |
|---|---|
| Value might be absent (lookup miss, optional field) | Option<T> |
| Value might fail to load with reason (I/O, parse) | Result<T, E> |
| Forwarding a fallible call inside another fallible function | ? |
| Converting between error types automatically | From impl + ? |
| Library: distinguishable errors callers can match on | enum + thiserror |
| App: just stop on the first error with context | anyhow::Result + ? |
| Truly impossible-to-recover bug | panic! / unwrap |
Rust's error handling is verbose at the call site by design — every potential failure point is visible. After a week the visibility stops feeling like noise and starts feeling like documentation.
Tools in the wild
4 tools- libraryanyhowfree tier
`anyhow::Result<T>` for application code where you want one universal error type.
- librarythiserrorfree tier
Derive macro for clean library error enums — `#[derive(thiserror::Error)]` + `#[from]`.
- cliclippyfree tier
Flags `unwrap()` / `expect()` in production paths; suggests `?` over `match err`.
- librarycolor-eyrefree tier
Anyhow-style errors with prettier reports + automatic spantraces.