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
Hide 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::Errorplays the same role asBox<dyn std::error::Error + Send + Sync + 'static>: it can hold any error type that isSend,Syncand'static, and?converts such errors into it. It also records context messages and can capture a backtrace. Its representation differs from aBox<dyn Error>: ananyhow::Erroris one pointer wide, while a boxed trait object is two (a data pointer and a vtable pointer). Ananyhow::Erroralways allocates; aBoxallocates for any error type that is not zero-sized.anyhow::Errordoes not implementstd::error::Erroritself. It dereferences todyn Error + Send + Sync, and the#[error(transparent)]variant above is a patternthiserrordocuments 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 toanyhow::Errorbut can be overridden..context(msg)and.with_context(|| msg)come from theContextextension trait, the same shape as the trait built in the adding-context exercise.anyhowimplements it forResult<T, E>(whereEis aSend + Sync + 'staticerror, or ananyhow::Error) and forOption<T>. On an error it returns a newanyhow::Errorwhose message ismsgand whosesource()chain leads to the original error.with_contexttakes a closure that runs only when there is an error, which avoids building the message (for example, withformat!) on success. This is the same eager-versus-lazy choice asunwrap_orversusunwrap_or_else.anyhow!("...")builds ananyhow::Errorfrom a format string (or from an existing error value), andbail!("...")is equivalent toreturn Err(anyhow!("...")).- Formatting.
{}prints only the outermost message, such asparsing port.{:#}prints the whole chain on one line joined with": ", such asparsing port: invalid digit found in string.{:?}prints the outermost message, then aCaused by:list of the chain, then the backtrace if one was captured. Backtraces are captured only when the environment variableRUST_LIB_BACKTRACE=1orRUST_BACKTRACE=1is set (andRUST_LIB_BACKTRACEis not0). - 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 Hide 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
thiserrorenums at the boundaries where callers react, andanyhowin the code that only passes errors up, includingmain. Athiserrortype converts intoanyhow::Errorwith?as long as it isSend,Syncand'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:
snafugeneratesDisplayandErrorimpls likethiserror, and also generates context selectors: small structs used to attach context, so the context fields a variant needs are checked by the compiler.eyreis a fork ofanyhowwhose report format can be customized by installing a handler.color-eyreis such a handler; it prints colored, sectioned reports and supports attaching notes and suggestions.mietteproduces diagnostic reports with source-code snippets, labels and help text, similar in style torustc'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
Hide 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:
thiserrorderivesDisplay,std::error::Error(includingsource()) andFromimpls for your own error types, and leaves no trace in your public API.anyhowprovides a single heap-allocated error type that holds anySend + Sync + 'staticerror, plus aContexttrait 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.