Error Libraries — thiserror and anyhow
Error Handling in Practice introduced thiserror and anyhow and the basic rule of thumb: thiserror for libraries, anyhow for applications. This tutorial goes deeper into the features that make each one actually pull its weight, automatic error conversion with #[from], source chains that preserve the full causal history, anyhow's .context() and downcasting, and the boundary in a real program where the two meet. The goal is to use them well, not just know they exist.
thiserror: #[from] and Automatic ? Conversion
The most valuable thiserror feature is #[from], which generates a From impl for a wrapped error type. That From impl is exactly what the ? operator needs (from Newtype Pattern and From/Into Conversions, ? calls From::from on the error): with #[from], ? converts foreign errors into your enum automatically, no manual .map_err() at every call site.
use thiserror::Error;
#[derive(Error, Debug)]
enum ConfigError {
#[error("failed to read config file")]
Io(#[from] std::io::Error),
#[error("invalid config format")]
Parse(#[from] serde_json::Error),
#[error("missing required key: {0}")]
MissingKey(String),
}
fn load_config(path: &str) -> Result<Config, ConfigError> {
let text = std::fs::read_to_string(path)?; // io::Error -> ConfigError::Io, automatically
let config: Config = serde_json::from_str(&text)?; // serde_json::Error -> ConfigError::Parse
Ok(config)
}
Each #[from] variant gets a generated From<ThatError> for ConfigError, so ? "just works" for both std::fs and serde_json calls in the same function, each foreign error lands in the right variant with no boilerplate. The #[error("...")] attribute supplies the Display text, and {0} interpolates tuple fields (named fields interpolate by name: {path}).
Source Chains: Preserving the Full Cause
A #[from] field is automatically also the error's source, the underlying error that caused this one. This builds a chain: ConfigError::Io knows its source is the original io::Error. Preserving that chain matters because the top-level message ("failed to read config file") is useless for debugging without the underlying cause ("No such file or directory: /etc/app.toml").
You can also mark a source explicitly with #[source] when the field isn't a #[from] (e.g. you want a custom constructor rather than an auto-generated From):
#[derive(Error, Debug)]
#[error("failed to process job {job_id}")]
struct JobError {
job_id: u64,
#[source]
cause: std::io::Error,
}
Walking the chain is done through the standard Error::source method, and most error-reporting code (including anyhow's output) prints the whole chain automatically:
fn print_chain(err: &dyn std::error::Error) {
eprintln!("error: {err}");
let mut source = err.source();
while let Some(e) = source {
eprintln!(" caused by: {e}");
source = e.source();
}
}
The takeaway: prefer #[from]/#[source] over flattening an underlying error into a String. A String discards the chain, the structured source keeps it, which is the difference between an actionable error report and a dead end. #[error(transparent)] is the related tool for a variant that should forward its Display and source straight through to a wrapped error without adding its own message, useful for a catch-all variant.
anyhow: Context Over Type Precision
anyhow::Error is a single type that can hold any error implementing std::error::Error, trading the matchable precision of a thiserror enum for the freedom to propagate anything with ? and no enum to maintain. Its signature feature is .context(), which attaches a human-readable explanation as a new layer in the source chain:
use anyhow::{Context, Result};
fn load_user_settings(user_id: u64) -> Result<Settings> {
let path = format!("/data/users/{user_id}/settings.json");
let text = std::fs::read_to_string(&path)
.with_context(|| format!("reading settings for user {user_id} from {path}"))?;
let settings: Settings = serde_json::from_str(&text)
.context("parsing settings JSON")?;
Ok(settings)
}
If the file is missing, the resulting error reads as a chain: reading settings for user 42 from /data/... → caused by → No such file or directory. Each .context() adds a frame explaining what the program was trying to do, which is exactly the information a raw io::Error lacks. Use .context(...) for a fixed string and .with_context(|| ...) for a message that needs formatting, the closure form defers the (possibly costly) string construction so it only runs on the error path, not on every successful call.
Downcasting: Recovering a Typed Error from anyhow
The apparent downside of anyhow erasing the concrete type is that you can't match on it, but you can recover the original type when you need to, via downcast_ref. This is how an application using anyhow everywhere can still react specifically to one particular error:
use anyhow::Result;
fn run() -> Result<()> {
if let Err(e) = load_config("app.toml") {
// was the underlying cause specifically a "file not found"?
if let Some(io_err) = e.downcast_ref::<std::io::Error>() {
if io_err.kind() == std::io::ErrorKind::NotFound {
eprintln!("no config found, using defaults");
return Ok(());
}
}
return Err(e); // anything else: propagate
}
Ok(())
}
downcast_ref::<T>() returns Some(&T) if the erased error is (or was caused by) a T, and None otherwise. This is the escape hatch that makes "use anyhow by default" practical: you keep the ergonomic propagation everywhere, and downcast only at the rare spot that needs to branch on a specific error.
Where the Two Meet in a Real Program
A typical layered application uses both, and the boundary between them is the point of the whole library/application split:
- Library / domain modules define
thiserrorenums, so callers (including your own higher layers) canmatchon specific failure modes and the public API documents exactly what can go wrong. - The application / binary layer (
main, request handlers, command implementations) usesanyhow, adding.context()as errors bubble up, because at that level you mostly want to report failures richly, not branch on every variant.
Because a thiserror enum implements std::error::Error, it converts into anyhow::Error automatically, the layers compose with no glue:
// library layer returns a typed error
fn load_config(path: &str) -> Result<Config, ConfigError> { /* ... */ }
// application layer absorbs it into anyhow, adding context
fn main() -> anyhow::Result<()> {
let config = load_config("app.toml")
.context("starting up")?; // ConfigError -> anyhow::Error, plus a context frame
Ok(())
}
A main returning anyhow::Result<()> prints the full context chain on exit, which is why this combination is the de facto standard. The rule, sharpened from the earlier tutorial: expose thiserror at API boundaries you don't control the callers of; use anyhow at the top where you control the program and just need failures to surface with context.
Key Takeaways
thiserror's#[from]generates theFromimpl that?uses, so foreign errors convert into the right enum variant automatically, no per-call.map_err().- Preserve source chains with
#[from]/#[source]instead of flattening causes into aString; the chain (printed viaError::source) is what turns a vague top-level message into an actionable report. Use#[error(transparent)]to forward a wrapped error'sDisplay/sourceunchanged. anyhowholds anystd::error::Errorin one type and adds.context()/.with_context()frames explaining what the program was doing; prefer the closure form when the message needs formatting.downcast_ref::<T>()recovers a concrete error type from ananyhow::Error, the escape hatch that lets you propagate withanyhoweverywhere but still branch on a specific error where needed.- Use both:
thiserrorenums in libraries/domain code (matchable, documented),anyhowin the application layer (context-rich propagation). Athiserrorenum converts intoanyhow::Errorfor free, so the layers compose cleanly.