Understanding Rust Error Handling

A practical guide to error handling in Rust — from Result and Option to anyhow and thiserror, with real-world patterns.

·3 min read

Understanding Rust Error Handling#

Error handling is one of Rust's greatest strengths. No exceptions, no null pointer surprises — just explicit types that force you to handle every failure path. Once it clicks, you'll wonder why other languages ever thought exceptions were a good idea.

The basics: Result and Option#

Everything starts with two enums:

enum Result<T, E> {
    Ok(T),
    Err(E),
}
 
enum Option<T> {
    Some(T),
    None,
}

Result is for operations that can fail. Option is for values that might not exist. Simple, composable, and type-checked at compile time.

The ? operator#

The question mark operator is syntactic sugar for early returns on errors:

// Without `?`
fn read_config() -> Result<Config, io::Error> {
    let content = match fs::read_to_string("config.toml") {
        Ok(c) => c,
        Err(e) => return Err(e),
    };
    // ...parse content
}
 
// With `?` — same behavior, less noise
fn read_config() -> Result<Config, io::Error> {
    let content = fs::read_to_string("config.toml")?;
    // ...parse content
}

Custom error types with thiserror#

For libraries, define explicit error types:

use thiserror::Error;
 
#[derive(Error, Debug)]
enum AppError {
    #[error("Failed to read config: {0}")]
    ConfigRead(#[from] io::Error),
 
    #[error("Invalid config format: {0}")]
    ConfigParse(#[from] toml::de::Error),
 
    #[error("Missing required field: {field}")]
    MissingField { field: String },
}

thiserror generates the Display and From implementations automatically. Your callers get precise, matchable error variants.

Application errors with anyhow#

For applications (not libraries), anyhow simplifies everything:

use anyhow::{Context, Result};
 
fn load_and_process() -> Result<Output> {
    let config = fs::read_to_string("config.toml")
        .context("Failed to read config file")?;
 
    let parsed: Config = toml::from_str(&config)
        .context("Failed to parse config")?;
 
    process(parsed)
        .context("Processing failed")
}

The .context() method adds human-readable messages to the error chain. When the error is printed, you get a full trace:

Error: Processing failed

Caused by:
    0: Failed to validate input
    1: missing required field "name"

Pattern: converting between error types#

Use map_err when you need to convert one error type to another:

fn fetch_data(url: &str) -> Result<Data, AppError> {
    reqwest::blocking::get(url)
        .map_err(|e| AppError::Network(e.to_string()))?
        .json::<Data>()
        .map_err(|e| AppError::Parse(e.to_string()))
}

When to use which#

| Situation | Use | |-----------|-----| | Library code | thiserror with custom error enum | | Application code | anyhow with .context() | | Quick prototype | anyhow everywhere | | Performance-critical path | Custom error type (no heap allocation) | | Optional values | Option<T> with .ok_or() to convert to Result |

The golden rule#

Make invalid states unrepresentable.

If a function can fail, return Result. If a value might not exist, use Option. Never panic in library code. Let the compiler be your safety net.

Rust's error handling takes more upfront effort than try/catch, but the payoff is enormous: every error is visible in the type signature, and the compiler won't let you forget to handle it.

Want more like this?