Error Handling In Depth

The ? operator across function boundaries, converting errors with From/Into, and anyhow vs thiserror.

Recap: where Option and Result leave off

The previous page introduced Option<T> for absent values, Result<T, E> for recoverable failures, and the ? operator for propagating an Err out of a function that returns a compatible type. That's enough for a single function with a single error type. Real programs are rarely that tidy — a function that reads a config file can fail because the file doesn't exist (an std::io::Error) or because a value inside it isn't a valid number (a std::num::ParseIntError). This page is about that gap: unifying different error types, converting between them automatically, and the two crates almost every real Rust project reaches for once error handling gets non-trivial.

The ? operator across function boundaries

? isn't limited to unwrapping a Result produced by a direct function call — it works through as many layers of function calls as you like, as long as every function in the chain returns a Result (or Option) with a compatible error type. Each ? immediately returns the Err to its own caller, which can itself use ? to forward it again, all the way up the call stack:

Rust
use std::num::ParseIntError;

fn parse_number(input: &str) -> Result<i32, ParseIntError> {
    input.trim().parse::<i32>()
}

fn double_it(input: &str) -> Result<i32, ParseIntError> {
    let n = parse_number(input)?; // propagates ParseIntError from parse_number, if any
    Ok(n * 2)
}

fn main() {
    match double_it("21") {
        Ok(n) => println!("Doubled: {n}"),           // Doubled: 42
        Err(e) => println!("Parse error: {e}"),
    }
}

The requirement is that double_it's error type must be the same as (or convertible into, covered next) parse_number's error type. This is what trips people up the first time they try to combine two functions that fail with genuinely different error types.

Converting between error types with From and Into

When two fallible operations in the same function return different error types, ? still works — as long as there's a From<SourceError> for TargetError implementation, because ? automatically calls .into() on the error it's propagating. This is the mechanism that makes a single function able to fail in multiple distinct ways while still returning one clean Result<T, MyError> type:

Rust
use std::fmt;
use std::fs;
use std::num::ParseIntError;

#[derive(Debug)]
enum ConfigError {
    Io(std::io::Error),
    Parse(ParseIntError),
}

impl fmt::Display for ConfigError {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        match self {
            ConfigError::Io(e) => write!(f, "could not read config file: {e}"),
            ConfigError::Parse(e) => write!(f, "could not parse config value: {e}"),
        }
    }
}

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

// These two impls are what let `?` silently convert either source error into ConfigError.
impl From<std::io::Error> for ConfigError {
    fn from(e: std::io::Error) -> Self {
        ConfigError::Io(e)
    }
}

impl From<ParseIntError> for ConfigError {
    fn from(e: ParseIntError) -> Self {
        ConfigError::Parse(e)
    }
}

fn read_max_connections(path: &str) -> Result<u32, ConfigError> {
    let contents = fs::read_to_string(path)?;       // io::Error -> ConfigError via From
    let value: u32 = contents.trim().parse()?;        // ParseIntError -> ConfigError via From
    Ok(value)
}

fn main() {
    match read_max_connections("config.txt") {
        Ok(n) => println!("Max connections: {n}"),
        Err(e) => println!("Failed to load config: {e}"),
    }
}

Neither ? line needed an explicit .map_err(ConfigError::Io) — the compiler found the matching From implementation on its own. This is the idiomatic Rust way to unify heterogeneous error sources into one type callers can match on with a single, well-defined enum, instead of a tangle of manual .map_err() calls at every call site.

Box<dyn Error> — a quicker, less structured alternative

Writing a custom enum plus two From impls is worth it in library code that callers need to match on. For an application's internal glue code, or a quick script, that ceremony is often more than the situation calls for. Box<dyn std::error::Error> is a trait object that can hold any error type at all, at the cost of callers no longer being able to match on which specific error occurred — only inspect its Display output:

Rust
use std::error::Error;
use std::fs;

