Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

2.2 · The Error Trait and Error Propagation Patterns

Domain 2 — Error Handling Ecosystem Duration: ~15 minutes Library components: std::error::Error, std::fmt::Display, std::fmt::Debug, <dyn Error>::downcast_ref (for downcasting), std::error::Request

Introduction

The std::error::Error trait lets Rust error types operate together. When you understand the trait, you can:

  • build structured error hierarchies,
  • examine error causes in code,
  • avoid Box<dyn Error> as the solution for all errors.

This tutorial shows:

  • What std::error::Error requires, and why.
  • The source() chain for error causes.
  • The difference between Display and Debug formatting for errors.
  • Downcasting with downcast_ref, which uses the same technique as std::any::Any.
  • How to build multi-level error hierarchies with From conversions.
  • The Error::provide method, which supplies typed context (nightly).

Implementing std::error::Error

Error has two required bounds, Display and Debug, and one optional method, source():

use std::error::Error;
use std::fmt;
use std::num::ParseIntError;

// Debug is one of the two required bounds. The derive implements it.
#[derive(Debug)]
enum CsvError {
    ColumnCount { expected: usize, got: usize },       // wrong number of columns
    ParseInt { column: usize, source: ParseIntError }, // a field is not an integer
    EmptyInput,                                        // the input has no data
}

// Display: the human-readable message (shown to end users)
impl fmt::Display for CsvError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::ColumnCount { expected, got } =>
                write!(f, "expected {expected} columns, got {got}"),
            // `..` ignores `source`: the message does not include the cause.
            Self::ParseInt { column, .. } =>
                write!(f, "column {column} is not a valid integer"),
            Self::EmptyInput =>
                write!(f, "input is empty"),
        }
    }
}

// Error: source() is optional. Implement it to give the cause of the error.
impl Error for CsvError {
    fn source(&self) -> Option<&(dyn Error + 'static)> {
        match self {
            // `source` is a &ParseIntError. It coerces to &(dyn Error + 'static).
            Self::ParseInt { source, .. } => Some(source),
            _ => None, // the other variants have no cause
        }
    }
}

The Debug impl (from the derive) is for developers. {:?} prints the full structure with field names and values, which is useful in log files and test output. Display is for operators. {} prints the error message, which is applicable to output that users see.

02_06_impl_error.rs prints:

parse OK: Ok([10, 20, 30])

EmptyInput Display:  input is empty
EmptyInput Debug:    EmptyInput

ColumnCount Display: expected 3 columns, got 2
ColumnCount Debug:   ColumnCount { expected: 3, got: 2 }

ParseInt Display:    column 1 is not a valid integer
ParseInt Debug:      ParseInt { column: 1, source: ParseIntError { kind: InvalidDigit } }
source() Display:    invalid digit found in string

Boxed dyn Error Display: column 0 is not a valid integer
Boxed source(): Some("invalid digit found in string")

All assertions passed.

The source() Chain

source() returns the underlying cause of an error. The causes make a chain:

AppError → QueryError → io::Error

Each link implements Error and returns the subsequent link from source(). A caller follows the chain to get the full sequence of causes:

Figure: Error Source Chain

fn print_error_chain(err: &dyn Error) {
    println!("  error: {err}");     // the Display message of the top-level error
    let mut current = err.source(); // Option<&(dyn Error + 'static)>
    let mut depth = 1;
    // The loop stops at the first error that has no source.
    while let Some(cause) = current {
        println!("  caused by [{depth}]: {cause}");
        current = cause.source(); // go to the subsequent cause
        depth += 1;
    }
}

Each layer wraps the error of the layer below it:

// Level 2: a query failure. It wraps the I/O error that caused it.
#[derive(Debug)]
struct QueryError {
    query: String,
    source: io::Error,
}

impl Error for QueryError {
    fn source(&self) -> Option<&(dyn Error + 'static)> {
        Some(&self.source) // the cause is the io::Error
    }
}

