Rust in Practice #2 Error Design: anyhow and thiserror, Engineering How It Dies

4 min read

The basics in Part 6 drew the boundary: expected failures are Result, impossible states are panic. Practice needs one more layer. A CLI tool’s error messages are the screen the user sees — part of the interface. The difference between a tool that dies with Error: No such file or directory (os error 2) and one that dies with error: failed to open log file: access.log comes from design, not implementation. This part covers the two crates responsible for that design: anyhow and thiserror.

The problem: error types are all different #

loglens already faces several kinds of failure. Opening a file gives std::io::Error, parsing a status code gives ParseIntError, and the JSON logs we add later give serde_json::Error. To propagate them with Part 6’s ?, the function’s return type must hold them all. Box<dyn Error> was the answer there; in practice we use an upgraded version that can also carry context.

Install
cargo add anyhow
src/main.rs
use anyhow::{Context, Result};

fn run(cli: Cli) -> Result<()> {
    // ...
    Ok(())
}

fn main() -> Result<()> {
    run(Cli::parse())
}

anyhow::Result<T> abbreviates Result<T, anyhow::Error>, and anyhow::Error is the type that holds any error. Its role matches Box<dyn Error>, but the decisive difference is context.

src/main.rs
use std::fs::File;

fn open_log(path: &Path) -> Result<File> {
    File::open(path)
        .with_context(|| format!("failed to open log file: {}", path.display()))
}

Now a missing file dies like this:

Example output
Error: failed to open log file: access.log

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

The upper layer’s explanation (“what we were trying to do”) and the lower layer’s cause (“why it failed”) print as a chain. Add context at each waypoint that ? passes through, and the error message becomes a route map to the failure point. The reason it takes a closure (|| format!(...)) is to avoid building the string on the success path. Deferring the cost of failure-only strings in code that mostly succeeds is the same principle as lazy evaluation in basics Part 7.

thiserror: errors that callers branch on #

anyhow’s errors are for humans to read. When code must branch on the kind of error, the story changes. Consider the log parser we build in Part 3: an “empty line,” a “malformed line,” and a “line whose status code is not a number” deserve different treatment (skip empty lines silently; count and report the rest). The tool for building concrete error types is thiserror.

Install
cargo add thiserror
src/parser.rs
use thiserror::Error;

#[derive(Debug, Error)]
pub enum ParseError {
    #[error("empty line")]
    Empty,
    #[error("too few fields (expected 6, found {found})")]
    TooFewFields { found: usize },
    #[error("status code is not a number: {0}")]
    BadStatus(#[from] std::num::ParseIntError),
}

Enums from basics Part 5 and traits from Part 8 converge here. The kinds of error are listed as variants; #[error("...")] auto-generates the Display implementation and #[derive(Error)] the standard Error trait implementation. #[from] instructs ? to convert a ParseIntError into ParseError::BadStatus automatically. Boilerplate that runs to dozens of lines by hand ends in a few lines of declaration.

The criterion: it splits at the boundary #

The division of labor fits in one sentence: if the caller must branch on the kind, thiserror; if it’s shown to a human and that’s the end, anyhow. Applied to loglens:

  • The parser module (Part 3): Result<LogEntry, ParseError> — the caller branches (“skip if empty, count otherwise”), so thiserror
  • main and command handling (the application layer): anyhow::Result<()> — every failure ultimately becomes a message and an exit, so anyhow

The two layers connect naturally. anyhow::Error accepts any type implementing the standard Error trait, so the parser’s ParseError is absorbed into the application layer with a single ?.

Exit codes: the contract with script users #

A CLI tool’s final interface is its exit code. When main returns Err, Rust prints the error and exits with code 1 — the baseline contract for users who compose pipelines like loglens stats access.log || alert. For reference, clap exits with code 2 on invalid arguments. The distinction between “ran but failed (1)” and “wrong usage (2)” is a long-standing Unix convention, and the default behavior already honors it.

Summary #

  • CLI error messages are interface. The design goal is a message that carries the context of “what we were trying to do,” not just the cause.
  • The application layer defaults to anyhow: main() -> anyhow::Result<()>, with with_context at the ? waypoints building the error chain.
  • Layers where callers branch on kind get a concrete error enum via thiserror. #[error] generates Display; #[from] generates the ? conversion.
  • The two layers join through a single ?. The parser’s concrete errors are absorbed into the application’s anyhow.
  • Next part fills in these error types for real: reading the file with BufReader and building the parser that turns a log line into a struct.
X