Error Handling in Rust: Result, ?, anyhow, and thiserror

Every Rust developer eventually has the same moment: the code is logically correct, the types line up, and the compiler still refuses. Not because the program would misbehave, but because it might — somewhere, in some interleaving you have not considered. That is the deal Rust offers. You pay for correctness at compile time, and in exchange the entire category of runtime surprises that other languages discover in production simply cannot exist. Error handling is where that bargain is most visible, because Rust forces a decision that most languages let you defer: is this failure expected, or is it a bug?

That single question splits Rust error handling into two halves. Bugs — broken invariants, impossible states — become panics. Expected failures — file not found, connection refused, invalid input — become values of type Result that flow through your program like any other data. The standard library, anyhow, and thiserror are just tools for making the second half ergonomic. This post walks through the whole stack: when to panic, how Result and the ? operator work, and how to structure errors so that callers and operators each get what they need.

Panic Is for Bugs, Result Is for Everything Else

A panic unwinds the thread (or aborts the process, depending on profile settings). It is the right tool when continuing would be meaningless: index out of bounds, arithmetic overflow in debug builds, an enum invariant violated. The Rust book’s guidance is direct — if the caller could reasonably handle the failure, return a Result instead. Panicking in a library because a network resource was unavailable is a design error; the caller can retry, degrade, or report. Panicking because a slice that your own invariant says is non-empty is empty is fine — nobody can handle that, because it means your code is wrong.

// Bug: impossible state, unreachable by construction. Panic is honest.
fn first_checkpoint(scores: &[u32]) -> u32 {
    assert!(!scores.is_empty(), "checkpoint list must be non-empty");
    scores[0]
}

// Expected failure: the caller may want to handle this. Return a Result.
fn parse_port(raw: &str) -> Result<u16, String> {
    raw.parse::<u16>()
        .map_err(|e| format!("invalid port '{raw}': {e}"))
}

The ? Operator and From Conversions

Result<T, E> is an ordinary enum with two variants, and you can always match on it by hand. What makes error propagation bearable is the ? operator: on Ok it unwraps the value, on Err it returns the error from the enclosing function immediately. Crucially, ? also applies a From conversion, so an inner error type is automatically converted into the function’s declared error type if that conversion exists. That single trait bound is what lets each layer of a program speak its own error dialect without manual translation.

use std::fs;
use std::io;
use std::num::ParseIntError;

// The ? operator propagates errors and converts types via From.
fn read_threshold(path: &str) -> Result<u32, io::Error> {
    let raw = fs::read_to_string(path)?; // io::Error propagates as-is
    let n: u32 = raw.trim().parse()?;    // ParseIntError -> io::Error via From
    Ok(n)
}

That snippet compiles because the standard library provides impl From<ParseIntError> for io::Error. In your own code you will usually derive those conversions rather than write them — which is where thiserror comes in.

Library Code: Typed Errors with thiserror

A library’s error type is part of its public API. Callers want to match on variants — retry on Timeout, surface Auth to the user, bubble up everything else. thiserror generates the boilerplate: a Display implementation from #[error(...)] attributes, From impls from #[from], and per-variant source chaining. The generated type is a plain enum — no runtime dependency, no boxing, fully matchable.

use thiserror::Error;

#[derive(Debug, Error)]
pub enum StoreError {
    #[error("connection to {host} failed: {source}")]
    Connect { host: String, source: std::io::Error },

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

    #[error("serialization failed")]
    Serialize(#[from] serde_json::Error),
}

// #[from] makes ? work on serde_json::Error for free:
fn save(v: &serde_json::Value) -> Result<String, StoreError> {
    Ok(serde_json::to_string(v)?)
}

Every variant documents itself, and the source field preserves the causal chain for loggers and reporters. Callers can match on StoreError::NotFound(key) and take real action — something impossible with a stringly-typed error.

Application Code: Context with anyhow

Binaries have different needs. Nobody upstream matches on a CLI’s error variants; the goal is a message that tells the operator what the program was doing when things broke. anyhow provides exactly that: an opaque, boxable error with human-readable context layers attached on the way up. Its context() and with_context() methods wrap any error in a description of the surrounding intent, and the printed report reads like a backtrace of blame.

use anyhow::{Context, Result};

fn run() -> Result<()> {
    let config_path = std::env::var("APP_CONFIG")
        .context("APP_CONFIG must be set")?;
    let raw = std::fs::read_to_string(&config_path)
        .with_context(|| format!("reading config from '{config_path}'"))?;
    let cfg: Config = toml::from_str(&raw)
        .context("parsing app configuration")?;
    serve(cfg)
}

A failure in that function prints something like Error: parsing app configuration followed by the underlying toml::de::Error — the operator sees which stage failed and why, without a debugger. When a rare caller does need the concrete type, downcast_ref recovers it from the boxed error. The anyhow crate and thiserror are companion pieces by the same author, and the intended split is simple: thiserror for libraries you publish, anyhow for binaries you run.

Anti-Patterns Worth Naming

  • Stringly-typed errors. Result<T, String> forces callers to parse your error message to make decisions. Messages are for humans; variants are for code.
  • Box<dyn Error> in library APIs. It works, but it erases the type information match needs, and it can pull in unnecessary trait objects. An enum (hand-rolled or via thiserror) says the same thing with a real API.
  • catch_unwind as control flow. Catching a panic to implement “retry on any failure” turns bugs into load. Unwind safety is subtle, and a caught panic may leave observers in a torn state. If a failure is expected, it should have been a Result in the first place.
  • unwrap in any path that touches the outside world. Files, sockets, environment variables, and user input fail routinely. unwrap() there converts an ordinary Tuesday into a crash.

A Decision Guide

  • Is the failure a broken invariant nobody can handle? panic! (or assert!, or unreachable!).
  • Are you writing a library whose callers must react differently to different failures? A typed error enum with thiserror.
  • Are you writing a binary that just needs good failure messages? anyhow::Result plus context() at each stage.
  • Does an inner error need to cross a layer boundary? Let ? and From do the translation; add #[from] when it is mechanical.
  • Do you need the concrete error back out of an anyhow::Error? downcast_ref — sparingly.

The mental shift is small but permanent: errors stop being exceptional control flow and become values you design, name, and route on purpose. Once the habit settles, going back to a language where any call can throw anything feels like flying without instruments — and the Rust compiler is no longer the enemy but the pre-flight checklist.

Leave a Reply

Your email address will not be published. Required fields are marked *