Skip to content
advanced Phase 7 · Error Handling with Result

Implementing the Error Trait

Build custom error enums with Display and source chaining for libraries.

45m
0 problems
Topic Progress 0%

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:

  1. Display — one sentence, with the identifying data (previous chapter).
  2. Debug — derived; used by main's Result return and by logging frameworks' {:?}.
  3. source() + From — source exposes the wrapped cause; From makes ? work, so fs::read(path)? inside your crate compiles because impl From<io::Error> for SignupError exists — 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?

Question 1 options

2. Why is storing `Inner(String)` in an error variant considered a defect compared with `Inner(std::io::Error)`?

Question 2 options

3. A caller needs to distinguish `UsernameTaken` from `RateLimited` to decide the HTTP status. Which error design supports that directly?

Question 3 options

4. Why is `Box<dyn Error>` typically written as `Box<dyn Error + Send + Sync + 'static>` in async Rust?

Question 4 options

Flashcards

Question

What must you implement to make a custom type usable as an error, and what does each part serve?

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?

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)]`?

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?

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?

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.

Continue Learning