Error Handling

Rust has no exceptions. Errors are values you handle explicitly with Result<T, E> and Option<T>. The type system forces you to deal with every failure path.

Result and Option

// Result: operation that can fail
enum Result<T, E> {
    Ok(T),    // success value
    Err(E),   // error value
}

// Option: value that might not exist
enum Option<T> {
    Some(T),  // value present
    None,     // value absent
}
use std::fs;

// File reading returns Result<String, io::Error>
let content = fs::read_to_string("config.toml");

match content {
    Ok(text) => println!("Config: {text}"),
    Err(e) => eprintln!("Failed to read config: {e}"),
}

// Vec::get returns Option<&T>
let numbers = vec![10, 20, 30];
match numbers.get(5) {
    Some(n) => println!("Found: {n}"),
    None => println!("Index out of bounds"),
}

The ? Operator

Propagate errors up the call stack without verbose match blocks. If the value is Err, return early from the function. If Ok, unwrap the value.

use std::fs;
use std::io;

fn read_username() -> Result<String, io::Error> {
    let content = fs::read_to_string("username.txt")?; // returns Err early if failed
    Ok(content.trim().to_string())
}

? works on Option too:

fn first_even(numbers: &[i32]) -> Option<i32> {
    let first = numbers.first()?;  // returns None if empty
    if first % 2 == 0 {
        Some(*first)
    } else {
        None
    }
}

Tip: ? can convert error types automatically if the source type implements From<SourceError> for TargetError. This is how anyhow and thiserror work under the hood.

The Golden Rule: Never .unwrap() in Production

// These will panic (crash) if the value is Err/None:
let f = File::open("config.toml").unwrap();       // panic on missing file
let n: i32 = "abc".parse().expect("not a number"); // panic with message

// unwrap() is fine in:
// - Tests
// - Examples and prototypes
// - Cases where you've proven the value exists (with a comment explaining why)

Gotcha: .unwrap() turns a recoverable error into a program crash. In production code, use ?, match, or unwrap_or_default() instead.

Useful Combinators

Methods on Result and Option that avoid verbose matching.

// Provide a default
let port: u16 = env::var("PORT")
    .ok()                           // Result -> Option
    .and_then(|s| s.parse().ok())   // parse, converting Result -> Option
    .unwrap_or(8080);               // default if None

// Transform the success value
let upper: Result<String, io::Error> = fs::read_to_string("name.txt")
    .map(|s| s.to_uppercase());

// Chain fallible operations
let config = fs::read_to_string("config.toml")
    .and_then(|text| toml::from_str(&text).map_err(|e| io::Error::other(e)));
CombinatorOn ResultOn Option
map(f)Transform Ok(T) to Ok(U)Transform Some(T) to Some(U)
and_then(f)Chain fallible operationsChain operations returning Option
unwrap_or(default)Use default on ErrUse default on None
unwrap_or_default()Use Default::default() on ErrUse Default::default() on None
ok()Convert Result to OptionN/A
map_err(f)Transform the error typeN/A

thiserror for Libraries

Define structured error types with automatic Display and From implementations.

[dependencies]
thiserror = "2"
use thiserror::Error;

#[derive(Error, Debug)]
enum AppError {
    #[error("config file not found: {path}")]
    ConfigNotFound { path: String },

    #[error("invalid port number: {0}")]
    InvalidPort(String),

    #[error("database error")]
    Database(#[from] sqlx::Error),  // auto-converts sqlx::Error via From

    #[error("io error")]
    Io(#[from] std::io::Error),
}

fn load_config(path: &str) -> Result<Config, AppError> {
    let text = std::fs::read_to_string(path)?;  // io::Error auto-converts to AppError::Io
    // ...
}

anyhow for Applications

When you don’t need structured error types - just propagate and add context.

[dependencies]
anyhow = "1"
use anyhow::{Context, Result};

fn load_config() -> Result<Config> {  // anyhow::Result = Result<T, anyhow::Error>
    let text = std::fs::read_to_string("config.toml")
        .context("failed to read config file")?;

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

    Ok(config)
}

fn main() -> Result<()> {
    let config = load_config()?;
    println!("Loaded: {:?}", config);
    Ok(())
}

When the error prints, you get a chain of context:

Error: failed to read config file

Caused by:
    No such file or directory (os error 2)

When to Use Which

SituationUseWhy
Library cratethiserrorCallers need to match on specific error variants
Application binaryanyhowYou just need to report errors with context
Quick prototype.unwrap() / .expect()Get something working, refine later
Known-safe unwrap.expect("reason")Document why it can’t fail

Tip: You can use both in the same project. Define error types with thiserror in your library code, and use anyhow in main.rs and CLI glue code.

Error Wrapping Pattern

Add context as errors propagate up through layers.

use anyhow::{Context, Result};

fn read_user_age(path: &str) -> Result<u32> {
    let text = std::fs::read_to_string(path)
        .with_context(|| format!("failed to read user file: {path}"))?;

    let age: u32 = text.trim().parse()
        .with_context(|| format!("invalid age in {path}: {text:?}"))?;

    Ok(age)
}

Tip: Use .with_context(|| ...) over .context(...) when the message includes runtime values. The closure is only evaluated on error, avoiding unnecessary allocations.

Next: Ownership | Traits