Rust Error Handling
Result<T,E> Β· Option chaining Β· the ? operator Β· unwrap vs expect Β· custom errors Β· panic vs recoverable
Sheet 2 of 2
Rust 2021
Intermediate
Printable
Two Error Strategies
panic! β Unrecoverable
// Immediately stops the thread panic!("index out of bounds"); // Triggers automatically on bad access let v = vec![1, 2, 3]; v[99]; // panics at runtime // Set RUST_BACKTRACE=1 to see trace // $ RUST_BACKTRACE=1 cargo run // Use panic for: // - bugs / programming errors // - tests // - prototype code // - truly unrecoverable states
Result<T, E> β Recoverable
// The Result enum (in std prelude) enum Result<T, E> { Ok(T), // success value Err(E), // error value } // Functions that can fail return Result use std::fs::File; let f = File::open("hello.txt"); // f is Result<File, std::io::Error> // Use for: // - I/O operations // - parsing / conversion // - network calls // - any fallible operation
Decision β which to use?
// Use panic! when: // β Invalid input that CAN'T happen // β Tests β unwrap() is fine // β Prototype / example code // β State is fundamentally broken // Use Result when: // β File might not exist // β Network could fail // β User input might be invalid // β Caller should decide what to do // Golden rule: // Libraries β always Result // Binaries β can panic on bad state
Rust has no exceptions. Errors are just values β
Result<T, E> for recoverable errors, panic! for unrecoverable ones. The type system forces you to acknowledge every possible error at compile time.Result<T, E> β Core Usage
Handle with match
use std::fs::File; use std::io::ErrorKind; let f = match File::open("log.txt") { Ok(file) => file, Err(e) => match e.kind() { // Create if not found ErrorKind::NotFound => match File::create("log.txt") { Ok(fc) => fc, Err(e) => panic!("{e}"), }, other => panic!("{other:?}"), }, };
Returning a Result from a function
use std::io::{self, Read}; use std::fs::File; fn read_username() -> Result<String, io::Error> { let mut f = match File::open("user.txt") { Ok(f) => f, Err(e) => return Err(e), }; let mut s = String::new(); match f.read_to_string(&mut s) { Ok(_) => Ok(s), Err(e) => Err(e), } }
The ? Operator
? on Result β early return on Err
// ? unwraps Ok, or returns Err early // Same function as before β much cleaner: fn read_username() -> Result<String, io::Error> { let mut f = File::open("user.txt")?; let mut s = String::new(); f.read_to_string(&mut s)?; Ok(s) } // Chain ? calls together fn read_username() -> Result<String, io::Error> { let mut s = String::new(); File::open("user.txt")? .read_to_string(&mut s)?; Ok(s) }
? on Option & in main()
// ? also works on Option β returns None fn first_char(s: &str) -> Option<char> { let line = s.lines().next()?; // Option? let ch = line.chars().next()?; // Option? Some(ch) } // Use ? in main() by returning Result fn main() -> Result<(), Box<dyn std::error::Error>> { let f = File::open("config.txt")?; // ... do work ... Ok(()) }
? also converts error types. It calls
From::from(e) on the error, so if your function returns a different error type, Rust will auto-convert it β as long as a From impl exists.unwrap Β· expect Β· unwrap_or Β· unwrap_or_else
unwrap & expect
// unwrap β panics with generic message let f = File::open("hi.txt").unwrap(); // panics: "called Result::unwrap() on Err" // expect β panics with YOUR message let f = File::open("hi.txt") .expect("hi.txt should exist"); // panics: "hi.txt should exist: ..." // Use unwrap/expect only in: // - tests // - prototypes // - cases you're 100% certain succeed
Safe alternatives β no panic
let res: Result<i32, &str> = Err("fail"); // Default value res.unwrap_or(0) // 0 // Compute default lazily res.unwrap_or_else(|e| { eprintln!("Error: {e}"); -1 }) // Default from Default trait res.unwrap_or_default() // 0 (i32::default) // Check then get if res.is_ok() { let val = res.unwrap(); // safe here }
Result combinators
let ok: Result<i32, &str> = Ok(2); // map β transform Ok value ok.map(|n| n * 3) // Ok(6) // map_err β transform Err value ok.map_err(|e| format!("{e}")) // and_then β chain fallible ops ok.and_then(|n| if n > 0 { Ok(n) } else { Err("neg") } ) // or β use fallback Result let err: Result<i32, &str> = Err("x"); err.or(Ok(5)) // Ok(5)
Never use unwrap() in library code. Callers can't handle a panic β always return
Result from library functions so downstream code can decide how to respond.Option<T> β Full Reference
Safe extraction
let opt: Option<i32> = Some(42); opt.unwrap_or(0) // 42 opt.unwrap_or_default() // 42 opt.unwrap_or_else(|| compute()) opt.is_some() // true opt.is_none() // false // Convert Option β Result opt.ok_or("was None") // Ok(42) let none: Option<i32> = None; none.ok_or("was None") // Err("was None")
Combinators
let s: Option<String> = Some(String::from("hello")); // map β transform if Some s.map(|v| v.len()) // Some(5) // and_then β chain Option ops s.as_deref() .and_then(|v| if v.len() > 3 { Some(v) } else { None } ) // Some("hello") // filter β keep if predicate true Some(4).filter(|n| n % 2 == 0) // Some(4) Some(3).filter(|n| n % 2 == 0) // None // flatten β Option<Option<T>> β Option<T> Some(Some(5)).flatten() // Some(5)
Custom Error Types
Implement std::error::Error
use std::fmt; // 1. Define your error type #[derive(Debug)] enum AppError { NotFound(String), ParseError(std::num::ParseIntError), IoError(std::io::Error), } // 2. Implement Display impl fmt::Display for AppError { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { match self { AppError::NotFound(s) => write!(f, "not found: {s}"), AppError::ParseError(e) => write!(f, "parse error: {e}"), AppError::IoError(e) => write!(f, "io error: {e}"), } } } // 3. Impl Error (can be empty) impl std::error::Error for AppError {}
From β auto conversion with ?
// Implement From so ? auto-converts impl From<std::io::Error> for AppError { fn from(e: std::io::Error) -> Self { AppError::IoError(e) } } impl From<std::num::ParseIntError> for AppError { fn from(e: std::num::ParseIntError) -> Self { AppError::ParseError(e) } } // Now ? works across error types! fn run() -> Result<(), AppError> { let _f = File::open("x.txt")?; // IoError let _n = "abc".parse::<i32>()?; // ParseError Ok(()) }
Box<dyn Error> β quick & easy
// No custom type β good for binaries type BoxError = Box<dyn std::error::Error>; fn run() -> Result<(), BoxError> { let _f = File::open("x.txt")?; let _n = "abc".parse::<i32>()?; Ok(()) } // type alias for cleaner signatures type Result<T> = std::result::Result<T, BoxError>; fn process() -> Result<String> { Ok(String::from("done")) }
thiserror & anyhow crates make this much easier.
thiserror generates boilerplate for library errors via #[derive(Error)]. anyhow gives you anyhow::Result<T> with context chaining β ideal for applications.thiserror crate
Cargo.toml
# Cargo.toml [dependencies] thiserror = "1"
Define errors with #[derive(Error)]
use thiserror::Error; #[derive(Error, Debug)] enum AppError { #[error("file not found: {0}")] NotFound(String), #[error("io error")] Io(#[from] std::io::Error), #[error("parse failed")] Parse(#[from] std::num::ParseIntError), } // Generates Display, Error, and From impls // automatically β no boilerplate! fn run() -> Result<(), AppError> { File::open("x.txt")?; // auto From Ok(()) }
anyhow crate
Cargo.toml
# Cargo.toml [dependencies] anyhow = "1"
Context & bail!
use anyhow::{Context, Result, bail, ensure}; fn run() -> Result<()> { // ? works on any error type let _f = File::open("x.txt") .context("opening config file")?; // bail! β early return with error let n: i32 = -1; if n < 0 { bail!("n must be positive, got {n}"); } // ensure! β assert with error ensure!(n > 0, "n ({n}) must be positive"); Ok(()) }
Use thiserror for libraries, anyhow for apps. Libraries need typed errors so callers can match on them. Applications just need to display errors to the user β anyhow's context chaining gives great diagnostics.
Error Propagation Patterns
Collect Results from iterators
// Parse a list of strings to ints let strings = vec!["1","2","3"]; // Fail on first error let nums: Result<Vec<i32>, _> = strings.iter() .map(|s| s.parse::<i32>()) .collect(); // Ok([1, 2, 3]) // Collect only successes (ignore errors) let ok_only: Vec<i32> = vec!["1","x","3"] .iter() .filter_map(|s| s.parse().ok()) .collect(); // [1, 3]
Multiple error types with map_err
use std::num::ParseIntError; #[derive(Debug)] enum MyErr { Parse(ParseIntError), Neg } fn parse_pos(s: &str) -> Result<u32, MyErr> { // Convert ParseIntError β MyErr::Parse let n: i32 = s.parse() .map_err(MyErr::Parse)?; // Check sign if n < 0 { return Err(MyErr::Neg); } Ok(n as u32) }
Error context with anyhow
use anyhow::{Context, Result}; fn load_config(path: &str) -> Result<String> { let mut f = File::open(path) // Add context to any error .with_context(|| format!("opening config: {path}"))?; let mut s = String::new(); f.read_to_string(&mut s) .context("reading config file")?; Ok(s) } // Error chain: "reading config file: // opening config: conf.toml: // No such file or directory (os error 2)"
Error Handling β Quick Reference
| Method / Operator | Works on | Returns | Behaviour |
|---|---|---|---|
| ? | Result / Option | T (unwrapped) | Returns Err/None early, propagates to caller |
| unwrap() | Result / Option | T | Panics with generic message if Err/None |
| expect("msg") | Result / Option | T | Panics with your message if Err/None |
| unwrap_or(default) | Result / Option | T | Returns value or the given default |
| unwrap_or_else(fn) | Result / Option | T | Returns value or calls closure for default |
| unwrap_or_default() | Result / Option | T | Returns value or T::default() |
| map(fn) | Result / Option | Result<U,E> / Option<U> | Transforms Ok/Some value, passes Err/None through |
| map_err(fn) | Result | Result<T,F> | Transforms Err value, passes Ok through |
| and_then(fn) | Result / Option | Result<U,E> / Option<U> | Chains another fallible operation if Ok/Some |
| or(res) | Result / Option | Result / Option | Returns self if Ok/Some, otherwise the argument |
| ok() | Result | Option<T> | Converts Ok(v) β Some(v), Err β None |
| ok_or(err) | Option | Result<T,E> | Converts Some(v) β Ok(v), None β Err(err) |
| is_ok() / is_err() | Result | bool | Check without consuming |
| is_some() / is_none() | Option | bool | Check without consuming |
| filter_map(fn) | Iterator | Iterator<T> | map + flatten β keeps only Some results |
Error Handling Mastery Checklist
| Result Fundamentals | Key point |
|---|---|
| Know when to panic vs use Result | bugs vs expected failures |
| Handle Result with match | Ok(v) / Err(e) arms |
| Return Result from functions | propagate to caller |
| Use ? to propagate cleanly | early return on Err |
| Safe Extraction | Key point |
|---|---|
| Prefer unwrap_or over unwrap | no panic risk |
| Use expect with a clear message | tests / truly impossible |
| Chain combinators: map, and_then | transform without unwrapping |
| Convert between Option and Result | ok() / ok_or() |
| Custom Errors | Key point |
|---|---|
| Impl Display + Error for custom type | required for std compat |
| Impl From for auto ? conversion | ? calls From::from(e) |
| Use thiserror in libraries | #[derive(Error)] |
| Use anyhow in applications | .context() chains |
Rust series complete! Β·
You've covered ownership, borrowing, lifetimes, enums, pattern matching, and the full error handling system. Next steps: explore closures & iterators, async/await, or smart pointers (Box, Rc, Arc, RefCell).