Rust in Practice #2 Error Design: anyhow and thiserror, Engineering How It Dies
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.
cargo add anyhowuse 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.
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:
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.
cargo add thiserroruse 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<()>, withwith_contextat 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.