Skip to content
← All Rust articles

Rust explainer

What thiserror and anyhow actually generate

About 12 min to read. Allow extra time to work through examples.

Maps the attributes of thiserror and the main pieces of anyhow to the standard-library Display, Error, source() and From code they generate or wrap, and explains when each crate fits.

This article uses Rust. You can make it your practice mode to browse related content across the site.

The code in this article cannot be run on this site. thiserror and anyhow are external crates (packages published on crates.io), and thiserror also contains a procedural macro, which is a program the compiler runs to generate code. The course harness builds one source file with no external crates, so the examples here are for reading. They were checked with thiserror 2.0 and anyhow 1.0 in an ordinary Cargo project.

Both crates package code that you can write with the standard library alone: an impl Display that produces the message, an impl std::error::Error whose source() method returns the underlying cause, From impls that let ? convert one error type into another, and a trait that attaches context to an error. The exercises implementing the Error trait, error chains and source(), From conversions and adding error context build each of those by hand. This article shows which of them each crate provides, so that an attribute such as #[error("...")] reads as ordinary code rather than as something opaque.

thiserror, translated

thiserror provides a derive macro, #[derive(Error)], for your own error types. Here is an enum written with it:

use thiserror::Error;

#[derive(Debug, Error)]
pub enum ConfigError {
    #[error("key `{0}` is missing")]
    MissingKey(String),

    #[error("could not read the config file")]
    Io(#[from] std::io::Error),

    #[error("key `{key}` is not an integer")]
    BadInt {
        key: String,
        #[source]
        source: std::num::ParseIntError,
    },

    #[error(transparent)]
    Other(#[from] anyhow::Error),
}

Each attribute corresponds to part of an impl you could write yourself:

attribute what it generates
#[derive(Error)] impl std::error::Error for ConfigError, including its source() method
#[error("...")] on a variant that variant's arm of impl Display
#[from] on a field impl From<FieldType> for ConfigError; the field also becomes that variant's source()
#[source] on a field that variant's arm of source() returns the field
a field named source the same as #[source], without the attribute
#[error(transparent)] Display and source() both forward to the single inner field

#[derive(Debug)] is still required, because the Error trait has Debug as a supertrait. A variant with #[from] may not have other fields, because From::from receives only the inner error and would have nothing to fill them with. (The one exception is a backtrace field, and thiserror's backtrace support currently needs a nightly compiler.)

Written out by hand, the four variants look like this. The code the macro emits is not character-for-character identical (for example, it calls a few small helper traits from the thiserror crate), but it behaves the same way:

impl std::fmt::Display for ConfigError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            ConfigError::MissingKey(k) => write!(f, "key `{k}` is missing"),
            ConfigError::Io(_) => f.write_str("could not read the config file"),
            ConfigError::BadInt { key, .. } => write!(f, "key `{key}` is not an integer"),
            // transparent: forward to the inner error's Display.
            ConfigError::Other(e) => std::fmt::Display::fmt(e, f),
        }
    }
}

impl std::error::Error for ConfigError {
    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
        match self {
            ConfigError::MissingKey(_) => None,
            ConfigError::Io(e) => Some(e),
            ConfigError::BadInt { source, .. } => Some(source),
            // transparent: forward to the inner error's own source().
            ConfigError::Other(e) => e.source(),
        }
    }
}

impl From<std::io::Error> for ConfigError {
    fn from(e: std::io::Error) -> Self {
        ConfigError::Io(e)
    }
}

impl From<anyhow::Error> for ConfigError {
    fn from(e: anyhow::Error) -> Self {
        ConfigError::Other(e)
    }
}

Note that the Io message does not include the io::Error text. The cause is reachable through source() instead, and code that prints a whole chain (as shown later for anyhow) prints it there.

thiserror has no runtime machinery of its own: it adds no allocation, global state or background work, and the derived impls are ordinary trait impls. The crate is still a normal dependency that gets compiled, because the generated code refers to those helper traits. Its documentation states that it deliberately does not appear in your public API: callers see the same Display, Error and From impls they would see if you had written them by hand, and switching between hand-written impls and thiserror is not a breaking change. That is why it is commonly used in libraries.

