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::Erroris morallyBox<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 forstd::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 forResult<T, E>, that wraps the error with a message and keeps the original as thesource.with_contextis the lazy form, for when building the message allocates — the same eager/lazy distinction asunwrap_orvsunwrap_or_else. -
anyhow!("…")constructs an error from a format string;bail!("…")isreturn Err(anyhow!("…")). -
{:#}formats the whole chain on one line — therenderfunction 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.
anyhowin a library is not automatically wrong. -
Both at once, which is the common professional shape.
thiserrorenums at the boundaries where reactions happen;anyhowin the glue and inmain, since everythiserrorerror converts into ananyhow::Errorfor 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. -
eyreandcolor-eyre— an anyhow fork with pluggable report handlers.color-eyregives 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. -
thiserror2.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:
-
thiserrorwrites yourDisplay,Error,source()andFromimpls. -
anyhowgives 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.