// Level 1: an application failure. It wraps the QueryError.
#[derive(Debug)]
struct AppError {
    context: String,
    source: QueryError,
}

impl Error for AppError {
    fn source(&self) -> Option<&(dyn Error + 'static)> {
        Some(&self.source) // the cause is the QueryError
    }
}

// Each type also has a Display impl (not shown here), because Error requires it.
// QueryError prints "query failed: ..." and AppError prints "operation failed: ...".

02_07_error_source_chain.rs prints:

=== Top-level error ===
  operation failed: fetch user list

=== source() at each level ===
level 1: operation failed: fetch user list
level 2: query failed: "SELECT * FROM users"
level 3: connection refused

=== Full error chain (walk) ===
  error: operation failed: fetch user list
  caused by [1]: query failed: "SELECT * FROM users"
  caused by [2]: connection refused

=== Chain through Box<dyn Error> ===
  error: operation failed: fetch user list
  caused by [1]: query failed: "SELECT 1"
  caused by [2]: connection refused

Chain depth: 3

All assertions passed.

The deprecated description() method

Display replaced the old fn description(&self) -> &str method. Rust 1.42 deprecated the method. Do not implement it in new code.

Downcasting with downcast_ref

When you receive a Box<dyn Error> or a &dyn Error, you can get the concrete type back with downcast_ref::<T>(). dyn Error + 'static has its own downcast_ref method. The method compares TypeId values, the same technique that std::any::Any uses. Any is not a supertrait of Error.

Figure: Downcasting dyn Error to Concrete Types

