anyhow in a Binary: Context Layers Instead of Error Variants
You are writing a CLI. Somewhere deep in the call stack serde_json::from_slice fails, and three frames up you know it was the manifest at ./services/pay.toml. Your options are: define an enum, wrap the serde error, implement Display, implement source, write the From impl — or add one word:
use anyhow::{Context, Result};
fn load_manifest(path: &Path) -> Result<Manifest> {
let bytes = std::fs::read(path)
.with_context(|| format!("reading manifest {}", path.display()))?;
let manifest = toml::from_slice(&bytes)
.context("parsing manifest as TOML")?;
Ok(manifest)
}
anyhow::Error is the Box<dyn Error + Send + Sync + 'static> you met in the previous topic, plus three things a plain box does not give you: a message stack, backtrace capture, and typed downcasting — all without declaring a single variant. anyhow::Result<T> is just Result<T, anyhow::Error>, so signatures stay one line.
context and with_context attach a layer on the way out, and main can add the outermost one itself:
fn main() -> anyhow::Result<()> {
let manifest = load_manifest(Path::new("./services/pay.toml"))
.context("starting service")?;
Ok(())
}
When the file is missing, {:?} at the top of main prints:
Error: starting service
Caused by:
0: reading manifest ./services/pay.toml
1: No such file or directory (os error 2)
Read that carefully: the last context attached is the outermost layer and prints as the headline, with every earlier context and the original OS error stacked beneath it. The discipline that follows is about ordering — write contexts from the innermost frame outward (reading manifest X deep in the loader, then starting service at the top) and the report reads as a story of what the program was doing when the root cause fired. The second rule is about cost: .context(msg) takes an owned String and builds it at the call site on every execution, whereas .with_context(|| format!("... {id}")) takes a closure invoked only on the Err path. Use with_context whenever the message interpolates anything non-trivial.
Failure without an error value is the other convenience: anyhow! creates an error from a format string, bail!("unsupported mode: {mode}") returns early with one, and ensure!(cond, "token expired") is if !cond { bail!(...) }.
fn authorize(tok: &str) -> Result<()> {
ensure!(tok.len() >= 32, "token too short: {} bytes", tok.len());
Ok(())
}
Backtraces are where anyhow earns the "applications" label. With RUST_BACKTRACE=1, {:?} on an anyhow::Error appends the capture, so a top-level fn main() -> anyhow::Result<()> prints where the failure originated, not just the chain of messages — invaluable when the real error is swallowed three modules below your context. Keep main thin: propagate with ? all the way up and let the Debug formatting at the top do the reporting; catch errors in main manually only when you need a specific exit code or a JSON error envelope for a service.
Two costs worth knowing before you commit. First, anyhow::Error erases the variant structure — a caller who must act (retry vs. 404 vs. fatal) needs err.downcast_ref::<LibError>(), which works only while the concrete type survived and is Send + Sync + 'static. Second, anyhow is a dependency of your public API if you expose it; that is fine for a binary, and precisely why libraries reach for the other crate instead.
thiserror and the Library Contract: Errors Your Callers Can Act On
Flip the perspective: your crate is billing-core, and three services depend on it. One of them retries on transient failures, one maps "invoice not found" to HTTP 404, and one logs everything as a fatal bug. They cannot do any of that with a message string — they need to match. That requirement decides the crate for you before you write a line: a library exposes a typed enum; thiserror is the derive that builds it.
use thiserror::Error;
#[derive(Debug, Error)]
pub enum BillingError {
#[error("invoice {id} not found")]
NotFound { id: String },
#[error("payment provider rejected the charge: {0}")]
ProviderRejected(String),
#[error("transient upstream failure")]
Transient(#[from] std::io::Error), // source() + impl From<io::Error>
#[error("retry budget exhausted after {attempts} attempts")]
RetryExhausted { attempts: u32 },
}
thiserror expands to the exact code from the previous topic: Display from the #[error] format strings, Error::source() returning the field marked #[source] (or named source, or any field that is itself an error when #[from] is present), and impl From<...> for each #[from] — the trait that makes ? work inside your crate. It is a compile-time-only dependency: nothing runs, there is no runtime to link, and it costs nothing downstream.
For the interview question "what does the derive actually generate?", the manual equivalent of the Transient variant is this:
impl std::fmt::Display for BillingError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Transient(_) => write!(f, "transient upstream failure"),
// ...one arm per variant, from the #[error] strings
_ => Ok(()),
}
}
}
impl std::error::Error for BillingError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self { Self::Transient(e) => Some(e), _ => None }
}
}
impl From<std::io::Error> for BillingError {
fn from(e: std::io::Error) -> Self { BillingError::Transient(e) } // what #[from] writes
}
Thirty lines of boilerplate per enum, gone — and regenerated consistently the day you add a variant.
Designing the variants is the actual work; the derive is mechanical. Three constraints come from the contract you are publishing:
- Variant count is API surface. Every variant is a match arm your callers must eventually handle. Three to six coarse outcomes beat twenty internal states; expose internals behind one
Internal(String)-style variant (or keep it private to the enum's module) rather than leakingserde_json::Erroras a public case. - Add
#[non_exhaustive]as soon as you intend to add variants — it forces callers to keep a catch-all arm, turning a future addition from a breaking change into a patch release. - Never expose
anyhow::Errorin a public signature. Downstream crates would inherit the dependency,?conversion to their error types gets awkward, and they cannot match anything.anyhowbelongs inside your binary or your private test code;thiserrortypes belong on the boundary.
Why not just return Box<dyn Error> from a library? Because a box removes exactly the two things these callers need — matching and downcasting-by-design — and it makes your crate's failure mode depend on allocation and type erasure rather than on a documented enum. Box is a boundary tool (previous topic); at a public boundary, types communicate.
The interview framing to memorize: thiserror is for the crate that defines errors; anyhow is for the crate that consumes them. A library states what can go wrong; an application decides how to report it. The next chapter shows the seam where the two meet in one binary.
The Working Setup: Typed Errors Below, anyhow Above
Most real systems use both crates in the same dependency tree, divided by who consumes each error. The library layer (db-core, billing-core) publishes typed enums built with thiserror; the application layer (billingd, a CLI, an integration test suite) pulls those errors upward with anyhow and adds narrative context.
// billing-core — typed contract
#[derive(Debug, thiserror::Error)]
pub enum BillingError {
#[error("invoice {0} not found")]
NotFound(String),
#[error("provider timeout")]
Timeout,
}
// billingd/src/main.rs — application
use anyhow::{Context, Result};
fn charge_all(cfg: &Config) -> Result<()> {
for inv in &cfg.invoices {
charge(inv)
.with_context(|| format!("charging invoice {inv}"))?; // BillingError -> anyhow::Error
}
Ok(())
}
The ? in charge_all compiles because anyhow::Error has a blanket From<E: std::error::Error + Send + Sync + 'static> — the library error must satisfy those three bounds, which is why thiserror enums should derive nothing that blocks them (keep them Send + Sync: no Rc, no raw pointers). Context layers accumulate around the typed error, and the printed report is both:
Error: charging invoice inv_9f3
Caused by:
0: provider timeout
The application still has escape hatches back to structure when it needs a policy decision:
if let Some(BillingError::Timeout) = err.downcast_ref::<BillingError>() {
return schedule_retry(err);
}
downcast_ref recovers the original concrete type because anyhow stores it rather than stringifying — this is the single most important property that distinguishes it from format!("{}", e).into(). The rule that keeps this honest: downcast to the type the library actually returned, and only while it is still Send + Sync + 'static.
At the very edge of a service, the two styles meet one last time — the handler decides what an erased error means for the protocol:
async fn handler(State(s): State<Store>) -> impl IntoResponse {
match s.fetch_invoice("inv_1").await {
Ok(inv) => (StatusCode::OK, Json(inv)).into_response(),
Err(err) if err.downcast_ref::<BillingError>()
.map_or(false, |e| matches!(e, BillingError::NotFound(_))) =>
(StatusCode::NOT_FOUND, "invoice not found").into_response(),
Err(err) => {
error!(?err, "invoice fetch failed"); // full context chain in the log
(StatusCode::INTERNAL_SERVER_ERROR, "internal error").into_response()
}
}
}
This is the whole strategy in one function: structured branching where the protocol needs it, narrative logging where it does not, and a generic 500 so internal error text never leaks to a client. The same split applies to tests — #[test] fn t() -> anyhow::Result<()> gives you ? and readable failures for free, while the library's own unit tests assert on typed variants, because matching in a test is exactly what matching is for.
Pick by asking one question per crate — "who reads these errors?" Concretely:
| Crate kind | Return type | Crate | Why |
|---|---|---|---|
| Library with meaningful branches | Result<T, MyEnum> |
thiserror |
callers match; you own the contract |
Binary / daemon main |
anyhow::Result<T> |
anyhow |
context chain + backtrace, no variants to declare |
| Generated bindings / glue | Box<dyn Error + Send + Sync> |
std | heterogeneous, uninteresting causes |
| Errors crossing FFI | i32 + message buffer |
neither | Rust types cannot cross the boundary |
One last cost note for interviews: context layers allocate a String per attached message (only on failure), and each ? conversion moves a boxed error — both trivial next to I/O, but the reason a hot inner loop returns a typed enum instead of anyhow::Error. With the error strategy set, the roadmap shifts to data: Vec and Dynamic Arrays is where the values these errors describe actually live.
Quiz
1. In anyhow, `fs::read(p).context("reading p")?` fails and the report shows three layers. Where does the message you just attached appear?
2. Why prefer `.with_context(|| format!("user {id} failed"))` over `.context(format!(...))`?
3. A library crate needs to let callers distinguish NotFound from Timeout. Which is correct?
4. Your `#[derive(thiserror::Error)]` enum is used inside a function returning `anyhow::Result<()>` with `?`. Why does the conversion compile?
Flashcards
Question
What do `context` and `with_context` add to a `Result`, and how do the layers print?
Click to reveal answer
Answer
Each attaches a human-readable message to an `anyhow::Error` as a new outer layer (and converts an `Option` into a `Result` with that message on `None`). Printing `{:?}` shows the last-attached message as the headline, then each earlier context and the original error as numbered "Caused by" lines.
Question
anyhow vs thiserror — state the rule and the reason.
Click to reveal answer
Answer
`thiserror` for libraries that publish typed error enums their callers must match on; `anyhow` for binaries that need context chains, backtraces, and `bail!`/`ensure!` without declaring variants. Reason: who consumes the error — matching needs structure, reporting needs narrative.
Question
What three bounds must an error satisfy to be convertible into `anyhow::Error` with `?`, and why?
Click to reveal answer
Answer
`std::error::Error + Send + Sync + 'static` — anyhow's blanket `From` requires them so the error can be boxed, moved across threads (async tasks), and owned beyond the current borrow. Enums holding `Rc` or non-thread-safe fields break this.
Question
How do you get structured control back after an error became `anyhow::Error`?
Click to reveal answer
Answer
`err.downcast_ref::<MyLibError>()` — anyhow stores the original concrete error rather than stringifying it, so downcasting recovers it (if it is `Send + Sync + 'static`). Pattern-match the recovered reference to retry, map to 404, etc.
Question
Name the anyhow macros and what each expands to.
Click to reveal answer
Answer
`anyhow!("msg {x}")` creates an Error from a format string; `bail!("msg")` is `return Err(anyhow!(...))`; `ensure!(cond, "msg")` is `if !cond { bail!(...) }` — all for failures that have no underlying error value yet.
Revision Notes
Key Takeaways
- 1. `anyhow::Error` is an erased error with three upgrades over `Box<dyn Error>`: a context message stack, backtrace capture, and typed `downcast_ref`
- 2. Contexts stack outward — the last attached message prints as the headline, so write them from the innermost frame up to get a readable story
- 3. `.with_context(|| ...)` defers formatting to the failure path; `.context(format!(...))` builds the string on every successful call too
- 4. `thiserror` is compile-time only and turns `#[error]`/`#[source]`/`#[from]` into `Display`, `source()`, and the `From` impls `?` needs
- 5. Both crates coexist across a seam: typed enums below, context-rich `anyhow` above — never `anyhow::Error` in a library's public API
Interview Tips
- • "\"anyhow or thiserror?\" — answer with the consumer: if a caller must match variants, it is thiserror; if the crate only reports, it is anyhow; then add that both often appear in one repo separated by the library/binary boundary"
- • "\"Explain the Caused by output\" — layers are attached outward, printed outermost first; mention `.with_context` for lazy formatting and `RUST_BACKTRACE=1` for the capture"
- • "\"You found `Box<dyn Error>` in a library signature — what is wrong?\" — it erases the variants callers need to branch on, forces allocation/type erasure, and hides the contract; replace with a `#[non_exhaustive]` thiserror enum"
- • "\"How do you keep a specific failure after it became anyhow::Error?\" — `downcast_ref::<TheOriginalType>()`, which works because anyhow stores the concrete error instead of stringifying it; note the `Send + Sync + 'static` requirement"
Cheat Sheet
anyhow for Applications, thiserror for Libraries
anyhow (binary side):
use anyhow::{Context, Result, bail, ensure};
fn run() -> Result<()> {
let bytes = fs::read(&path).with_context(|| format!("reading {path}"))?; // lazy message
ensure!(bytes.len() < 4096, "manifest too large: {} bytes", bytes.len());
parse(&bytes).context("parsing manifest")?;
Ok(()) // main -> anyhow::Result<()> prints Debug chain + backtrace
}
| Need | Use |
|---|---|
| Attach a layer | .context(msg) / .with_context(|| msg) (lazy) |
| Fail with no underlying error | anyhow! / bail! / ensure! |
| Recover structure | err.downcast_ref::<ConcreteError>() |
| Backtrace | RUST_BACKTRACE=1 with {:?} |
thiserror (library side):
#[derive(Debug, thiserror::Error)]
pub enum BillingError {
#[error("invoice {id} not found")] NotFound { id: String },
#[error("provider timeout")] Timeout(#[source] std::io::Error), // source()
#[error("bad token")] BadToken(#[from] jwt::Error), // source() + From
}
Layer order: last context attached = headline; earlier contexts and the root error print below as Caused by:.
Migration seam: library returns Result<T, MyError> → app calls .context("operating on X")? (blanket From needs Error + Send + Sync + 'static) → catch at main, optionally downcast_ref for policy.