Error Handling with thiserror & anyhow
Reviewed & published by Brayan K
Rust's Result and ? operator are the foundation, but two crates make real-world error handling effortless: thiserror for precise library errors, and anyhow for ergonomic application-level reporting.
Part of the free Rust course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.
In this lesson you'll derive typed error enums with thiserror, propagate and enrich errors with anyhow's context and macros, and learn exactly when to reach for each.
What You'll Learn in This Lesson
- Custom error enums with #[derive(Error)]
- #[error("...")] Display messages
- #[from] for automatic ? conversion
- anyhow::Result and the ? operator
- .context, bail!, and ensure!
- When to use each vs Box<dyn Error>
1️⃣ Typed Library Errors with thiserror
A library should expose a precise error type its callers can match on. #[derive(Error)] implements std::error::Error, #[error("...")] supplies the Display message, and #[from] lets ? auto-convert a source error.
// thiserror: define a precise error TYPE for a library.
// Callers can match on each variant.
use thiserror::Error;
#[derive(Error, Debug)]
pub enum ConfigError {
// #[error("...")] is the Display message; {0} interpolates the field.
#[error("config file not found: {0}")]
NotFound(String),
// #[from] generates From<std::num::ParseIntError>, so ? auto-converts.
#[error("invalid number in config")]
BadNumber(#[from] std::num::ParseIntError),
#[error("missing required key: {key}")]
MissingKey { key: String },
}
fn parse_port(raw: &str) -> Result<u16, ConfigError> {
// The ? converts ParseIntError into ConfigError::BadNumber via #[from].
let port: u16 = raw.parse()?;
Ok(port)
}
fn main() {
match parse_port("oops") {
Ok(p) => println!("port = {p}"),
Err(e) => println!("error: {e}"),
}
}Because BadNumber carries #[from], the ? in parse_port turned a ParseIntError into our own typed variant with no manual conversion.
2️⃣ Application Errors with anyhow
In application code you usually just want to propagate and report. anyhow::Result<T> is Result<T, anyhow::Error>, so ? accepts any error, and .context attaches a helpful note as it bubbles up.
// anyhow: one easy error type for APPLICATIONS.
// Great when you just want to propagate and report, not match.
use anyhow::{Context, Result};
fn load_config(path: &str) -> Result<String> {
// ? works with ANY error type here; .context adds a helpful message.
let text = std::fs::read_to_string(path)
.with_context(|| format!("failed to read config at {path}"))?;
Ok(text)
}
fn main() -> Result<()> {
let config = load_config("/no/such/file.toml")?;
println!("loaded {} bytes", config.len());
Ok(())
}Returning anyhow::Result<()> from main printed the full cause chain — your context plus the underlying OS error.
3️⃣ Early Exits with bail! and ensure!
anyhow's macros make guard clauses concise. bail!("msg") returns an error immediately, and ensure!(cond, "msg") bails only when the condition is false — like assert!, but returning instead of panicking.
// bail! returns early with an error; ensure! checks a condition.
use anyhow::{bail, ensure, Result};
fn withdraw(balance: i64, amount: i64) -> Result<i64> {
// ensure!(cond, msg): if cond is false, return Err with the message.
ensure!(amount > 0, "amount must be positive, got {amount}");
if amount > balance {
// bail! is shorthand for: return Err(anyhow!(...));
bail!("insufficient funds: have {balance}, need {amount}");
}
Ok(balance - amount)
}
fn main() -> Result<()> {
println!("new balance = {}", withdraw(100, 30)?);
println!("new balance = {}", withdraw(100, 250)?); // triggers bail!
Ok(())
}These keep validation readable: the happy path stays at the top level while invalid inputs exit early with a clear message.
Mini-Challenge: A Typed MathError
Define a small thiserror enum and return it from a fallible function.
// 🎯 MINI-CHALLENGE: a typed library error with thiserror.
//
// 1. Define #[derive(Error, Debug)] enum MathError with one variant
// DivideByZero, giving it #[error("cannot divide by zero")].
// 2. Write fn divide(a: i32, b: i32) -> Result<i32, MathError> that
// returns Err(MathError::DivideByZero) when b == 0, else Ok(a / b).
// 3. In main, call divide(10, 0) and print the error.
//
// Expected output: error: cannot divide by zero
// your code hereCommon Errors (and the fix)
- "the trait Error is not implemented" — Add #[derive(Error, Debug)] from thiserror.
- "? cannot convert the error" — Add #[from] to the source field, or switch to anyhow.
- Missing Display message — Every variant needs an #[error("...")] attribute.
- Exposing anyhow from a library — Prefer a typed thiserror error so callers can match on it.
Pro Tips
- 💡 thiserror down, anyhow up: libraries return typed errors, the binary collects them with anyhow.
- 💡 Add context at boundaries: a .context at each layer turns a cryptic error into a story.
- 💡 Return Result from main: fn main() -> anyhow::Result<()> prints the full cause chain for free.
📋 Quick Reference — Errors
| Goal | Rust Syntax |
|---|---|
| Typed error enum | #[derive(Error, Debug)] enum E {} |
| Display message | #[error("not found: {0}")] |
| Auto convert with ? | V(#[from] OtherError) |
| App result | fn f() -> anyhow::Result<T> |
| Add context | .context("while loading")? |
| Early error | bail!("bad input") |
🎉 Lesson Complete!
- ✅ thiserror derives typed, matchable error enums for libraries
- ✅ #[error("...")] and #[from] remove error boilerplate
- ✅ anyhow gives applications easy propagation with .context, bail!, and ensure!
- ✅ Use thiserror down in libraries, anyhow up in applications
Practice quiz
Which crate is designed for defining error types in libraries?
- thiserror
- tokio
- clap
- anyhow
Answer: thiserror. thiserror helps libraries declare precise, typed error enums that callers can match on.
Which crate is aimed at applications that just need easy error propagation?
- serde
- rayon
- anyhow
- thiserror
Answer: anyhow. anyhow gives applications a single, ergonomic error type for reporting, not for callers to match on.
What does thiserror's #[derive(Error)] do?
- Logs errors automatically
- Generates a std::error::Error implementation for your type
- Catches panics
- Spawns a retry task
Answer: Generates a std::error::Error implementation for your type. #[derive(Error)] implements std::error::Error (and helpers) for your custom error enum.
What does the #[error("...")] attribute provide?
- A backtrace flag
- A retry count
- A log level
- The Display message for that variant
Answer: The Display message for that variant. #[error("...")] supplies the Display text for an error variant, with field interpolation like {0} or {path}.
What does #[from] on an error variant's field generate?
- A From conversion so ? can auto-convert that source error
- A Default impl
- A Clone impl
- A Hash impl
Answer: A From conversion so ? can auto-convert that source error. #[from] derives From for the wrapped error so the ? operator converts it into your error type automatically.
What is the type alias anyhow::Result<T> equal to?
- Result<T, Box<str>>
- Result<T, anyhow::Error>
- Option<T>
- Result<T, String>
Answer: Result<T, anyhow::Error>. anyhow::Result<T> is shorthand for Result<T, anyhow::Error>, a dynamic error that wraps almost anything.
What does the .context("...") method from anyhow add?
- A timeout
- A mutex guard
- A new thread
- Extra descriptive context to an error as it propagates
Answer: Extra descriptive context to an error as it propagates. .context(...) attaches a human-readable message, building a helpful chain as the error bubbles up.
What does anyhow's bail!("msg") macro do?
- Retries the operation
- Logs and continues
- Returns early with an error built from the message
- Panics the thread
Answer: Returns early with an error built from the message. bail! is shorthand for returning Err(anyhow!(...)) early from the current function.
Compared to Box<dyn Error>, what does anyhow::Error add?
- Compile-time variant checking
- Captured backtraces and easy .context, plus single-word ergonomics
- Zero-cost typed matching
- Automatic logging to a file
Answer: Captured backtraces and easy .context, plus single-word ergonomics. anyhow::Error wraps any error like Box<dyn Error> but adds context chaining and backtrace capture.
What is the recommended split between the two crates?
- thiserror for library error types, anyhow for application-level handling
- Never use either
- thiserror everywhere
- anyhow everywhere
Answer: thiserror for library error types, anyhow for application-level handling. Libraries expose typed errors with thiserror; applications consume and report them ergonomically with anyhow.
Continue this course
- Previous: Serialization with Serde
- Next: Send, Sync & Shared-State Concurrency — The thread-safety marker traits and Arc<Mutex<T>> shared state
- Quick reference: Rust cheat sheet
Frequently asked questions
When should I use thiserror versus anyhow?
The community rule of thumb is: thiserror for libraries, anyhow for applications. A library should expose a precise, typed error so its callers can inspect and match on specific failures and decide how to react — thiserror makes defining that enum painless. An application, on the other hand, usually just wants to propagate errors up to main and print a useful report; it rarely needs to programmatically distinguish every failure, so anyhow's single dynamic error type and ergonomic helpers are ideal. The two compose well: a binary can use anyhow at the top level while still depending on libraries whose functions return their own thiserror types, because anyhow can absorb any of them.
What do #[derive(Error)], #[error("...")], and #[from] actually generate?
On an enum, thiserror's #[derive(Error)] generates a complete std::error::Error implementation, including the source() method that links an error to its underlying cause. The #[error("...")] attribute on each variant provides that variant's Display message, and it can interpolate fields: {0} for tuple fields or {name} for named fields. Putting #[from] on a single field generates a From conversion from that field's type into your error enum, which is what lets the ? operator automatically wrap a lower-level error (like a ParseIntError) into the matching variant of your error type. Together these remove almost all of the boilerplate of hand-writing error enums.
How do anyhow's Result, Context, bail!, and ensure! work together?
anyhow::Result<T> is just an alias for Result<T, anyhow::Error>, where anyhow::Error can hold essentially any error. Because of that, the ? operator works on almost anything inside an anyhow function. The Context trait adds .context("msg") and .with_context(|| ...) so you can attach a descriptive note as an error travels upward, producing a readable causal chain. The bail! macro is shorthand for returning early with a freshly built error (return Err(anyhow!(...))), and ensure!(condition, message) bails only when the condition is false, much like assert! but returning an error instead of panicking. Returning anyhow::Result<()> from main even prints the whole context chain for you.
How do these compare to plain Box<dyn Error>?
Box<dyn std::error::Error> is the standard-library way to hold any error behind a trait object, and it requires no extra crates. anyhow::Error fills a similar role but adds real ergonomics: it captures a backtrace (when enabled), offers the .context chaining API for richer messages, prints a nicely formatted cause chain, and is a single concrete type that is convenient to thread through your code. So anyhow is essentially a more capable, friendlier Box<dyn Error> for application code. thiserror sits at the opposite end of the spectrum: instead of erasing the type, it gives you a concrete, exhaustively matchable error enum, which is exactly what library consumers want.