Everything needed to write a library error type that callers can match on, that reports cleanly through
any source()-walking reporter, and that can evolve without breaking releases.
Contents
- 1. Attribute reference
- 2. Format strings
- 3. Sources, from and transparent
- 4. Granularity: one enum per what?
- 5. Variants by caller reaction
- 6. Context fields instead of strings
- 7. Evolving errors: non_exhaustive and helpers
- 8. Not leaking dependency types
- 9. The ErrorKind / opaque struct pattern
- 10. Size, Clone, PartialEq and generics
- 11. Backtraces in typed errors
- 12. Message style
1. Attribute reference
| Attribute | Where | Meaning |
|---|---|---|
#[derive(Debug, thiserror::Error)] | enum or struct | implements Display (from #[error]) and std::error::Error |
#[error("...")] | each enum variant, or the struct | Display message with format args |
#[error(transparent)] | variant or struct with exactly one field | forward Display and source() to that field |
#[source] | one field | returned by Error::source() |
field named source | one field | treated as #[source] automatically |
#[from] | the (only) field of a variant | implies #[source] and generates impl From<FieldType> |
#[backtrace] / Backtrace field | field | provides a backtrace through the unstable Error::provide API (nightly only) |
Constraints the derive enforces at compile time:
- A
#[from]variant may contain only the source field (and optionally a backtrace field). Context fields next to it are rejected:Fromwould not know what to put in them. - Two variants with
#[from]of the same type produce twoFromimpls: error E0119 "conflicting implementations". Use#[from]on one,#[source]+map_erron the other. #[error(transparent)]requires exactly one field.- Every variant of an enum needs its own
#[error("...")]or#[error(transparent)].
2. Format strings
#[derive(Debug, thiserror::Error)]
pub enum ConfigError {
#[error("missing key `{0}`")] // tuple field by index
Missing(String),
#[error("{key} must be between {min} and {max}, got {value}")] // named fields
OutOfRange { key: &'static str, min: i64, max: i64, value: i64 },
#[error("unexpected value {value:?} for {key}")] // Debug formatting quotes strings
Unexpected { key: String, value: String },
#[error("cannot read {}", .path.display())] // extra args; `.field` refers to a field
Read { path: std::path::PathBuf, #[source] source: std::io::Error },
#[error("invalid {type} literal")] // thiserror 2: field r#type, written {type}
Literal { r#type: &'static str },
#[error("limit is {limit}", limit = usize::MAX)] // named extra argument
TooBig,
}Rules:
- Placeholders are standard
format!syntax:{field},{field:?},{0},{0:>8},{{for a literal brace. - Additional arguments after the string can reference fields with a leading dot:
.0,.path,.items.len(). {field}requires the field to implementDisplay.PathBufdoes not: use{path:?}or.path.display().- thiserror 2: raw identifiers are written unraw inside the string (
{type}forr#type);{r#type}is rejected. Tuple variants can no longer mix numeric{0}access with extra positional arguments in the same message (it was ambiguous).
3. Sources, from and transparent
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum SyncError {
// 1:1 wrap, no context needed: `?` converts automatically.
#[error("sync transport failed")]
Transport(#[from] std::io::Error),
// Same source type, different meaning: no #[from], build it with map_err.
#[error("failed to write checkpoint {path:?}")]
Checkpoint { path: std::path::PathBuf, #[source] source: std::io::Error },
// Forward everything: this layer adds no information.
#[error(transparent)]
Remote(#[from] RemoteError),
}
fn save_checkpoint(path: &std::path::Path, bytes: &[u8]) -> Result<(), SyncError> {
std::fs::write(path, bytes).map_err(|source| SyncError::Checkpoint { path: path.to_owned(), source })
}The double-print rule
A reporter walks the chain: outer.to_string(), then outer.source().to_string(), and so on. If the outer
Display already contains the inner message, the reader sees it twice:
// bad: Display includes the source AND source() returns it
#[error("failed to read config: {0}")]
Read(#[from] std::io::Error),Error: failed to read config: No such file or directory (os error 2)
Caused by:
No such file or directory (os error 2)// good: describe this layer only
#[error("failed to read config")]
Read(#[from] std::io::Error),Error: failed to read config
Caused by:
No such file or directory (os error 2)Pick one: either print the inner error in Display and return None from source() (a leaf error that
flattens its cause, rarely right), or return it from source() and leave it out of Display (almost always
right). #[error(transparent)] is the explicit "this layer is invisible" choice: Display and source()
both delegate, so nothing is duplicated.
When you only ever display {} (no reporter walks the chain), the source is invisible. That is the
reporter's job: use anyhow's {:#}/{:?}, or a loop over source() (see
anyhow-context-reporting.md).
4. Granularity: one enum per what?
| Shape | Pros | Cons | Use when |
|---|---|---|---|
One crate-wide Error | simple imports, one Result alias | every function "can return" every variant; docs lie | small crates (< ~10 variants) |
One enum per module (store::Error, index::Error) | variants match the module's operations | some duplication | most libraries |
One enum per operation (OpenError, QueryError) | precise: callers match only possible cases | many types, conversion boilerplate | public APIs where precision matters (std does this: ParseIntError, TryFromIntError) |
Struct + kind (io::Error style) | stable API, hide internals | less pattern matching ergonomics | large public crates with changing internals |
A good default: one enum per module, plus dedicated small error structs for leaf operations that can fail
in exactly one way (ParsePortError).
5. Variants by caller reaction
Group failures by what the caller will do, not by which dependency produced them.
| Variant | Caller reaction | Retryable | HTTP |
|---|---|---|---|
NotFound { id } | show 404 / create it | no | 404 |
AlreadyExists { id } | treat as success or 409 | no | 409 |
Conflict { id, expected, actual } | re-read and retry the read-modify-write | yes | 409 |
InvalidInput { field, reason } | tell the user | no | 400/422 |
Unauthorized / Forbidden | re-authenticate / deny | no | 401/403 |
Busy / RateLimited { retry_after } | back off and retry | yes | 503/429 |
Timeout { op, after } | retry if idempotent | yes | 504 |
Io(io::Error) / Backend { source } | report; maybe retry by kind | depends | 500/503 |
Corrupted { path, offset } | alert, never retry | no | 500 |
If two variants always get the same reaction, merge them. If one variant needs two reactions depending on
a detail, split it or add a kind()/is_retryable() helper.
6. Context fields instead of strings
// bad: the only way to learn the id is to parse the message
#[error("{0}")]
NotFound(String), // built with format!("user {id} not found")
// good: data first, message derived from data
#[error("user {id} not found")]
NotFound { id: u64 },Fields let callers act (if let Err(StoreError::NotFound { id }) = ..), let logs be structured, and keep
messages consistent. Include what an operator needs to reproduce: ids, paths, offsets, limits, the
operation name. Do not include secrets or whole payloads.
7. Evolving errors: non_exhaustive and helpers
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum StoreError {
#[error("record {id} not found")]
#[non_exhaustive] // fields can be added later, too
NotFound { id: u64 },
#[error("store i/o failed")]
Io(#[from] std::io::Error),
}
impl StoreError {
pub fn is_not_found(&self) -> bool {
matches!(self, Self::NotFound { .. })
}
}#[non_exhaustive]on the enum forces downstreammatchto include_ =>, so new variants are not breaking. Inside your own crate, matches stay exhaustive (you still get compiler help).#[non_exhaustive]on a variant prevents downstream code from constructing it and requires..in patterns, so adding a field is not breaking.- Helper predicates (
is_not_found(),is_retryable()) give callers a stable API even if variants are reorganised.
8. Not leaking dependency types
Every type appearing in a public variant is part of your API. #[from] reqwest::Error means a reqwest
major bump is your major bump, and callers may start matching on reqwest internals.
Options, from lightest to strongest:
// 1. Keep it as a source, but not a public field type: box it.
#[error("http request to {url} failed")]
Http { url: String, #[source] source: Box<dyn std::error::Error + Send + Sync> },
// 2. Wrap it in your own opaque newtype.
#[derive(Debug, thiserror::Error)]
#[error(transparent)]
pub struct TransportError(reqwest::Error); // private field: callers can't touch reqwest::Error
#[error("http request to {url} failed")]
Http { url: String, #[source] source: TransportError },
// 3. Classify at the edge and keep only what callers need.
#[error("http {status} from {url}")]
Status { url: String, status: u16 },Constructing option 1 from a concrete error: map_err(|e| Error::Http { url, source: Box::new(e) })
(or e.into()).
It is fine to expose std types (std::io::Error, std::num::ParseIntError) and types from crates you
already expose elsewhere in your API.
9. The ErrorKind / opaque struct pattern
std::io::Error exposes a stable kind() and keeps its representation private. Use it when the set of
causes is large, changes often, or comes from many dependencies.
use std::{error::Error as StdError, fmt};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum ErrorKind {
NotFound,
PermissionDenied,
InvalidInput,
Unavailable,
Other,
}
#[derive(Debug)]
pub struct Error {
kind: ErrorKind,
message: String,
source: Option<Box<dyn StdError + Send + Sync + 'static>>,
}
impl Error {
pub fn kind(&self) -> ErrorKind {
self.kind
}
pub fn is_retryable(&self) -> bool {
self.kind == ErrorKind::Unavailable
}
pub(crate) fn new(kind: ErrorKind, message: impl Into<String>) -> Self {
Self { kind, message: message.into(), source: None }
}
pub(crate) fn with_source(mut self, source: impl StdError + Send + Sync + 'static) -> Self {
self.source = Some(Box::new(source));
self
}
}
impl fmt::Display for Error {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.message)
}
}
impl StdError for Error {
fn source(&self) -> Option<&(dyn StdError + 'static)> {
self.source.as_deref().map(|e| e as &(dyn StdError + 'static))
}
}
// Internal conversions stay private to the crate's behaviour, not its types:
impl From<std::io::Error> for Error {
fn from(e: std::io::Error) -> Self {
let kind = match e.kind() {
std::io::ErrorKind::NotFound => ErrorKind::NotFound,
std::io::ErrorKind::PermissionDenied => ErrorKind::PermissionDenied,
std::io::ErrorKind::TimedOut | std::io::ErrorKind::Interrupted => ErrorKind::Unavailable,
_ => ErrorKind::Other,
};
Error::new(kind, "i/o error").with_source(e)
}
}Callers: match err.kind() { ErrorKind::NotFound => .., _ => .. }. You can add kinds, change sources
and swap dependencies freely.
10. Size, Clone, PartialEq and generics
Result<T, E>is at least as large asE. A 200-byte error makes everyOkpath move 200 bytes.clippy::result_large_errwarns above 128 bytes. Fix by boxing the big variant's payload (Box<LargeContext>) or the whole error (pub struct Error(Box<ErrorInner>)).std::io::Erroris notCloneand notPartialEq, so enums containing it cannot derive them. Test withmatches!instead ofassert_eq!(see testing-errors.md). If you must share an error between consumers, wrap it inArc.- Generic errors work:
#[derive(Debug, thiserror::Error)] pub enum SendError<T> { #[error("channel closed")] Closed(T) }. thiserror infers the bounds its Display impl needs from the fields you format;Tstill needsDebug(Error requires it) and'staticto be boxed or used as a source. - Errors must be
'staticto be used assource()or boxed asdyn Error + 'static: avoid borrowed fields (&'a str); useString,Box<str>,Arc<str>or&'static str.
11. Backtraces in typed errors
Stable Rust has std::backtrace::Backtrace (1.65) but not the provide API that lets reporters find a
backtrace inside an arbitrary error. thiserror treats any field whose type is named Backtrace specially:
it generates a provide() impl that only compiles on nightly (error_generic_member_access). On stable,
derive nothing for such a type and write the two small impls by hand:
use std::backtrace::Backtrace;
use std::fmt;
#[derive(Debug)]
pub struct InvariantError {
what: &'static str,
backtrace: Backtrace, // captured explicitly; respects RUST_BACKTRACE / RUST_LIB_BACKTRACE
}
impl InvariantError {
pub fn new(what: &'static str) -> Self {
Self { what, backtrace: Backtrace::capture() }
}
pub fn backtrace(&self) -> &Backtrace {
&self.backtrace
}
}
impl fmt::Display for InvariantError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "invariant violated: {}", self.what)
}
}
impl std::error::Error for InvariantError {}Most applications get backtraces from anyhow instead: the anyhow::Error created at the first ? captures
one (when enabled), which points close enough to the failure.
12. Message style
Follow the std/API guidelines convention:
- lowercase, no trailing period:
"invalid digit found in string" - describe what failed at this layer:
"failed to open store log", not"error"or"oops" - no
"error: "prefix (the reporter adds it), no source text (the chain adds it) - name the thing:
"user {id} not found","config key{key}is missing" - use
{:?}for user-provided strings so whitespace and empty values are visible:"invalid name {name:?}"
rust-borrow-checker-error-explainer
Explain and fix Rust borrow-checker, ownership, move and lifetime compiler errors: E0382 use of moved value, E0499 cannot borrow as mutable more than once, E0502 also borrowed as immutable, E0505, E0506, E0503, E0507 cannot move out of index/Rc/reference, E0515 return reference to local, E0597 does not live long enough, E0716 temporary value dropped while borrowed, E0373 closure may outlive (spawn needs move), E0521 borrowed data escapes, E0384, E0596, E0594, E0381, E0713, 'lifetime may not live long enough', RefCell 'already borrowed: BorrowMutError' panics. Ranks fixes from restructuring (split borrows, entry API, split_at_mut, get_disjoint_mut, mem::take, Option::take, indices, thread::scope) down to clone and Rc<RefCell>/Arc<Mutex>; never unsafe. Covers NLL limits (get-or-insert), iterator invalidation, closure captures, self-referential structs. Ships std-only Rust triage tools.
rust-cargo-workspace-designer
Design, review and fix Cargo workspaces: when to split a crate (compile time, boundaries, publishing, proc-macros), crates/* vs apps/ + crates/ layouts, virtual manifest vs root package, members/exclude/default-members, resolver 2 vs 3 (MSRV-aware, edition 2024), [workspace.package] and [workspace.dependencies] inheritance, [workspace.lints], root-only profiles (lto, codegen-units), additive features with dep: and weak ?/ syntax, feature unification pitfalls, internal path + version deps, publish = false, Cargo.lock and MSRV policy, xtask, CI with --locked, nextest, rust-cache, sccache, cargo-hack/deny/machete/udeps/semver-checks, and cargo build --timings. Ships a workspace auditor and crate-graph tool.
rust-lifetime-annotation-advisor
Decide when Rust lifetime annotations are needed and which ones: the three elision rules, structs holding references (struct Parser<'a>), owned vs borrowed (String, &str, Cow<'a, str>, Arc<str>), one vs several lifetimes, 'a: 'b bounds, T: 'static misconceptions, Box<dyn Trait + 'a> default object lifetimes, impl Trait + '_ and edition 2024 capture rules with use<..>, variance and PhantomData, HRTB for<'a> Fn(&'a T), GATs and lending iterators, async fn and tokio::spawn 'static, self-referential structs (ouroboros, self_cell, indices, arenas), Rc/Arc to escape lifetime soup. Ships std-only Rust checkers for redundant, missing, unused and over-restrictive lifetimes.
rust-tokio-async-service-builder
Build and review production async Rust services on Tokio 1.x and axum 0.8: runtime choice (#[tokio::main] multi_thread vs current_thread, Builder), axum routing with /{id} paths, State, extractors, IntoResponse error types, axum::serve with_graceful_shutdown, tower/tower-http middleware (TraceLayer, TimeoutLayer, CorsLayer, ConcurrencyLimitLayer, RequestBodyLimitLayer), Arc<AppState>, std vs tokio Mutex, JoinSet, TaskTracker, CancellationToken, select! cancel safety, bounded mpsc, oneshot, broadcast, watch, actors, Semaphore, timeouts, retries, spawn_blocking, sqlx PgPool, reqwest Client reuse, tracing, tests with start_paused and oneshot, Dockerfile. Fixes 'future cannot be sent between threads safely', E0373, Rc or MutexGuard held across await, 'Cannot start a runtime from within a runtime', 'Path segments must not start with :'. Ships std-only Rust scanners and a compiling axum 0.8 scaffold.
rust-trait-design-reviewer
Review and design Rust traits and generic APIs: generics vs impl Trait vs dyn Trait (static vs dynamic dispatch), dyn compatibility / object safety (generic methods, Self returns, where Self: Sized, error[E0038] not dyn compatible / cannot be made into an object), associated types vs generic parameters, blanket impls, coherence and orphan rule (E0117, E0119, E0210), newtype, extension, sealed and marker traits, supertraits and trait upcasting (1.86), async fn in traits (1.75) and the Send bound problem (trait_variant, async-trait), RPITIT, GATs, From/Into/TryFrom/AsRef/Borrow, std trait checklist (Debug, Clone, PartialEq, Hash, Default, Display, Error, Send/Sync, serde feature), #[must_use], #[non_exhaustive], semver hazards, Deref abuse, builders, typestate, PhantomData, Box<dyn Error + Send + Sync>, Fn traits, mockall.
rust-unsafe-block-auditor
Audit unsafe Rust for soundness: inventory every unsafe block, unsafe fn, unsafe impl/trait, extern block and unsafe attribute, check // SAFETY: comments and # Safety docs, and classify each as sound, needs-justification or likely UB, proposing safe alternatives first. Covers the five unsafe superpowers, aliasing (Stacked/Tree Borrows), invalid values, MaybeUninit vs mem::zeroed/uninitialized, transmute, slice::from_raw_parts, Vec::set_len/from_raw_parts, get_unchecked, CString::as_ptr dangling, unsafe impl Send/Sync, Pin projection, FFI (extern "C", repr(C), C-unwind, Box::into_raw/from_raw), strict provenance, static mut and &raw, edition 2024 (unsafe extern, #[unsafe(no_mangle)], unsafe_op_in_unsafe_fn, env::set_var), Miri, sanitizers, cargo-fuzz, loom, cargo-geiger and clippy safety lints.