Skip to main content
Technology & EngineeringRust199 lines

Error Handling

Rust error handling with Result, Option, the ? operator, and ecosystem crates anyhow and thiserror

Quick Summary35 lines
You are an expert in Rust error handling for writing robust, recoverable code using Result, Option, and the broader error ecosystem.

## Key Points

- Use `anyhow::Result` in `main()` and binary application code for convenience.
- Use `thiserror` in library crates to provide structured error types callers can match on.
- Always add context with `.context()` or `.with_context()` when propagating errors — bare `?` loses the "what were we trying to do" information.
- Prefer `Result` over `panic!`. Reserve `panic!` for truly unrecoverable states and violated invariants.
- Use `.expect("reason")` over `.unwrap()` so panics carry intent.
- Return `Result` from `main()` for clean exit-code handling:
- **Overusing `.unwrap()`**: Every `.unwrap()` is a potential panic. Use `?`, `.unwrap_or_default()`, or `.expect()` with a reason.
- **Stringly-typed errors**: Returning `Result<T, String>` makes error matching impossible. Use typed errors.
- **Swallowing errors**: Ignoring `Result` with `let _ = fallible_call();` silently discards failures. Assign to `_` only when you consciously choose to discard.
- **Mixing `anyhow` in library public APIs**: Library callers cannot downcast or match on `anyhow::Error` easily. Keep `anyhow` for application code.
- **Not implementing `std::error::Error`**: Custom error types should implement `Error` (done automatically by `thiserror`) so they compose with the ecosystem.

## Quick Example

```rust
fn read_id(input: &str) -> Result<u64, String> {
    input
        .parse::<u64>()
        .map_err(|e| format!("invalid ID '{input}': {e}"))
}
```

```rust
fn main() -> anyhow::Result<()> {
    let config = load_config("app.toml")?;
    run(config)?;
    Ok(())
}
```
skilldb get rust-skills/Error HandlingFull skill: 199 lines
Paste into your CLAUDE.md or agent config

Error Handling — Rust Programming

You are an expert in Rust error handling for writing robust, recoverable code using Result, Option, and the broader error ecosystem.

Core Philosophy

Rust treats errors as data, not as exceptional control flow. By encoding failure into the type system with Result<T, E> and absence with Option<T>, the compiler forces you to acknowledge and handle every failure path. This is not ceremony for its own sake -- it is the mechanism that makes Rust programs reliable without runtime exceptions or hidden panics.

The split between anyhow (for applications) and thiserror (for libraries) reflects a deeper design choice. Application code cares about reporting errors to humans with rich context. Library code cares about structuring errors so callers can programmatically react. Mixing these concerns -- using anyhow::Error in a library's public API or defining elaborate error enums in main.rs -- creates friction in both directions.

The ? operator is the linchpin. It makes error propagation as lightweight as exceptions in other languages while remaining explicit and visible. Every ? in your code is a documented early-return point. Adding .context() at each propagation site builds a causal chain that reads like a stack trace but carries semantic meaning: not just where the error occurred, but what the program was trying to do when it failed.

Anti-Patterns

  • Sprinkling .unwrap() throughout production code: Every .unwrap() is an implicit panic!. In production, prefer ? for propagation, .expect("reason") when an invariant truly cannot be violated, or .unwrap_or_default() when a fallback is acceptable.
  • Returning Result<T, String> from library functions: String errors cannot be matched, downcasted, or composed. Callers are forced to parse error messages. Use thiserror to define a proper enum that callers can handle programmatically.
  • Using anyhow in library public APIs: anyhow::Error is opaque to callers -- they cannot match on variants or recover selectively. Keep anyhow for binary crates where the error's final destination is a log line or user message.
  • Bare ? without context: Propagating with ? alone loses the "what were we trying to do" information. A chain of bare ? calls produces an error like "No such file or directory" with no indication of which file or why. Always attach context with .context() or .with_context().
  • Silently discarding Result with let _ =: Assigning a Result to _ tells the compiler you intentionally ignore the outcome, but it also hides real failures. Only discard results when failure genuinely does not matter, and add a comment explaining why.

Overview

Rust has no exceptions. Errors are values represented by Result<T, E> for recoverable errors and panic! for unrecoverable ones. The Option<T> type handles the absence of a value. The ? operator provides ergonomic error propagation. The anyhow and thiserror crates are the standard ecosystem choices for application-level and library-level error handling respectively.

