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::Errorrequires, and why. - The
source()chain for error causes. - The difference between
DisplayandDebugformatting for errors. - Downcasting with
downcast_ref, which uses the same technique asstd::any::Any. - How to build multi-level error hierarchies with
Fromconversions. - The
Error::providemethod, 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
Errorimpl has asource()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::provideuses theerror_generic_member_accessfeature (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
| Concept | What it gives you |
|---|---|
impl Display | Human-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 TopError | Automatic wrapping through ? |
Error::provide | Typed context of any type, in addition to source() (nightly) |
Code Examples
| File | Description |
|---|---|
02_06_impl_error.rs | How to implement Error with Display, Debug, and source() |
02_07_error_source_chain.rs | Multi-level source() chains, and how to follow a chain in code |
02_08_downcast_errors.rs | downcast_ref and Box::downcast, which give the concrete type back |
02_09_error_hierarchies.rs | Top-level error enum with From conversions, and Box<dyn Error> at API boundaries |
02_10_error_provide.rs | Error::provide for typed context (requires nightly) |