fn read_max_connections(path: &str) -> Result<u32, Box<dyn Error>> {
    let contents = fs::read_to_string(path)?;   // io::Error coerces into Box<dyn Error> automatically
    let value: u32 = contents.trim().parse()?;    // ParseIntError coerces the same way
    Ok(value)
}

Both error types satisfy Box<dyn Error> with no From impls of your own required, because the standard library already implements From<E> for Box<dyn Error> for every E: Error. It's genuinely less code — the trade-off is a caller can no longer write match err { ConfigError::Io(_) => ..., ConfigError::Parse(_) => ... }, only println!("{err}") or a downcast.

anyhow and thiserror, conceptually

Two crates from the ecosystem formalize the two approaches above and are close to a de facto standard in real Rust projects:

  • thiserror provides a derive macro that generates the Display, std::error::Error, and From boilerplate shown above automatically, from attributes on your own enum. It's meant for library code whose callers still need a well-typed, matchable error.
  • anyhow provides a single anyhow::Error type that any error can be converted into with ?, plus easy attachment of extra context (.context("failed to read config")). It's meant for application code (the fn main binary, not a published library) that just needs to propagate and report errors, not have callers match on specific variants.
Rust
// Illustrative — requires `thiserror = "1"` in Cargo.toml
use thiserror::Error;

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

    #[error("could not parse config value: {0}")]
    Parse(#[from] std::num::ParseIntError),
}
Rust
// Illustrative — requires `anyhow = "1"` in Cargo.toml
use anyhow::{Context, Result};

fn read_max_connections(path: &str) -> Result<u32> {
    let contents = std::fs::read_to_string(path)
        .context("could not read config file")?;
    let value: u32 = contents.trim().parse()
        .context("could not parse config value")?;
    Ok(value)
}

#[from] in the thiserror example generates exactly the From implementation written out by hand earlier. anyhow::Result<T> is shorthand for Result<T, anyhow::Error>, and .context(...) attaches a human-readable message without losing the original underlying error, which still prints via {:#} or .source().

Hand-rolled enum + From thiserror anyhow
Typical use Small project, no new dependency wanted Library/crate whose callers match on error variants Application (binary) code, top-level error reporting
Callers can match specific errors Yes Yes No — one opaque anyhow::Error
Boilerplate Most — write Display/Error/From by hand Generated by the derive macro None — just propagate with ?
Adds a dependency No Yes Yes

Common mistakes

  • Implementing Display for a custom error type but forgetting to implement (or derive) std::error::Error for it — without it, the type can't be used with Box<dyn Error>, anyhow, or anywhere else the standard library expects a real error type.
  • Reaching for anyhow inside a library crate whose callers genuinely need to distinguish between failure cases — that forces every consumer of the library to deal with one opaque error type instead of a matchable enum.
  • Writing a From impl that swallows useful information (e.g., converting every error into a bare String with no source error preserved) instead of wrapping the original error, losing the ability to inspect what actually went wrong underneath.
  • Manually match-ing and re-wrapping every error at every call site instead of defining a couple of From impls once and letting ? do the conversion automatically.

Interview questions

Q: What has to be true for the ? operator to work when a function calls another function that returns a different error type? There must be a From<OtherError> for ThisFunctionsErrorType implementation, because ? automatically calls .into() on the error before propagating it. Without a matching From impl, the code simply won't compile — Rust never silently discards or coerces an incompatible error type.

Q: What's the practical difference between thiserror and anyhow? thiserror generates the Display/Error/From boilerplate for a custom, matchable error enum, and is meant for library code whose callers need to distinguish between specific failure cases. anyhow provides one flexible anyhow::Error type that anything can convert into via ?, plus easy context attachment, and is meant for application code that just needs to propagate and report errors without exposing a typed enum to match on.

Q: Why would you choose a custom error enum over Box<dyn Error>? A custom enum lets callers match on exactly which failure occurred (ConfigError::Io vs. ConfigError::Parse) and react differently to each. Box<dyn Error> is quicker to write since it needs no From impls of your own, but it erases the concrete type — callers can only print or log the error, not branch on what specifically went wrong.