Check your understanding The message key {0} is missing contains {0}. What does {0} refer to there, and what would a bare {} refer to? Show answer

In a thiserror message, {0} is shorthand for the variant's first tuple field, self.0. So #[error("key `{0}` is missing")] becomes write!(f, "key `{}` is missing", self.0). In a struct-like variant you write the field name instead, as in {key}, which becomes self.key. This is a rewrite done by thiserror; in a plain write! call, {0} would mean "the first extra argument after the format string", not a field.

A bare {} is not given any special meaning. It is an ordinary positional placeholder, so it needs an extra argument after the message. #[error("{}", .0)] supplies one: .0 is thiserror's shorthand for self.0 inside those extra arguments (named fields use .key). Without an argument, #[error("{}")] fails to compile, exactly as format!("{}") does.

#[error("{}", .0)] is not the same as #[error(transparent)]. Both print the inner error's message, but only transparent also forwards source(). With "{}", .0 on a field that is not marked #[source] or #[from], source() returns None.

Apart from that field shorthand, the message is a standard format string, so the usual format specs work: {key:?} uses Debug, and width and precision specs such as {0:>8} behave as they do in format!.

The macro does not enforce message style. The standard library's Error documentation recommends concise, lowercase messages without trailing punctuation. A common further convention is that each error either prints its cause in its own message or returns it from source(), but not both. #[error("failed to load config: {0}")] on a #[from] field breaks that convention: the field is the source, so any code that prints the whole chain prints the cause twice.

anyhow, translated

anyhow provides one error type for code that mostly needs to report errors rather than match on them:

use anyhow::{Context, Result, bail};

struct Config {
    port: u16,
}

fn load(path: &str) -> Result<Config> {
    let text = std::fs::read_to_string(path)
        .with_context(|| format!("reading config from {path}"))?;

    if text.is_empty() {
        bail!("config file {path} is empty");
    }

    let port: u16 = text.trim().parse().context("parsing port")?;
    Ok(Config { port })
}

