The Two Things an Error Must Do: Say What Went Wrong, and Point at Why
Your crate defines its own failure type, and the compiler will not let you treat it like an error until it implements std::error::Error. The trait's required body is deceptively small — the real obligations come from its supertraits and from what callers do with it:
pub trait Error: Debug + Display {
fn source(&self) -> Option<&(dyn Error + 'static)> { None }
// description()/cause() are deprecated and ignored by modern code
}
So the minimum viable error is a #[derive(Debug)] type with a hand-written Display, plus an empty impl std::error::Error for MyError {}. That compiles. It is also nearly useless, because it answers only "what failed" and not "because of what." A realistic implementation:
use std::fmt;
#[derive(Debug)]
pub enum LoadError {
NotFound { path: String },
Malformed { path: String, detail: String },
Inner(std::io::Error),
}
impl fmt::Display for LoadError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::NotFound { path } => write!(f, "config not found: {path}"),
Self::Malformed { path, detail } => write!(f, "config {path} malformed: {detail}"),
Self::Inner(e) => write!(f, "config store unavailable: {e}"),
}
}
}
impl std::error::Error for LoadError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Inner(e) => Some(e), // link the cause
_ => None,
}
}
}
Display is user-facing text: it is what a CLI prints, what a 500 response embeds, and what log lines carry. Two rules keep it honest — the message should read as a sentence fragment without a trailing period or a leading "error:" (the printing layer adds its own framing), and it should contain the identifying data (path, key, line number), not just the category. write!(f, "failed") is a wasted Display.
source() is machine-facing: it returns the next error down the causal chain, typed as &(dyn Error + 'static) so any error fits. This is what makes wrapping legitimate rather than lossy. Walk it to see the power:
fn report(mut e: &(dyn std::error::Error + 'static)) {
eprintln!("error: {e}");
while let Some(cause) = e.source() {
eprintln!(" caused by: {cause}");
e = cause;
}
}
// error: config store unavailable: entity not found (os error 2)
// caused by: entity not found (os error 2)
Every logging framework, anyhow's {:?} formatting, and any framework that reports errors on your behalf relies on this chain. An error that swallows its cause — Self::Inner(format!("{e}")) storing a String — cuts the chain permanently: the OS error code, the kind(), the ability to downcast to std::io::Error are all gone. Keep the original value in a variant and expose it through source().
Two practical details. First, Display and source must agree: if Display already prints the inner error (as Self::Inner(e) above does), the chain will show that text twice — acceptable, and what #[error(transparent)] in thiserror deliberately does; otherwise keep the outer message about context and let source supply the detail. Second, Error: Debug means your enum derives Debug, which prints structurally — never put secrets in fields you would not want in a crash dump, and never derive Clone, PartialEq casually on errors that wrap io::Error (it is neither). This manual Display + source pair is exactly what the next chapter automates, and it is the single most common "implement this trait in an interview" request in Rust.
A Typed Error Enum Is an API: Variants Callers Can Match On
Decide what your library's callers are allowed to do with a failure, and the error type writes itself. If callers must branch — retry on timeout, treat missing as empty, fail hard on corrupt — they need variants, not strings:
#[derive(Debug, PartialEq)]
pub enum SignupError {
UsernameTaken(String),
WeakPassword { score: u8 },
RateLimited { retry_after: std::time::Duration },
Storage(std::io::Error),
}
A caller can now write exhaustive, compiler-checked handling:
match signup(user).await {
Ok(id) => id,
Err(SignupError::UsernameTaken(name)) => return web::conflict(format!("{name} is taken")),
Err(SignupError::RateLimited { retry_after }) => return web::too_many(*retry_after),
Err(e) => return web::internal(e), // everything else still handled
}
That _ => arm is the whole point: unknown variants from a newer version of the crate still have a home, so the enum is forward-compatible as long as callers keep a catch-all. Contrast this with Box<dyn Error> or anyhow::Error, where the caller must downcast by concrete type or compare strings — no exhaustiveness, no compiler help, no structure.
The three responsibilities to design for:
Display— one sentence, with the identifying data (previous chapter).Debug— derived; used bymain'sResultreturn and by logging frameworks'{:?}.source()+From—sourceexposes the wrapped cause;Frommakes?work, sofs::read(path)?inside your crate compiles becauseimpl From<io::Error> for SignupErrorexists — the conversion rule introduced in The question mark Operator.
Writing those From impls by hand is mechanical, which is why the ecosystem converged on thiserror — it generates Display, Error, source, and From from attributes while keeping the enum yours:
#[derive(Debug, thiserror::Error)]
pub enum SignupError {
#[error("username {0} is already taken")]
UsernameTaken(String),
#[error("password too weak: score {score}/4")]
WeakPassword { score: u8 },
#[error("rate limited, retry after {retry_after:?}")]
RateLimited {
retry_after: std::time::Duration,
#[source] // this field becomes source()
reason: std::io::Error, // #[from] would also generate From impl
},
}
#[from] on a field does double duty: it marks the field as the source and generates impl From<ThatType> for SignupError, which is precisely the trait ? needs — so the From impls you wrote manually in the previous chapter become one attribute each. (One constraint: at most one #[from] per enum, because From must be unambiguous.)
Interview framing, and it comes up often: "why an enum instead of String?" Answer with the caller's perspective — matching, exhaustiveness, structured data for HTTP status mapping, and a stable source() chain, versus stringly-typed errors that force parsing. Add the honest counterpoint: an enum couples callers to your variant list, so a library should keep variants coarse and few (three to six), expose #[non_exhaustive] if it expects to add more, and never let internal error types leak as public variants unless callers must see them.
Mapping a typed enum to protocol codes then becomes one table in your codebase instead of string sniffing:
impl From<SignupError> for StatusCode {
fn from(e: SignupError) -> Self {
match e {
SignupError::UsernameTaken(_) => StatusCode::CONFLICT,
SignupError::WeakPassword { .. } => StatusCode::UNPROCESSABLE_ENTITY,
SignupError::RateLimited { .. } => StatusCode::TOO_MANY_REQUESTS,
SignupError::Storage(_) => StatusCode::INTERNAL_SERVER_ERROR,
}
}
}
Exhaustiveness pays off a second time here: when a later version adds a variant, this match fails to compile and forces you to decide its HTTP meaning — a decision a String-based error would have skipped silently until a client parsed the wrong status.
When to Erase the Type: Box<dyn Error>, Downcasting, and Send + Sync
Some functions refuse to enumerate their failures. main can receive an io::Error from a file, a ParseIntError from a flag, and a JoinError from a thread — three unrelated types. Forcing them into one enum means wrapping all three anyway; type erasure says: keep them different, store them behind one pointer:
use std::error::Error;
fn load(path: &str) -> Result<Config, Box<dyn Error + Send + Sync + 'static>> {
let text = std::fs::read_to_string(path)?; // io::Error auto-boxed via From
let port: u16 = text.trim().parse()?; // ParseIntError auto-boxed
Ok(Config { port })
}
Erasure works because of two std conveniences: Box<dyn Error> implements Display/Debug by delegating to the inner value, and impl<E: Error + 'static> From<E> for Box<dyn Error> exists — so ? boxes any error automatically. The moment you write Box<dyn Error>, you have traded structure for freedom, and the trade has a precise cost table:
| Capability | Typed enum | Box<dyn Error> |
|---|---|---|
| Caller branches on cause | match, exhaustive |
downcast_ref::<E>() by concrete type |
| Add a variant later | breaking for exhaustive matches | never breaking |
| Compilation | one error type in signatures | short signatures, type hidden |
Chain via source() |
yes | yes (delegates) |
| Heterogeneous returns | must unify in an enum | free |
Downcasting is the retrieval side of erasure:
fn handle(e: Box<dyn Error + Send + Sync>) {
if let Some(io) = e.downcast_ref::<std::io::Error>() {
if io.kind() == std::io::ErrorKind::NotFound { /* treat as empty */ }
}
}
downcast_ref only works when the concrete type is 'static and you kept the exact type — converting through a From impl earlier destroys the ability to recover it. Erasure is therefore a one-way door per layer: erase at the top of a binary for convenience, keep types low in a library where decisions are made.
The + Send + Sync suffix is not decoration. The default Box<dyn Error> is neither Send nor Sync, so it cannot cross a std::thread boundary or be held in a type shared across threads — and Tokio's TaskError/JoinError payloads and anyhow::Error both require Send + Sync + 'static for exactly this reason. Add them habitually: type AnyError = Box<dyn Error + Send + Sync + 'static> and you never meet the "cannot be sent between threads" wall in async code.
Why the trait can be boxed at all is worth one sentence in an interview: std::error::Error is object-safe. Its methods take &self, return no generic parameters, and source() deliberately returns Option<&(dyn Error + 'static)> rather than Option<&Self> — a concrete self type would make the trait unusable as dyn Error. The 'static bound on that return is what guarantees the cause can outlive the borrow, which is also why downcasting to 'static types works.
Costs, in rough order of what people care about: each erased error is one heap allocation for the box (plus one for each Send + Sync wrapper layer), method calls go through a vtable instead of being inlined, and — the real cost — type information is gone from the signature, so errors are discovered by reading documentation or by a runtime downcast failing, never by the compiler. In a hot path that formats errors on success, that is measurable; in an error path, it is noise.
Where each tool belongs, in one line each: typed enums in libraries (callers must act on specific failures), Box<dyn Error> at binary boundaries (main, a worker loop, a plugin hook returning "anything"), anyhow::Error when you want context + chain formatting without declaring types — the choice the next topic expands into a rule.
Quiz
1. What are the actual supertrait requirements for implementing `std::error::Error`, and what does `source()` contribute?
2. Why is storing `Inner(String)` in an error variant considered a defect compared with `Inner(std::io::Error)`?
3. A caller needs to distinguish `UsernameTaken` from `RateLimited` to decide the HTTP status. Which error design supports that directly?
4. Why is `Box<dyn Error>` typically written as `Box<dyn Error + Send + Sync + 'static>` in async Rust?
Flashcards
Question
What must you implement to make a custom type usable as an error, and what does each part serve?
Click to reveal answer
Answer
`Debug` (usually derived — used by `{:?}` logging and `main`'s Result), `Display` (one user-facing sentence with identifying data), and `impl Error` — whose body may be empty but should override `source()` to expose the wrapped cause as `&(dyn Error + 'static)`.
Question
What is `Error::source()` and why does it matter more than Display?
Click to reveal answer
Answer
It returns the next error down the causal chain, typed as `dyn Error`, so logging frameworks can print "caused by:" layers. It matters because it preserves the original typed values — a Stringified cause loses downcast, ErrorKind, and any further chaining.
Question
What does `thiserror` generate from `#[derive(Error)]`?
Click to reveal answer
Answer
The `Display` impl from `#[error("...")]` messages, the `Error` impl with `source()` (from a `#[source]` field or one named `source`), and `impl From<...>` for any `#[from]` field — which is what `?` needs to convert source errors into your enum.
Question
Typed enum vs `Box<dyn Error>` — the one-line decision rule?
Click to reveal answer
Answer
Typed enum in libraries, when callers must `match` on specific causes and act differently; `Box<dyn Error + Send + Sync + 'static>` at binary boundaries (main, workers, plugins) where you only need to display, log, or chain "whatever can fail".
Question
When does `downcast_ref::<T>()` on an erased error succeed?
Click to reveal answer
Answer
Only when the stored concrete type is exactly `T` and `'static` — so it works right after an `?` boxed the original error, and fails once a `From` conversion or a `map_err` replaced it with another type or a String.
Revision Notes
Key Takeaways
- 1. `Error: Debug + Display`; the trait body compiles empty, but a useful error overrides `source()` so the causal chain survives wrapping
- 2. `Display` feeds humans (CLI output, HTTP bodies, log lines) while `source()` feeds tools — one identifies the data, the other preserves the typed cause
- 3. A typed enum turns errors into an API: callers `match` exhaustively with structured payloads, and `#[non_exhaustive]` keeps future variants non-breaking
- 4. `#[from]` in thiserror does double duty — it generates the `From` impl that `?` requires and marks the field as the source
- 5. `Box<dyn Error>` buys short signatures and heterogeneous returns at the cost of downcasting; add `+ Send + Sync + 'static` before it can cross a thread or task boundary
Interview Tips
- • "\"Implement the Error trait for this type\" — write Display with the identifying data, derive Debug, then an `impl Error` that returns the wrapped cause from `source()`; mention the deprecated `description()` so the interviewer knows the modern trait shape"
- • "\"What is the difference between Display and source?\" — Display is the human message for one layer; source is the typed link downward, which is what `{:?}` in anyhow and every 'caused by' printer walks"
- • "\"How would you let callers distinguish two failures?\" — a typed enum with a variant per outcome and a catch-all arm in callers; add `#[non_exhaustive]` if you want room to grow without breaking them"
- • "\"Why can't my async task return `Box<dyn Error>`?\" — the default trait object is neither Send nor Sync; write `Box<dyn Error + Send + Sync + 'static>` and explain that the bounds come from the task channel, not from errors themselves"
Cheat Sheet
Implementing the Error Trait
The contract:
#[derive(Debug)] // required supertrait
impl std::fmt::Display for MyError { // required supertrait: one sentence + identifying data
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "...") }
}
impl std::error::Error for MyError { // body may be empty...
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { ... } // ...but override this
}
Cause chain walker:
let mut e: &(dyn std::error::Error + 'static) = &err;
eprintln!("error: {e}");
while let Some(c) = e.source() { eprintln!(" caused by: {c}"); e = c; }
thiserror equivalent:
#[derive(Debug, thiserror::Error)]
enum AppError {
#[error("user {0} taken")] Taken(String),
#[error("storage failed")] Storage(#[from] std::io::Error), // also becomes source()
}
Design rules:
| Situation | Choice |
|---|---|
| Library, callers branch on cause | typed enum, 3-6 variants, #[non_exhaustive] if it will grow |
Binary / main / worker loop |
Box<dyn Error + Send + Sync + 'static> |
| Want context + backtrace, no types | anyhow::Error (next topic) |
| Need the original back | store the value, expose via source() — never stringify |
Never: return Box<dyn String>; a stringified cause cannot be downcast or chained.