Error handling
Rust admits two principal error mechanisms: recoverable errors via Result<T, E>, and unrecoverable errors via panic!. The design is pointed: errors a caller could reasonably handle are returned via Result; conditions that indicate a programmer bug or genuinely unrecoverable state cause a panic. The ? operator admits compact error propagation through Result chains. The conventional ecosystem uses thiserror for library-defined error types and anyhow for application error handling. The combination — Result for propagation, ? for chains, panic! for bugs, Option for absent-value cases — covers the error-handling surface; the type system ensures errors are explicit and cannot be silently ignored.
Result<T, E>
The standard library’s principal error type:
enum Result<T, E> {
Ok(T),
Err(E),
}
The conventional uses:
use std::num::ParseIntError;
fn parse(s: &str) -> Result<i32, ParseIntError> {
s.parse::<i32>()
}
let r1 = parse("42"); // Ok(42)
let r2 = parse("not a number"); // Err(ParseIntError { ... })
match r1 {
Ok(n) => println!("got {}", n),
Err(e) => println!("error: {}", e),
}
A function that may fail returns Result<T, E>; the caller must explicitly handle both cases.
The ? operator
The ? operator admits compact error propagation:
use std::num::ParseIntError;
fn parse_two(s1: &str, s2: &str) -> Result<i32, ParseIntError> {
let a: i32 = s1.parse()?;
let b: i32 = s2.parse()?;
Ok(a + b)
}
Each ?:
- If the value is
Ok(x), unwraps it asx. - If the value is
Err(e), returnsErr(e)from the enclosing function.
The ? is roughly:
let a: i32 = match s1.parse() {
Ok(n) => n,
Err(e) => return Err(e),
};
The conventional Rust error-handling style is built around chains of ?:
fn read_and_parse(path: &str) -> Result<i32, Box<dyn std::error::Error>> {
let contents = std::fs::read_to_string(path)?;
let n: i32 = contents.trim().parse()?;
Ok(n * 2)
}
Error type conversion
The ? operator applies the From trait to convert error types:
use std::io;
use std::num::ParseIntError;
#[derive(Debug)]
enum MyError {
Io(io::Error),
Parse(ParseIntError),
}
impl From<io::Error> for MyError {
fn from(err: io::Error) -> Self {
MyError::Io(err)
}
}
impl From<ParseIntError> for MyError {
fn from(err: ParseIntError) -> Self {
MyError::Parse(err)
}
}
fn read_and_parse(path: &str) -> Result<i32, MyError> {
let contents = std::fs::read_to_string(path)?; // io::Error → MyError
let n: i32 = contents.trim().parse()?; // ParseIntError → MyError
Ok(n)
}
The ? calls From::from() to convert; if a conversion is not implemented, the compiler reports a type error.
Box<dyn Error>
For applications without elaborate error types, Box<dyn std::error::Error> admits propagating any error:
fn read_and_parse(path: &str) -> Result<i32, Box<dyn std::error::Error>> {
let contents = std::fs::read_to_string(path)?;
let n: i32 = contents.trim().parse()?;
Ok(n)
}
The Box<dyn Error> is a trait object admitting any type implementing the Error trait. The form admits substantial conciseness in application code; libraries conventionally define explicit error types.
Defining error types
For library-quality error types, the conventional pattern:
use std::fmt;
#[derive(Debug)]
enum ParseError {
Empty,
Invalid(String),
OutOfRange { value: i64, max: i64 },
}
impl fmt::Display for ParseError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
ParseError::Empty => write!(f, "empty input"),
ParseError::Invalid(s) => write!(f, "invalid input: {}", s),
ParseError::OutOfRange { value, max } => {
write!(f, "value {} exceeds max {}", value, max)
}
}
}
}
impl std::error::Error for ParseError {}
The conventional requirements for an error type:
Debug— for{:?}formatting (typically derived).Display— for human-readable messages.Error— for the standard error trait (admits?and trait objects).- Optionally
Send + Sync + 'static— for thread-safe propagation.
thiserror
The thiserror crate (de facto standard) admits substantial conciseness:
use thiserror::Error;
#[derive(Error, Debug)]
enum DataError {
#[error("missing field: {0}")]
MissingField(String),
#[error("invalid number: {value}")]
InvalidNumber { value: String },
#[error("I/O error")]
Io(#[from] std::io::Error),
#[error("parse error")]
Parse(#[from] std::num::ParseIntError),
}
The #[derive(Error)] and #[error("...")] produce the Display implementation; the #[from] produces From implementations admitting ?-based conversion. The form is the conventional Rust library error definition.
anyhow
The anyhow crate admits substantial conciseness for application code:
use anyhow::{Result, Context};
fn read_config(path: &str) -> Result<Config> {
let contents = std::fs::read_to_string(path)
.context(format!("failed to read {}", path))?;
let config: Config = toml::from_str(&contents)
.context("failed to parse config")?;
Ok(config)
}
The anyhow::Result<T> is Result<T, anyhow::Error>; the Error is a flexible trait-object-style error. The .context() admits attaching descriptive messages. The form is the conventional Rust application error handling.
The conventional discipline:
- Libraries use
thiserror— explicit, well-defined error types. - Applications use
anyhow— flexible, message-focused.
Option<T>
For the “absent value” case, Option<T> rather than Result:
enum Option<T> {
Some(T),
None,
}
The conventional uses:
fn first_word(s: &str) -> Option<&str> {
s.split_whitespace().next()
}
let s = "hello world";
match first_word(s) {
Some(w) => println!("first word: {}", w),
None => println!("empty"),
}
// Or with ?:
fn process(s: &str) -> Option<i32> {
let first = s.split_whitespace().next()?;
first.parse().ok()
}
The ? works for Option: Some(x) becomes x; None returns None from the enclosing function.
The conventional choice:
Option<T>— for “value may be absent” with no error reason needed.Result<T, E>— for “value may be absent because of an error”.
Result and Option methods
Both types have a substantial method surface for combinators:
Unwrapping (panicking)
let v = result.unwrap(); // panics on Err
let v = result.expect("expected a value"); // panics with custom message
let v = result.unwrap_or(default); // returns default on Err
let v = result.unwrap_or_else(|e| compute(e)); // computed default
let v = result.unwrap_or_default(); // T::default()
The conventional discipline:
unwrap()in tests and prototype code.expect()when the error genuinely should not happen and the caller wants a clear panic message.unwrap_or()/unwrap_or_else()/unwrap_or_default()for fallback.?in production code for propagation.
Mapping
let r: Result<i32, _> = "42".parse();
let r: Result<i32, _> = r.map(|n| n * 2); // doubles the Ok value
let r: Result<i32, _> = r.map_err(|e| format!("{}", e)); // transforms the error
Combinators
result.and_then(|x| compute(x)); // chain; "flatMap"
result.or_else(|e| recover(e)); // recover from Err
result.is_ok();
result.is_err();
result.ok(); // Result<T, E> → Option<T>
result.err(); // Result<T, E> → Option<E>
Option methods
let opt: Option<i32> = Some(5);
let opt: Option<i32> = opt.map(|n| n * 2);
let opt: Option<i32> = opt.and_then(|n| if n > 0 { Some(n) } else { None });
let opt: Option<i32> = opt.or(Some(default));
let opt: Option<i32> = opt.or_else(|| compute_default());
let v = opt.unwrap_or(0);
let v = opt.unwrap_or_else(|| compute_default());
let r: Result<i32, &str> = opt.ok_or("missing");
let r: Result<i32, &str> = opt.ok_or_else(|| compute_error());
panic!
For unrecoverable errors:
panic!("unreachable code reached");
panic!("invalid state: {:?}", state);
if condition {
panic!("assertion failed");
}
assert!(x > 0, "x must be positive, got {}", x);
assert_eq!(actual, expected);
The conventional uses:
- Programmer bugs — invariants violated.
- Genuinely unrecoverable conditions — the program cannot continue safely.
- Tests —
assert!,assert_eq!panic on failure. - Prototypes —
unwrap(),expect()for code that’s not yet polished.
The panic! unwinds the stack by default, calling destructors as it goes. The behaviour can be configured to abort (immediate termination) for smaller binary size:
[profile.release]
panic = "abort"
Recovering from panics
std::panic::catch_unwind admits catching a panic:
use std::panic;
let result = panic::catch_unwind(|| {
do_something_risky();
});
match result {
Ok(v) => println!("succeeded: {:?}", v),
Err(_) => println!("panicked"),
}
The mechanism is rare in idiomatic code; conventionally, panic indicates a bug, and the program should terminate. The principal use is for FFI boundaries (preventing panics from unwinding into C code) and test runners.
Common patterns
Validate-and-parse
fn parse_positive(s: &str) -> Result<u32, ParseError> {
let n: i32 = s.parse().map_err(|_| ParseError::Invalid(s.to_string()))?;
if n < 0 {
Err(ParseError::Negative(n))
} else {
Ok(n as u32)
}
}
Default on error
let port: u16 = std::env::var("PORT")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(8080);
Error context
use anyhow::{Context, Result};
fn read_config(path: &str) -> Result<Config> {
let contents = std::fs::read_to_string(path)
.with_context(|| format!("failed to read config from {}", path))?;
let config: Config = toml::from_str(&contents)
.with_context(|| format!("failed to parse config at {}", path))?;
Ok(config)
}
The error message accumulates context, producing a chain like:
Error: failed to parse config at /etc/app.toml
Caused by:
expected `=`, found `:` at line 5
Library error types with thiserror
use thiserror::Error;
#[derive(Error, Debug)]
pub enum DatabaseError {
#[error("connection failed: {0}")]
Connection(String),
#[error("query failed")]
Query(#[from] QueryError),
#[error("transaction error: {message}")]
Transaction { message: String },
}
Application errors with anyhow
use anyhow::{anyhow, Result, bail};
fn process(input: &str) -> Result<i32> {
if input.is_empty() {
bail!("input is empty");
}
if input.len() > 100 {
return Err(anyhow!("input too long: {}", input.len()));
}
let n: i32 = input.parse()?;
Ok(n * 2)
}
Early return on error
fn process(input: &str) -> Result<i32, String> {
let n: i32 = match input.parse() {
Ok(n) => n,
Err(_) => return Err("not a number".to_string()),
};
if n < 0 {
return Err("negative".to_string());
}
Ok(n * 2)
}
// More conventionally:
fn process(input: &str) -> Result<i32, String> {
let n: i32 = input.parse().map_err(|_| "not a number".to_string())?;
if n < 0 {
return Err("negative".to_string());
}
Ok(n * 2)
}
Result-type conversion
let r: Result<i32, String> = parse_number(input);
// As Option:
let opt: Option<i32> = r.ok();
// Convert error:
let r: Result<i32, MyError> = r.map_err(MyError::ParseFailure);
// Default on error:
let v: i32 = r.unwrap_or(0);
A note on assert! vs Result
The conventional choice:
assert!— for invariants that must hold; failure indicates a bug.Result— for operations that may fail under reasonable conditions.
fn divide(a: i32, b: i32) -> Result<i32, &'static str> {
if b == 0 {
return Err("division by zero");
}
Ok(a / b)
}
fn binary_search<T: Ord>(slice: &[T], target: &T) -> Option<usize> {
assert!(slice.windows(2).all(|w| w[0] <= w[1]),
"slice must be sorted");
// ... actual search ...
}
The assert! admits expressing preconditions; the Result admits expressing fallibility.
A note on ? in main
main may return Result:
fn main() -> Result<(), Box<dyn std::error::Error>> {
let contents = std::fs::read_to_string("file.txt")?;
println!("{}", contents);
Ok(())
}
The ? works in main if the return type is compatible. The form admits substantial conciseness for prototype code and command-line tools.
A note on the conventional discipline
The contemporary Rust error-handling advice:
- Use
Result<T, E>for fallible operations. - Use
?for propagation; avoid manualmatchchains. - Use
thiserrorfor library error types. - Use
anyhowfor application error handling. - Use
panic!only for genuine bugs and unrecoverable conditions. - Use
assert!anddebug_assert!for invariants. - Provide context with
.context()(anyhow) ormap_err. - Use
Option<T>for absent-value cases without an error reason. - Implement
Displayon error types — produces human-readable messages. - Implement
Fromon error types — admits the?operator across error types.
The combination — Result for explicit error propagation, ? for compact chains, panic! for bugs, the Error trait, the thiserror/anyhow ecosystem — is the substance of Rust’s error-handling discipline. The mechanism admits substantial robustness without exception-style overhead.