Error Handling in Practice

Rust has no exceptions. Errors are values, and functions that can fail say so explicitly in their return type. This forces error handling to be a first-class design decision rather than an afterthought, but it also means you need clear patterns for when to use what.

This tutorial covers the practical side: how Result and Option work in real code, how ? removes boilerplate, and when to reach for thiserror or anyhow.


Scenario 1 - The basics: Result and Option

Result<T, E> represents either a success (Ok(T)) or a failure (Err(E)).
Option<T> represents either a value (Some(T)) or nothing (None).

fn parse_port(s: &str) -> Result<u16, std::num::ParseIntError> {
    s.parse::<u16>()
}

fn first_item(items: &[&str]) -> Option<&str> {
    items.first().copied()
}

Use Result when failure has a meaningful cause. Use Option when absence is not an error. The item just is not there.

Extracting values:

// Crash on failure — only acceptable when failure is a programming error
let port = parse_port("8080").unwrap();

// Crash with a readable message
let port = parse_port("8080").expect("PORT must be a valid u16");

// Provide a default
let port = parse_port(input).unwrap_or(3000);

// Branch on the result
match parse_port(input) {
    Ok(p) => println!("Listening on {}", p),
    Err(e) => eprintln!("Invalid port: {}", e),
}

Tip: Reserve .unwrap() for two cases only: tests, and places where you can prove by logic that the value is always Ok or Some. In all other cases, handle or propagate the error.


Scenario 2 - Propagating errors with ?

When a function can fail at multiple points, .unwrap()-ing every step produces noisy code. The ? operator propagates an error up to the caller automatically.

Without ?

fn read_config(path: &str) -> Result<String, Box<dyn std::error::Error>> {
    let content = match std::fs::read_to_string(path) {
        Ok(c) => c,
        Err(e) => return Err(Box::new(e)),
    };
    let trimmed = match content.trim().parse::<String>() {
        Ok(s) => s,
        Err(e) => return Err(Box::new(e)),
    };
    Ok(trimmed)
}

With ?

fn read_config(path: &str) -> Result<String, Box<dyn std::error::Error>> {
    let content = std::fs::read_to_string(path)?;
    Ok(content.trim().to_string())
}

? does three things: if the value is Err, it calls .into() to convert the error to the function's error type, then returns early. If the value is Ok, it unwraps and continues. The function's return type matters here: ? needs a target type to convert into.

? also works on Option:

fn get_username(id: u32) -> Option<String> {
    let user = find_user(id)?;  // returns None if find_user returns None
    Some(user.name.clone())
}

Scenario 3 - Multiple error types in one function

A real function often calls library code that returns different error types. Returning Box<dyn std::error::Error> works but loses type information at the call site.

The standard solution is to define your own error type.

Defining an error type with thiserror

thiserror is a small derive macro that eliminates the boilerplate of implementing std::error::Error and Display by hand.

# Cargo.toml
[dependencies]
thiserror = "2"
use thiserror::Error;

#[derive(Debug, Error)]
pub enum ConfigError {
    #[error("could not read file: {0}")]
    Io(#[from] std::io::Error),

    #[error("invalid value for '{field}': {message}")]
    InvalidValue { field: String, message: String },

    #[error("missing required key: {0}")]
    MissingKey(String),
}

#[from] implements From<std::io::Error> for ConfigError automatically, so ? can convert io::Error into ConfigError::Io without any extra code.

fn load_config(path: &str) -> Result<Config, ConfigError> {
    let content = std::fs::read_to_string(path)?;  // io::Error -> ConfigError::Io via From

    let value = content.trim();
    if value.is_empty() {
        return Err(ConfigError::MissingKey("config content".to_string()));
    }

    Ok(parse(value)?)
}

Use thiserror when you are writing a library, a module with a public API, or any code where callers need to match on specific error variants and respond differently to each.


Scenario 4 - Application-level error handling with anyhow

In application code (binaries, CLI tools, integration code), you often do not need callers to match on error variants. You just need errors to propagate, carry context, and produce a readable message at the top.

anyhow is designed for exactly this.

# Cargo.toml
[dependencies]
anyhow = "1"
use anyhow::{Context, Result};

fn run() -> Result<()> {
    let config = std::fs::read_to_string("config.toml")
        .context("failed to read config.toml")?;

    let port: u16 = config.trim().parse()
        .context("config.toml must contain a valid port number")?;

    println!("Starting on port {}", port);
    Ok(())
}

anyhow::Result<T> is Result<T, anyhow::Error>. Any error type that implements std::error::Error converts into it automatically, so ? works with any library error without a custom enum.

.context() wraps the error with an additional message. When the error is printed, both messages appear in order, giving you a readable chain:

Error: failed to read config.toml

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

Use anyhow when you are writing a binary or application layer and you want errors to bubble up with context rather than be matched on.


Scenario 5 - Transforming and combining Results

Mapping the success value

let port: Result<u16, _> = "8080".parse();
let display: Result<String, _> = port.map(|p| format!(":{}", p));
// Ok(":8080")

Mapping the error

let result = std::fs::read_to_string("file.txt")
    .map_err(|e| format!("read failed: {}", e));

Collecting a Vec of Results

When you have an iterator of Result<T, E> and want either a Vec<T> or the first error:

let strings = vec!["1", "2", "bad", "4"];
let numbers: Result<Vec<i32>, _> = strings.iter().map(|s| s.parse::<i32>()).collect();
// Err(ParseIntError) — stops at "bad"

collect() into Result<Vec<T>, E> short-circuits at the first Err. If all items are Ok, you get Ok(Vec<T>).


Scenario 6 - unwrap in tests and scripts

unwrap() and expect() are fine in tests. A panic is the correct failure mode there, and the message from expect() helps identify the failing assertion.

#[test]
fn parses_valid_port() {
    let port = "8080".parse::<u16>().expect("test input should be valid");
    assert_eq!(port, 8080);
}

For quick scripts and main() functions where you genuinely do not want to handle errors, propagate to main instead:

fn main() -> anyhow::Result<()> {
    let content = std::fs::read_to_string("data.txt")?;
    println!("{}", content);
    Ok(())
}

When main() returns Result, a returned Err prints the error and exits with a non-zero code. No manual eprintln! or process::exit() needed.


Choosing the Right Tool

SituationReach for
Library or public APIthiserror: typed, matchable errors
Application / binaryanyhow: context-rich, propagate freely
Prototype or scriptBox<dyn Error> or anyhow
Absence is not an errorOption
Tests.unwrap() / .expect()
Guaranteed safe unwrap.unwrap() with a comment explaining why

Key Takeaways

  • Errors are values. Functions that can fail declare it in their return type.
  • ? propagates errors up to the caller and converts between error types via From.
  • Define a typed error enum with thiserror when callers need to distinguish between error variants.
  • Use anyhow in application code when you only need errors to propagate and display cleanly.
  • .context() adds human-readable explanation to an error without replacing it.
  • Collect an iterator of Result into Result<Vec<T>, E> to fail fast on the first error.