// NetworkError (field `code: u32`) and ParseError (field `field: String`)
// are two structs that implement Error.
fn handle_plugin_error(err: &(dyn Error + Send + Sync + 'static)) {
    // downcast_ref gives Some(&NetworkError) only if the concrete type is NetworkError.
    if let Some(net) = err.downcast_ref::<NetworkError>() {
        println!("  → NetworkError (code {}): retrying...", net.code);
    } else if let Some(parse) = err.downcast_ref::<ParseError>() {
        println!("  → ParseError on field {:?}: not retrying.", parse.field);
    } else {
        // The concrete type is not known: only the Display message is available.
        println!("  → Unknown error type: {err}");
    }
}

downcast_ref::<T>() returns Option<&T>. The result is None if the concrete type is not T. To consume the box and get an owned Box<T>, use Box::downcast::<T>():

let net_err: Box<dyn Error + Send + Sync> = /* ... */;
match net_err.downcast::<NetworkError>() {
    Ok(concrete) => println!("code={}", concrete.code), // concrete: Box<NetworkError>
    Err(_box) => { /* the downcast failed, and _box is the original box */ }
}

Important: Downcasting requires a 'static bound. Box<dyn Error + 'static> (the default) permits it. Box<dyn Error + 'a> with a non-static lifetime does not.

02_08_downcast_errors.rs prints:

fetch ok: Ok("data payload")

Received: network error 503: service unavailable
  → NetworkError (code 503): retrying...

Received: parse error on field "timestamp": "not-a-date"
  → ParseError on field "timestamp": not retrying.

downcast consumed: code=503

io::Error downcast: kind=NotFound

All assertions passed.

Building Custom Error Hierarchies

A library with a good structure has one top-level error enum that contains all the sub-error types. From implementations let ? convert each sub-error with no explicit conversion at the call site:

// NetworkError and ProtocolError are the error enums of two layers.
// Each one implements Error and has its own source().
#[derive(Debug)]
enum ClientError {
    Network(NetworkError),   // wraps a sub-error
    Protocol(ProtocolError), // wraps a sub-error
    InvalidUrl(String),      // has no inner error
}

// The From impls let `?` wrap a sub-error automatically at each call site
impl From<NetworkError> for ClientError {
    fn from(e: NetworkError) -> Self { Self::Network(e) }
}
impl From<ProtocolError> for ClientError {
    fn from(e: ProtocolError) -> Self { Self::Protocol(e) }
}

// connect(&str) -> Result<(), NetworkError>
// parse_response(&str) -> Result<u64, ProtocolError>
fn fetch(url: &str) -> Result<u64, ClientError> {
    if !url.starts_with("http") {
        return Err(ClientError::InvalidUrl(url.to_owned()));
    }
    // connect returns Err(NetworkError::Timeout) for the host "down.example".
    connect("down.example")?;               // NetworkError → ClientError via From
    Ok(parse_response("Content-Length: 42")?) // ProtocolError → ClientError via From
}

The hierarchy has these parts:

  • Sub-error types are narrow and reusable, and each one has its own source() chain.
  • The top-level enum wraps them. Its Error impl has a source() method that returns the inner error.
  • Callers match on the top-level enum to decide what to do.
  • Logging code uses Box<dyn Error> to handle all errors in the same way.

02_09_error_hierarchies.rs prints the Display message of each error:

network path: network: network timeout
  source: network timeout

url error: invalid URL: "ftp://example.com"

protocol error: protocol: invalid status line: "Garbage: xyz"
  source: invalid status line: "Garbage: xyz"

boxed: invalid URL: "ftp://x"

All assertions passed.

Error::provide: Typed Context Beyond source() (nightly)

Note: Error::provide uses the error_generic_member_access feature (tracking issue #99301). This feature requires nightly as of Rust 1.99.

source() gives you the chain of causes. But sometimes you want to attach typed context: a request ID, a file path, or a backtrace. The caller must be able to read that context without a downcast to the concrete type. Error::provide does this:

// Nightly only: the crate root needs #![feature(error_generic_member_access)].
use std::error::{Error, Request};

// RequestId is a newtype: struct RequestId(pub String).
// ServiceError has a `request_id: RequestId` field and a Display impl.
impl Error for ServiceError {
    fn provide<'a>(&'a self, request: &mut Request<'a>) {
        // Offer a &RequestId to each caller that requests this type.
        request.provide_ref::<RequestId>(&self.request_id);
    }
}

Callers get the context with std::error::request_ref::<T>(&err):

fn extract_request_id(err: &dyn Error) -> Option<&RequestId> {
    // The result is None if the error does not provide a RequestId.
    std::error::request_ref::<RequestId>(err)
}

// This also works through a Box<dyn Error>. `my_err` is a ServiceError.
let boxed: Box<dyn Error> = Box::new(my_err);
let id = std::error::request_ref::<RequestId>(&*boxed); // &*boxed is a &dyn Error

This mechanism is the base for Backtrace integration. See tutorial 2.4.

02_10_error_provide.rs prints (on nightly):

error:      service error: upstream timeout
request_id: Some("req-abc-123")

error:      something went wrong
request_id: None

boxed error: service error: disk full
request_id:  Some("req-xyz-999")

All assertions passed.

Summary

ConceptWhat it gives you
impl DisplayHuman-readable error message through {}
impl Debug (usually derived)Programmer-readable detail through {:?}
fn source()Chain of causes. Follow it with a while let loop
downcast_ref::<T>()The concrete type from a &dyn Error
Box::downcast::<T>()The concrete type from a Box<dyn Error>. The call consumes the box
From<SubError> for TopErrorAutomatic wrapping through ?
Error::provideTyped context of any type, in addition to source() (nightly)

Code Examples

FileDescription
02_06_impl_error.rsHow to implement Error with Display, Debug, and source()
02_07_error_source_chain.rsMulti-level source() chains, and how to follow a chain in code
02_08_downcast_errors.rsdowncast_ref and Box::downcast, which give the concrete type back
02_09_error_hierarchies.rsTop-level error enum with From conversions, and Box<dyn Error> at API boundaries
02_10_error_provide.rsError::provide for typed context (requires nightly)