Piece by piece:

  • anyhow::Error plays the same role as Box<dyn std::error::Error + Send + Sync + 'static>: it can hold any error type that is Send, Sync and 'static, and ? converts such errors into it. It also records context messages and can capture a backtrace. Its representation differs from a Box<dyn Error>: an anyhow::Error is one pointer wide, while a boxed trait object is two (a data pointer and a vtable pointer). An anyhow::Error always allocates; a Box allocates for any error type that is not zero-sized.
  • anyhow::Error does not implement std::error::Error itself. It dereferences to dyn Error + Send + Sync, and the #[error(transparent)] variant above is a pattern thiserror documents for holding one.
  • anyhow::Result<T> is a type alias: type Result<T, E = anyhow::Error> = std::result::Result<T, E>. The error type defaults to anyhow::Error but can be overridden.
  • .context(msg) and .with_context(|| msg) come from the Context extension trait, the same shape as the trait built in the adding-context exercise. anyhow implements it for Result<T, E> (where E is a Send + Sync + 'static error, or an anyhow::Error) and for Option<T>. On an error it returns a new anyhow::Error whose message is msg and whose source() chain leads to the original error. with_context takes a closure that runs only when there is an error, which avoids building the message (for example, with format!) on success. This is the same eager-versus-lazy choice as unwrap_or versus unwrap_or_else.
  • anyhow!("...") builds an anyhow::Error from a format string (or from an existing error value), and bail!("...") is equivalent to return Err(anyhow!("...")).
  • Formatting. {} prints only the outermost message, such as parsing port. {:#} prints the whole chain on one line joined with ": ", such as parsing port: invalid digit found in string. {:?} prints the outermost message, then a Caused by: list of the chain, then the backtrace if one was captured. Backtraces are captured only when the environment variable RUST_LIB_BACKTRACE=1 or RUST_BACKTRACE=1 is set (and RUST_LIB_BACKTRACE is not 0).
  • Downcasting. error.downcast_ref::<std::num::ParseIntError>() still finds the original error after context has been added, and the context value itself can be downcast as well.

Compared with Box<dyn Error + Send + Sync>, then, anyhow adds context wrapping, optional backtrace capture, a one-pointer representation, chain-aware formatting and downcasting through context layers. Each piece is a convenience over a pattern you can write by hand, not a different error-handling model.

Check your understanding A widely repeated rule is "thiserror for libraries, anyhow for applications". Where does that rule fall short? Show answer

The rule names the kind of crate, but the more useful question is whether the code that receives the error needs to react differently to different failures. An enum lets that code match on variants. An anyhow::Error offers only downcasting and message text.

Cases where the slogan gives the wrong answer:

  • An application with an internal API. If one module's failures drive another module's behavior (retry on one error, fall back on another), that boundary benefits from an enum even though the crate is an application.
  • A library whose errors always end the caller's task. For some tools, such as a code generator called from a build script, every failure is reported and the caller stops. An enum then gives the caller little to work with, and an opaque error is a reasonable choice.
  • Both in one program. A common arrangement is thiserror enums at the boundaries where callers react, and anyhow in the code that only passes errors up, including main. A thiserror type converts into anyhow::Error with ? as long as it is Send, Sync and 'static; the conversion moves it into a heap allocation.

One cost is consistent: if a library's public functions return anyhow::Error, callers cannot match exhaustively on its failures. They must downcast to concrete types they guess at, or inspect message text, and both break silently when the library changes. A practical summary is: an opaque error fits where the error will mostly be shown to a person, and an enum fits where code needs to decide what to do next.

Related crates

Some other error-handling crates follow the same patterns:

  • snafu generates Display and Error impls like thiserror, and also generates context selectors: small structs used to attach context, so the context fields a variant needs are checked by the compiler.
  • eyre is a fork of anyhow whose report format can be customized by installing a handler. color-eyre is such a handler; it prints colored, sectioned reports and supports attaching notes and suggestions.
  • miette produces diagnostic reports with source-code snippets, labels and help text, similar in style to rustc's own errors. It is designed for tools that report problems in input text, such as parsers and linters.

The examples in this article were checked against thiserror 2.0 and anyhow 1.0. Major versions can change details, so check the documentation for the version you depend on.

Check your understanding An enum has two variants, each with a #[from] field of type std::io::Error, one for reading a file and one for writing it. It does not compile. Why? Show answer

Each #[from] generates impl From<std::io::Error> for ConfigError. Two impls of the same trait for the same types are a conflict, and the compiler reports E0119 ("conflicting implementations of trait"). thiserror does not check for this; the error comes from the compiler after expansion. The same error appears if you try to write a blanket impl<E: Error> From<E> for ConfigError, because the standard library already provides impl<T> From<T> for T, which covers E = ConfigError.

The fix shows something about API design. Both operations fail with the same type, so the type alone cannot say which operation failed. That information is context, and a From conversion cannot supply it, because from receives only the error value. So the caller attaches it:

use thiserror::Error;

#[derive(Debug, Error)]
pub enum ConfigError {
    #[error("could not read {path}")]
    Read { path: String, #[source] source: std::io::Error },

    #[error("could not write {path}")]
    Write { path: String, #[source] source: std::io::Error },
}

fn read_config(path: &str) -> Result<String, ConfigError> {
    std::fs::read_to_string(path).map_err(|source| ConfigError::Read {
        path: path.to_string(),
        source,
    })
}

(The #[source] attributes are optional here, because the fields are named source; they are written out for clarity.) There is no #[from], so there are no conflicting impls, and ? alone no longer converts an io::Error into ConfigError. Instead, the call site uses map_err (or a context helper) and supplies the path and the operation, which only the call site knows.

A useful rule: implement From when the conversion needs no information beyond the error itself, and use map_err at the call site when it does.

Summary

Both crates generate or wrap the same standard-library pieces that can be written by hand:

  • thiserror derives Display, std::error::Error (including source()) and From impls for your own error types, and leaves no trace in your public API.
  • anyhow provides a single heap-allocated error type that holds any Send + Sync + 'static error, plus a Context trait for adding messages, chain-aware formatting, downcasting and optional backtraces.

When a compiler error mentions one of these crates, it is usually about one of these impls (a missing Debug, a conflicting From, a field type that does not implement Error, or an error that is not Send), so writing the impl out by hand is a reliable way to understand it.

step 20 of 24

Errors Are Values

Up next #[must_use] in depth