Core Concepts

Result and Option

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

// Option: value that may be absent
fn find_user(id: u64) -> Option<String> {
    if id == 1 { Some("Alice".into()) } else { None }
}

The ? Operator

Propagates errors up the call stack, performing From conversion automatically:

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

fn read_config() -> Result<String, io::Error> {
    let path = fs::read_to_string("config.toml")?;
    Ok(path)
}

Custom Error Types with thiserror

Use thiserror in libraries to define structured, convertible error types:

use thiserror::Error;

#[derive(Debug, Error)]
pub enum AppError {
    #[error("database error: {0}")]
    Database(#[from] sqlx::Error),

    #[error("invalid configuration: {field}")]
    InvalidConfig { field: String },

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

    #[error(transparent)]
    Unexpected(#[from] anyhow::Error),
}

Application Errors with anyhow

Use anyhow in application code (binaries) for easy ad-hoc error handling:

use anyhow::{Context, Result};

fn load_config(path: &str) -> Result<Config> {
    let content = std::fs::read_to_string(path)
        .with_context(|| format!("failed to read config from {path}"))?;
    let config: Config = toml::from_str(&content)
        .context("failed to parse config TOML")?;
    Ok(config)
}

Implementation Patterns

Combining Option and Result

fn get_env_port() -> Result<u16, anyhow::Error> {
    let port_str = std::env::var("PORT")
        .context("PORT not set")?;
    let port = port_str.parse::<u16>()
        .context("PORT is not a valid u16")?;
    Ok(port)
}

Converting Between Option and Result

// Option -> Result
let value: Option<i32> = Some(42);
let result: Result<i32, &str> = value.ok_or("value was None");

// Result -> Option
let result: Result<i32, String> = Ok(42);
let option: Option<i32> = result.ok();

Error Type Hierarchies

use thiserror::Error;

#[derive(Debug, Error)]
pub enum RepoError {
    #[error("entity not found: {0}")]
    NotFound(String),
    #[error("duplicate key: {0}")]
    Duplicate(String),
    #[error(transparent)]
    Internal(#[from] sqlx::Error),
}

#[derive(Debug, Error)]
pub enum ServiceError {
    #[error(transparent)]
    Repo(#[from] RepoError),
    #[error("validation failed: {0}")]
    Validation(String),
}

Iterating with Results

// Collect into Result<Vec<T>, E> — stops on first error
let numbers: Result<Vec<i32>, _> = vec!["1", "2", "three"]
    .iter()
    .map(|s| s.parse::<i32>())
    .collect();

// Partition successes and failures
let (oks, errs): (Vec<_>, Vec<_>) = vec!["1", "two", "3"]
    .iter()
    .map(|s| s.parse::<i32>())
    .partition(Result::is_ok);

Mapping Errors

fn read_id(input: &str) -> Result<u64, String> {
    input
        .parse::<u64>()
        .map_err(|e| format!("invalid ID '{input}': {e}"))
}

Best Practices

  • Use anyhow::Result in main() and binary application code for convenience.
  • Use thiserror in library crates to provide structured error types callers can match on.
  • Always add context with .context() or .with_context() when propagating errors — bare ? loses the "what were we trying to do" information.
  • Prefer Result over panic!. Reserve panic! for truly unrecoverable states and violated invariants.
  • Use .expect("reason") over .unwrap() so panics carry intent.
  • Return Result from main() for clean exit-code handling:
fn main() -> anyhow::Result<()> {
    let config = load_config("app.toml")?;
    run(config)?;
    Ok(())
}

Common Pitfalls

  • Overusing .unwrap(): Every .unwrap() is a potential panic. Use ?, .unwrap_or_default(), or .expect() with a reason.
  • Stringly-typed errors: Returning Result<T, String> makes error matching impossible. Use typed errors.
  • Swallowing errors: Ignoring Result with let _ = fallible_call(); silently discards failures. Assign to _ only when you consciously choose to discard.
  • Mixing anyhow in library public APIs: Library callers cannot downcast or match on anyhow::Error easily. Keep anyhow for application code.
  • Not implementing std::error::Error: Custom error types should implement Error (done automatically by thiserror) so they compose with the ecosystem.

Install this skill directly: skilldb add rust-skills

Get CLI access →