1026-error-display — Custom Error Display and Source Chain
Tutorial
The Problem
Structured error hierarchies lose their value if they cannot be rendered as human-readable messages. The std::error::Error trait provides a source() method for linking errors in a chain — root cause, intermediate wrapper, outer context — and Display for rendering each layer. Walking this chain produces messages like "startup failed: config error: file not found: /etc/app.conf".
This chain-walking pattern is what anyhow and eyre automate. Understanding it from scratch explains what those crates provide and when to build your own.
🎯 Learning Outcomes
Display for nested error typesError::source() to link errors in a causal chainanyhow::Error formats its chain by defaultto_string() and the full source chainCode Example
#![allow(clippy::all)]
// 1026: Custom Display for Nested Errors with Source Chain
// Walking the Error::source() chain for human-readable output
use std::error::Error;
use std::fmt;
// Inner error (root cause)
#[derive(Debug)]
enum IoError {
FileNotFound(String),
PermissionDenied(String),
}
impl fmt::Display for IoError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
IoError::FileNotFound(p) => write!(f, "file not found: {}", p),
IoError::PermissionDenied(p) => write!(f, "permission denied: {}", p),
}
}
}
impl Error for IoError {}
// Middle error (wraps inner)
#[derive(Debug)]
struct ConfigError {
operation: String,
source: IoError,
}
impl fmt::Display for ConfigError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{} failed", self.operation)
}
}
impl Error for ConfigError {
fn source(&self) -> Option<&(dyn Error + 'static)> {
Some(&self.source)
}
}
// Outer error (wraps middle)
#[derive(Debug)]
struct AppError {
module_name: String,
source: ConfigError,
}
impl fmt::Display for AppError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "[{}] error", self.module_name)
}
}
impl Error for AppError {
fn source(&self) -> Option<&(dyn Error + 'static)> {
Some(&self.source)
}
}
// Approach 1: Walk the source chain
fn display_error_chain(err: &dyn Error) -> String {
let mut chain = Vec::new();
let mut current: Option<&dyn Error> = Some(err);
let mut depth = 0;
while let Some(e) = current {
let prefix = if depth == 0 { "Error" } else { "Caused by" };
let indent = " ".repeat(depth);
chain.push(format!("{}{}: {}", indent, prefix, e));
current = e.source();
depth += 1;
}
chain.join("\n")
}
// Approach 2: Collect all error messages into a vec
fn error_sources(err: &dyn Error) -> Vec<String> {
let mut sources = vec![err.to_string()];
let mut current = err.source();
while let Some(e) = current {
sources.push(e.to_string());
current = e.source();
}
sources
}
// Approach 3: Single-line display with arrows
fn display_inline(err: &dyn Error) -> String {
error_sources(err).join(" -> ")
}
fn make_error() -> AppError {
AppError {
module_name: "config".into(),
source: ConfigError {
operation: "reading settings".into(),
source: IoError::FileNotFound("/etc/app.conf".into()),
},
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_display_chain() {
let err = make_error();
let chain = display_error_chain(&err);
assert!(chain.contains("Error:"));
assert!(chain.contains("Caused by:"));
assert!(chain.contains("file not found"));
let lines: Vec<&str> = chain.lines().collect();
assert_eq!(lines.len(), 3); // outer, middle, inner
}
#[test]
fn test_error_sources() {
let err = make_error();
let sources = error_sources(&err);
assert_eq!(sources.len(), 3);
assert_eq!(sources[0], "[config] error");
assert_eq!(sources[1], "reading settings failed");
assert!(sources[2].contains("file not found"));
}
#[test]
fn test_inline_display() {
let err = make_error();
let inline = display_inline(&err);
assert!(inline.contains(" -> "));
assert!(inline.starts_with("[config] error"));
}
#[test]
fn test_source_chain() {
let err = make_error();
// Level 0: AppError
assert_eq!(err.to_string(), "[config] error");
// Level 1: ConfigError
let src1 = err.source().unwrap();
assert_eq!(src1.to_string(), "reading settings failed");
// Level 2: IoError
let src2 = src1.source().unwrap();
assert!(src2.to_string().contains("file not found"));
// Level 3: None
assert!(src2.source().is_none());
}
#[test]
fn test_single_error_chain() {
let err = IoError::FileNotFound("test.txt".into());
let chain = display_error_chain(&err);
assert_eq!(chain, "Error: file not found: test.txt");
assert_eq!(error_sources(&err).len(), 1);
}
#[test]
fn test_display_vs_debug() {
let err = make_error();
// Display: human-readable
assert_eq!(format!("{}", err), "[config] error");
// Debug: programmer-readable with structure
let debug = format!("{:?}", err);
assert!(debug.contains("AppError"));
assert!(debug.contains("ConfigError"));
}
}Key Differences
Error::source() for each wrapper; OCaml's Error.tag builds the chain automatically.Error.t is a lazy tree; Rust's Display is computed eagerly when called.source() chain goes from outer to inner (you call source() repeatedly); OCaml's Error tree goes from inner to outer as tags are added.anyhow equivalence**: anyhow::Error wraps Rust errors and provides automatic source-chain display; it is conceptually similar to OCaml's Base.Error.OCaml Approach
OCaml's Base.Error is a lazy tree that records the full context automatically:
let config_error = Error.of_string "file not found: /etc/app.conf"
let app_error = Error.tag config_error ~tag:"config error"
let full = Error.tag app_error ~tag:"startup failed"
Error.to_string_hum full renders "startup failed: config error: file not found: /etc/app.conf". The laziness means the string is only built when rendered.
Full Source
#![allow(clippy::all)]
// 1026: Custom Display for Nested Errors with Source Chain
// Walking the Error::source() chain for human-readable output
use std::error::Error;
use std::fmt;
// Inner error (root cause)
#[derive(Debug)]
enum IoError {
FileNotFound(String),
PermissionDenied(String),
}
impl fmt::Display for IoError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
IoError::FileNotFound(p) => write!(f, "file not found: {}", p),
IoError::PermissionDenied(p) => write!(f, "permission denied: {}", p),
}
}
}
impl Error for IoError {}
// Middle error (wraps inner)
#[derive(Debug)]
struct ConfigError {
operation: String,
source: IoError,
}
impl fmt::Display for ConfigError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{} failed", self.operation)
}
}
impl Error for ConfigError {
fn source(&self) -> Option<&(dyn Error + 'static)> {
Some(&self.source)
}
}
// Outer error (wraps middle)
#[derive(Debug)]
struct AppError {
module_name: String,
source: ConfigError,
}
impl fmt::Display for AppError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "[{}] error", self.module_name)
}
}
impl Error for AppError {
fn source(&self) -> Option<&(dyn Error + 'static)> {
Some(&self.source)
}
}
// Approach 1: Walk the source chain
fn display_error_chain(err: &dyn Error) -> String {
let mut chain = Vec::new();
let mut current: Option<&dyn Error> = Some(err);
let mut depth = 0;
while let Some(e) = current {
let prefix = if depth == 0 { "Error" } else { "Caused by" };
let indent = " ".repeat(depth);
chain.push(format!("{}{}: {}", indent, prefix, e));
current = e.source();
depth += 1;
}
chain.join("\n")
}
// Approach 2: Collect all error messages into a vec
fn error_sources(err: &dyn Error) -> Vec<String> {
let mut sources = vec![err.to_string()];
let mut current = err.source();
while let Some(e) = current {
sources.push(e.to_string());
current = e.source();
}
sources
}
// Approach 3: Single-line display with arrows
fn display_inline(err: &dyn Error) -> String {
error_sources(err).join(" -> ")
}
fn make_error() -> AppError {
AppError {
module_name: "config".into(),
source: ConfigError {
operation: "reading settings".into(),
source: IoError::FileNotFound("/etc/app.conf".into()),
},
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_display_chain() {
let err = make_error();
let chain = display_error_chain(&err);
assert!(chain.contains("Error:"));
assert!(chain.contains("Caused by:"));
assert!(chain.contains("file not found"));
let lines: Vec<&str> = chain.lines().collect();
assert_eq!(lines.len(), 3); // outer, middle, inner
}
#[test]
fn test_error_sources() {
let err = make_error();
let sources = error_sources(&err);
assert_eq!(sources.len(), 3);
assert_eq!(sources[0], "[config] error");
assert_eq!(sources[1], "reading settings failed");
assert!(sources[2].contains("file not found"));
}
#[test]
fn test_inline_display() {
let err = make_error();
let inline = display_inline(&err);
assert!(inline.contains(" -> "));
assert!(inline.starts_with("[config] error"));
}
#[test]
fn test_source_chain() {
let err = make_error();
// Level 0: AppError
assert_eq!(err.to_string(), "[config] error");
// Level 1: ConfigError
let src1 = err.source().unwrap();
assert_eq!(src1.to_string(), "reading settings failed");
// Level 2: IoError
let src2 = src1.source().unwrap();
assert!(src2.to_string().contains("file not found"));
// Level 3: None
assert!(src2.source().is_none());
}
#[test]
fn test_single_error_chain() {
let err = IoError::FileNotFound("test.txt".into());
let chain = display_error_chain(&err);
assert_eq!(chain, "Error: file not found: test.txt");
assert_eq!(error_sources(&err).len(), 1);
}
#[test]
fn test_display_vs_debug() {
let err = make_error();
// Display: human-readable
assert_eq!(format!("{}", err), "[config] error");
// Debug: programmer-readable with structure
let debug = format!("{:?}", err);
assert!(debug.contains("AppError"));
assert!(debug.contains("ConfigError"));
}
}#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_display_chain() {
let err = make_error();
let chain = display_error_chain(&err);
assert!(chain.contains("Error:"));
assert!(chain.contains("Caused by:"));
assert!(chain.contains("file not found"));
let lines: Vec<&str> = chain.lines().collect();
assert_eq!(lines.len(), 3); // outer, middle, inner
}
#[test]
fn test_error_sources() {
let err = make_error();
let sources = error_sources(&err);
assert_eq!(sources.len(), 3);
assert_eq!(sources[0], "[config] error");
assert_eq!(sources[1], "reading settings failed");
assert!(sources[2].contains("file not found"));
}
#[test]
fn test_inline_display() {
let err = make_error();
let inline = display_inline(&err);
assert!(inline.contains(" -> "));
assert!(inline.starts_with("[config] error"));
}
#[test]
fn test_source_chain() {
let err = make_error();
// Level 0: AppError
assert_eq!(err.to_string(), "[config] error");
// Level 1: ConfigError
let src1 = err.source().unwrap();
assert_eq!(src1.to_string(), "reading settings failed");
// Level 2: IoError
let src2 = src1.source().unwrap();
assert!(src2.to_string().contains("file not found"));
// Level 3: None
assert!(src2.source().is_none());
}
#[test]
fn test_single_error_chain() {
let err = IoError::FileNotFound("test.txt".into());
let chain = display_error_chain(&err);
assert_eq!(chain, "Error: file not found: test.txt");
assert_eq!(error_sources(&err).len(), 1);
}
#[test]
fn test_display_vs_debug() {
let err = make_error();
// Display: human-readable
assert_eq!(format!("{}", err), "[config] error");
// Debug: programmer-readable with structure
let debug = format!("{:?}", err);
assert!(debug.contains("AppError"));
assert!(debug.contains("ConfigError"));
}
}
Deep Comparison
Custom Error Display — Comparison
Core Insight
Error messages need layers: "what went wrong" (Display), "why" (source chain), and "where" (Debug). Rust's Error trait standardizes all three.
OCaml Approach
string_of_* functions at each levelinner/source fieldsRust Approach
Display trait: human-readable message for THIS error onlyError::source(): returns reference to underlying causewhile let Some(e) = current.source()Debug trait: programmer-readable with full structureComparison Table
| Aspect | OCaml | Rust |
|---|---|---|
| Display | string_of_* function | impl Display |
| Source chain | Manual field access | Error::source() method |
| Chain walking | Recursive function | While-let loop |
| Standard trait | No | std::error::Error |
| Debug output | [@@deriving show] (ppx) | #[derive(Debug)] |
| Inline format | Manual concatenation | display_inline via join(" -> ") |
Exercises
DeploymentError that wraps AppError and format the resulting four-level chain.error_chain_to_vec(err: &dyn Error) -> Vec<String> that collects all messages from root to leaf.Display that renders the chain inline as "outer (caused by: middle (caused by: root))".