Errors and Recovery
A library error is part of a public contract; an application error is often a diagnostic story assembled while unwinding.
Thiserror: make failure inspectable
Consumer goal. Expose withdrawal failures that callers can handle by meaning rather than by parsing prose.
use thiserror::Error;
#[derive(Debug, Error)]
enum WithdrawError {
#[error("amount must be greater than zero")]
InvalidAmount,
#[error("insufficient funds: requested {requested}, available {available}")]
InsufficientFunds { requested: u64, available: u64 },
}
fn withdraw(balance: &mut u64, amount: u64) -> Result<(), WithdrawError> {
if amount == 0 {
return Err(WithdrawError::InvalidAmount);
}
if amount > *balance {
return Err(WithdrawError::InsufficientFunds {
requested: amount,
available: *balance,
});
}
*balance -= amount;
Ok(())
}
fn main() {
let mut balance = 100;
match withdraw(&mut balance, 150) {
Ok(()) => println!("new balance: {balance}"),
Err(WithdrawError::InsufficientFunds { requested, available }) => {
println!("cannot withdraw {requested}; only {available} remains")
}
Err(error) => println!("withdrawal failed: {error}"),
}
}
Observed stdout.
cannot withdraw 150; only 100 remains
Boundary behavior. The caller chooses whether to recover from a specific variant, display the error, or propagate it.
Compile-time guarantee. Pattern matching exposes structured fields and can be exhaustive. Renaming the display message cannot silently break callers that match the enum.
Runtime boundary. Which failure occurs depends on input and state.
Tradeoff. A public enum is a strong stable contract. Adding a variant can break exhaustive downstream matches unless the API uses a non-exhaustive strategy.
Deep-dive question. What code does derive(Error) generate, and why does
Thiserror avoid becoming visible in the public API’s types?
Anyhow: add meaning while failure travels upward
Consumer goal. Preserve a low-level JSON error while explaining what the application was trying to do.
use anyhow::{Context, Result};
use serde::Deserialize;
#[derive(Deserialize)]
#[allow(dead_code)]
struct Config {
port: u16,
}
fn parse_config(input: &str) -> Result<Config> {
serde_json::from_str(input)
.context("configuration is not valid JSON")
}
fn main() {
if let Err(error) = parse_config(r#"{"port":"many"}"#) {
println!("{error:#}");
}
}
Observed stdout with Anyhow 1.0.104 and Serde JSON 1.0.151.
configuration is not valid JSON: invalid type: string "many", expected u16 at line 1 column 14
Boundary behavior. The concrete Serde error becomes one cause in a chain;
{:#} renders the chain compactly.
Compile-time guarantee. Result<T> keeps propagation explicit, but it does
not expose a fixed set of error variants to the caller.
Runtime boundary. Recovery by semantic category is possible only through downcasting or earlier typed handling.
Tradeoff. Type erasure makes application plumbing easy and diagnostics rich, but it is usually a poor public contract for a reusable library.
Deep-dive question. How does Context preserve the original source while
allowing lazy, application-specific messages?
Compare the architectural roles
| Question | Thiserror | Anyhow |
|---|---|---|
| Primary audience | callers of a reusable API | maintainers and users of an application |
| Public shape | concrete enum or struct | erased anyhow::Error |
| Recovery | match variants and fields | propagate, display, or downcast |
| Evolution pressure | variants are API commitments | context text is less structural |
| Main value | stable machine-readable contract | low-friction diagnostic context |
They often belong in the same system: typed errors within libraries, contextual errors near application orchestration and process boundaries.