Coding by Hand
Rust home

Custom Errors with thiserror

A package travels through three hands before it reaches your door. A truck driver picks it up, a sorter in the warehouse routes it, a courier puts it on the porch. When something goes wrong, you do not want a sticky note that just says Error. You want the right hand to stamp the package with what they were trying to do and what stopped them, then hand the package back up the chain so the next person sees the whole story. Custom errors in Rust are those stamps. Each layer of your program prints its own kind, and the ? operator is the conveyor belt that carries a stamped package back up to whoever asked for it.

Three couriers pass the package up the chain, each stamping a labeled tag when they cannot continue.
Three couriers pass the package up the chain, each stamping a labeled tag when they cannot continue.

Rust shipped 1.0 in 2015 with Result<T, E> baked into the standard library, but no opinion on what E should be. People hand-wrote enums for every project, then hand-wrote a Display impl so the error printed something readable, then hand-wrote From impls so ? could convert one layer's error into the next. A maintainer at the Linux Foundation named David Tolnay watched everyone type the same boilerplate over and over and shipped a crate called thiserror in 2019. The crate is a procedural macro — it reads your enum at compile time, looks at little #[error("…")] tags you put on each variant, and writes the Display and From impls for you. Zero runtime cost. The compiled binary looks exactly like the hand-written version. Tolnay later wrote anyhow for application code that just wants one error type — thiserror is what you reach for in a library where the caller needs to tell the variants apart.

Here is the package label printer. Three small enums, one for each station, plus a top-level enum that wraps all three so the caller of load_port gets one type back no matter where the failure happened. The #[from] tag on a variant tells thiserror to write a From impl, which is the line that lets ? convert a ReadError into a ConfigError without you typing the conversion by hand.

#[derive(Debug, Error)]
enum ReadError {
    #[error("config file is empty")]
    Empty,
}

#[derive(Debug, Error)]
enum ParseIntError {
    #[error("expected a number, got `{0}`")]
    NotANumber(String),
}

#[derive(Debug, Error)]
enum PortError {
    #[error("port {0} is outside 1..=65535")]
    OutOfRange(i64),
}

#[derive(Debug, Error)]
enum ConfigError {
    #[error("could not read config: {0}")]
    Read(#[from] ReadError),
    #[error("could not parse port: {0}")]
    Parse(#[from] ParseIntError),
    #[error("invalid port: {0}")]
    Port(#[from] PortError),
}

Read the tags out loud. #[error("config file is empty")] becomes the string the user sees when they print the error. #[error("port {0} is outside 1..=65535")] pulls the first tuple field of the variant into the message. #[error("could not read config: {0}")] prints the wrapped inner error after a colon, so you end up with a sentence that walks the chain — outer cause first, then the underlying one. That layered sentence is the whole point. When a user sees could not read config: config file is empty, they know which layer failed and why, in one line.

The three layers are tiny on purpose. Each one returns its own narrow error type, the kind of thing that station could possibly produce and nothing else.

fn read_line(raw: &str) -> Result<&str, ReadError> { // allow:stdin name evokes the stage; this wraps &str, no real I/O
    if raw.is_empty() {
        return Err(ReadError::Empty);
    }
    Ok(raw)
}

fn parse_int(text: &str) -> Result<i64, ParseIntError> {
    text.parse::<i64>()
        .map_err(|_| ParseIntError::NotANumber(text.to_string()))
}

fn check_port(n: i64) -> Result<u16, PortError> {
    if (1..=65535).contains(&n) {
        Ok(n as u16)
    } else {
        Err(PortError::OutOfRange(n))
    }
}

fn load_port(raw: &str) -> Result<u16, ConfigError> {
    let line = read_line(raw)?; // allow:stdin same wrapper as above
    let n = parse_int(line)?;
    let port = check_port(n)?;
    Ok(port)
}

Look at load_port. Three function calls, three different return types — Result<&str, ReadError>, then Result<i64, ParseIntError>, then Result<u16, PortError> — all funneled into one Result<u16, ConfigError>. The ? after each call does two jobs at once. If the call returned Ok, it unwraps the inside value and keeps going. If the call returned Err, it converts the inner error type into ConfigError using the #[from] impl that thiserror generated, then returns it. Without ?, you would type the same three match blocks any Rust programmer has typed a thousand times. With ?, the conveyor belt does it.

The driver in main feeds four inputs at the loading dock and prints what came back.

fn main() {
    let inputs = ["8080", "", "abc", "70000"];
    for raw in inputs {
        match load_port(raw) {
            Ok(port) => println!("ok({raw:?}) -> port {port}"),
            Err(e) => println!("err({raw:?}) -> {e}"),
        }
    }
}

Run it and you see the package labels come out the other end.

ok("8080") -> port 8080
err("") -> could not read config: config file is empty
err("abc") -> could not parse port: expected a number, got `abc`
err("70000") -> invalid port: port 70000 is outside 1..=65535

One question worth asking — why have a separate ParseIntError when the standard library already ships std::num::ParseIntError? You can wrap the std one with #[from] if you want; people often do. The reason a library would define its own is control over the message. The std error says "invalid digit found in string," which is fine for a single layer, but in a chain it reads worse than expected a number, got "abc". When the message is the product, you control the message.

Next lesson — handling the boundary where Rust talks to the actual disk, where read_line stops being a fake function over a string and starts being a real BufReader over a file that may not exist.