Skip to content
← All articles

What thiserror and anyhow actually generate

A line-by-line translation from the two crates you will meet on day one of any Rust job back to the hand-written code you already wrote in this track.

Nothing in this article can be compiled here. thiserror and anyhow are external crates, and thiserror is a procedural macro, which this course’s single-file no-crates harness cannot build. Everything below is read-only illustration.

That is fine, because the point is not to use them — it is to read them. Having written Display, Error, source(), From and a context extension trait by hand over the last few items, you can now look at these macros and see exactly which of those they are writing for you. Which is a much better position than the usual one, where a beginner copies #[error("...")] off Stack Overflow and never finds out what it did.

thiserror, translated

Here is a thiserror enum you will meet in real code:

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),
}

And here is what each attribute generates, in the vocabulary you already have.

attribute generates
#[derive(Error)] impl std::error::Error for ConfigError
#[error("...")] the matching arm of impl Display
#[from] impl From<ThatType> for ConfigError, and makes the field the source
#[source] the matching arm of fn source()
a field literally named source the same, no attribute needed
#[error(transparent)] forwards both Display and source() to the inner error

Expand the first two variants by hand and you get code you have already written:

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"),
            // ...
        }
    }
}

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),
            // ...
        }
    }
}

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

There is no third thing. thiserror is a typing-saving device with zero runtime component — it does not exist at run time, it adds no dependency to your callers’ binaries, and its output is code you could have written. That is also why it is safe to use in libraries: nothing about it leaks into your public API except the impls, which you wanted anyway.

💡#[error("key {0} is missing")] interpolates a field into the message. Given what you know about Display, what is {0} actually doing, and what would {} alone mean? click to reveal

{0} is the first field of this variant, and it is passed to the generated write! exactly the way a positional argument is. For a struct-like variant you use the field names — {key} — and thiserror wires them up the same way.

{} on its own is a different thing entirely: with no index and no name it refers to self, so #[error("{}", .0)] and #[error(transparent)] are related idioms for forwarding.

The part worth internalising is that this is not a special templating language. It is a write! format string with the fields already in scope, so everything you know about Display formatting applies — {key:?} for Debug, width and precision specifiers, all of it.

And the convention from item 6.14 still applies and is not enforced by the macro: lowercase, no trailing punctuation, describes this error only and never the chain. thiserror will happily generate #[error("Failed to load config: {0}")], which double-prints the cause for anyone walking source().

anyhow, translated

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

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 is morally Box<dyn Error + Send + Sync + 'static>, plus a backtrace and a stack of context messages. (The real implementation is a single-word thin pointer to a heap block holding a vtable and the payload — narrower than a boxed trait object, which is two words. That is an optimisation, not a different idea.)
  • anyhow::Result<T> is a type alias for std::result::Result<T, anyhow::Error>. Nothing more.
  • .context(msg) / .with_context(|| msg) is the extension trait you built in item 6.19: a trait with one method, blanket-implemented for Result<T, E>, that wraps the error with a message and keeps the original as the source. with_context is the lazy form, for when building the message allocates — the same eager/lazy distinction as unwrap_or vs unwrap_or_else.
  • anyhow!("…") constructs an error from a format string; bail!("…") is return Err(anyhow!("…")).
  • {:#} formats the whole chain on one line — the render function you wrote by hand, joining with ": ". {:?} gives the multi-line form with the backtrace.

So the value anyhow adds over Box<dyn Error + Send + Sync> is: the context stack, the backtrace, the thin pointer, and downcast that still works through all of it. Those are real conveniences. None of them is a mechanism you have not now seen.

💡A widely repeated rule is "thiserror for libraries, anyhow for binaries". Where does that rule break down? click to reveal

It breaks down because the real question is not what kind of crate you are writing — it is whether your callers need to match on the error.

Cases where the slogan misleads:

  • A binary with a real internal API. If one module’s failures drive another module’s behaviour — retry this, fall back on that — then that boundary wants an enum, even though the crate is a binary.
  • A library whose errors are always fatal to the caller. A code-generation tool, a build script helper, a linter: if every failure ends with the caller printing a message and stopping, an enum buys them nothing and costs you boilerplate. anyhow in a library is not automatically wrong.
  • Both at once, which is the common professional shape. thiserror enums at the boundaries where reactions happen; anyhow in the glue and in main, since every thiserror error converts into an anyhow::Error for free.

The one thing that is firm: if you use anyhow in a library’s public API, your callers cannot match, cannot react, and will end up matching on message text. So the honest version of the rule is: use anyhow anywhere the error is about to be shown to a human; use an enum anywhere it is about to be read by code.

The neighbours

You will run into these too, and being unsurprised is most of the benefit:

  • snafu — like thiserror, but context-first: it generates context selectors so that adding context is checked by the type system rather than by a string.
  • eyre and color-eyre — an anyhow fork with pluggable report handlers. color-eyre gives you coloured, sectioned terminal output with suggestions. Same shape, better presentation.
  • miette — diagnostics with source spans, labels and help text, in the style of rustc’s own errors. Worth it for anything that parses a language.
  • thiserror 2.x — a 2024 rewrite; the attributes above are unchanged.
💡You have written a thiserror enum with a #[from] on two different variants wrapping two different io::Error-producing operations. It does not compile. Why? click to reveal

Because #[from] generates impl From<std::io::Error> for YourError for each variant, and two impls of the same trait for the same pair of types is E0119 — conflicting implementations. It is the same rule that stopped you writing a blanket impl<E: Error> From<E> back in item 6.15.

The fix tells you something about API design. If two different operations both fail with io::Error and you want to distinguish them, the distinction is context, not type — and context cannot be recovered by a From conversion, which sees only the error value. So you attach it at the call site:

#[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 },
}

No #[from], so no conflicting impls, and now ? alone will not build these — you use map_err or a context helper, which is correct, because only the call site knows which file it was.

This is the From-vs-map_err rule from item 6.15, restated: From when the conversion is context-free, map_err when it is not. The macro cannot change that, and the compiler will not let it pretend otherwise.

What to take away

The two crates are not abstractions over error handling — they are code generators for the exact pattern you spent this track writing by hand:

  • thiserror writes your Display, Error, source() and From impls.
  • anyhow gives you a boxed error with a context stack and a backtrace, plus the extension trait that pushes onto it.

Neither introduces a mechanism. If you ever hit an error message from one of them that makes no sense, expand the macro in your head into the impls above and the message will be about those impls, not about magic.