Error Handling Patterns

Rust error handling with Result, the ? operator, custom error types, thiserror, anyhow, and common patterns

Rust has no exceptions. Errors are values returned via Result<T, E>. The compiler ensures you handle them. This reference covers the patterns you’ll use every day.

The two error types

// Option<T> — something may be absent
fn find_user(id: u32) -> Option<User> {
    users.iter().find(|u| u.id == id).cloned()
}

// Result<T, E> — an operation may fail
fn read_config(path: &str) -> Result<Config, std::io::Error> {
    let data = std::fs::read_to_string(path)?;
    Ok(parse_config(&data))
}

Guideline: Use Option when absence is expected (search, lookup). Use Result when failure is unexpected or carries information.

The ? operator

The ? operator unwraps Ok or early-returns Err. It’s the idiomatic way to chain fallible operations.

// Without ? — verbose
fn load_user(path: &str) -> Result<User, std::io::Error> {
    let data = match std::fs::read_to_string(path) {
        Ok(d) => d,
        Err(e) => return Err(e),
    };
    let user: User = match serde_json::from_str(&data) {
        Ok(u) => u,
        Err(e) => return Err(std::io::Error::new(
            std::io::ErrorKind::InvalidData, e)),
    };
    Ok(user)
}

// With ? — concise
fn load_user(path: &str) -> Result<User, Box<dyn std::error::Error>> {
    let data = std::fs::read_to_string(path)?;
    let user: User = serde_json::from_str(&data)?;
    Ok(user)
}

The ? operator also performs automatic conversion via the From trait. If the function returns Result<_, MyError>, any Err type that implements From<OriginalError> will be converted automatically.

Custom error types

For libraries, define your own error enum:

use std::fmt;

#[derive(Debug)]
pub enum AppError {
    Io(std::io::Error),
    Parse(serde_json::Error),
    NotFound(String),
    InvalidInput { field: String, reason: String },
}

impl std::fmt::Display for AppError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            AppError::Io(e) => write!(f, "IO error: {}", e),
            AppError::Parse(e) => write!(f, "Parse error: {}", e),
            AppError::NotFound(s) => write!(f, "Not found: {}", s),
            AppError::InvalidInput { field, reason } => {
                write!(f, "Invalid {}: {}", field, reason)
            }
        }
    }
}

impl std::error::Error for AppError {}

// From implementations for automatic ? conversion
impl From<std::io::Error> for AppError {
    fn from(e: std::io::Error) -> Self { AppError::Io(e) }
}

impl From<serde_json::Error> for AppError {
    fn from(e: serde_json::Error) -> Self { AppError::Parse(e) }
}

// Now ? works seamlessly
fn load_user(path: &str) -> Result<User, AppError> {
    let data = std::fs::read_to_string(path)?;           // io::Error -> AppError
    let user: User = serde_json::from_str(&data)?;        // json::Error -> AppError
    Ok(user)
}

fn find_user(id: u32) -> Result<User, AppError> {
    users.iter().find(|u| u.id == id)
        .cloned()
        .ok_or(AppError::NotFound(format!("User {}", id)))
}

thiserror — derive Error

The thiserror crate eliminates boilerplate for custom error types:

use thiserror::Error;

#[derive(Error, Debug)]
pub enum AppError {
    #[error("IO error: {0}")]
    Io(#[from] std::io::Error),

    #[error("Parse error: {0}")]
    Parse(#[from] serde_json::Error),

    #[error("Not found: {0}")]
    NotFound(String),

    #[error("Invalid {field}: {reason}")]
    InvalidInput { field: String, reason: String },

    #[error("Permission denied for {resource}")]
    PermissionDenied { resource: String },
}

#[from] automatically generates the From implementation, enabling ? conversion.

anyhow — application error handling

For applications (not libraries), anyhow simplifies error handling:

use anyhow::{Context, Result, bail};

fn load_config(path: &str) -> Result<Config> {
    let data = std::fs::read_to_string(path)
        .context(format!("Failed to read {}", path))?;

    let config: Config = toml::from_str(&data)
        .context("Failed to parse config")?;

    if config.port == 0 {
        bail!("Invalid port number: 0");
    }

    Ok(config)
}

fn main() -> Result<()> {
    let config = load_config("config.toml")?;
    run(config)?;
    Ok(())
}

Guideline: Use thiserror for library crates (callers need to match on error variants). Use anyhow for application code (you mainly need to log/display errors).

Common patterns

ok_or — convert Option to Result

fn get_user(id: u32) -> Result<User, AppError> {
    users.iter().find(|u| u.id == id)
        .cloned()
        .ok_or(AppError::NotFound(format!("User {}", id)))
}

map_err — transform the error

fn read_data(path: &str) -> Result<Data, AppError> {
    std::fs::read_to_string(path)
        .map_err(|e| AppError::Io(e))?;
    // With From impl, just use ? instead:
    let data = std::fs::read_to_string(path)?;
    Ok(parse(data))
}

unwrap and expect — for prototyping and tests

// unwrap — panics on Err/None, use in tests only
let data = std::fs::read_to_string("test.txt").unwrap();

// expect — panics with a message
let data = std::fs::read_to_string("test.txt")
    .expect("test file should exist");

// In production code, use ? or proper error handling

Combining Results

// Chain with and_then
fn process(input: &str) -> Result<Output, AppError> {
    parse_input(input)
        .and_then(validate)
        .and_then(transform)
}

// Combine multiple results
fn load_all() -> Result<(Config, Data, Cache), AppError> {
    let config = load_config("config.toml")?;
    let data = load_data("data.bin")?;
    let cache = load_cache("cache.db")?;
    Ok((config, data, cache))
}

Fallible constructors

struct Email(String);

impl Email {
    fn new(address: &str) -> Result<Self, AppError> {
        if address.contains('@') {
            Ok(Email(address.to_string()))
        } else {
            Err(AppError::InvalidInput {
                field: "email".into(),
                reason: "must contain @".into(),
            })
        }
    }
}

let email = Email::new("[email protected]")?;

The “try” block pattern (Rust 2024+)

// In nightly/2024 edition, try blocks evaluate to Result:
// let result: Result<i32, _> = try {
//     let data = std::fs::read_to_string("file.txt")?;
//     let num: i32 = data.trim().parse()?;
//     num * 2
// };

Choosing the right approach

Situation Use
Value may be absent Option<T>
Operation may fail Result<T, E>
Library with typed errors Custom error enum + thiserror
Application error handling anyhow
Tests and prototyping .unwrap() / .expect()
Production code ? operator + proper error types
Need to attach context .context() (anyhow)
Need to match on errors Custom enum (thiserror)