ExamplesBy LevelBy TopicLearning Paths
1026 Intermediate

1026-error-display — Custom Error Display and Source Chain

Functional Programming

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

  • • Implement Display for nested error types
  • • Implement Error::source() to link errors in a causal chain
  • • Write a chain-walking function that formats the full error hierarchy
  • • Understand how anyhow::Error formats its chain by default
  • • Appreciate the difference between to_string() and the full source chain
  • Code 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

  • Explicit vs automatic: Rust requires manually implementing Error::source() for each wrapper; OCaml's Error.tag builds the chain automatically.
  • Lazy rendering: OCaml's Error.t is a lazy tree; Rust's Display is computed eagerly when called.
  • Chain direction: Rust's 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"));
        }
    }
    ✓ Tests Rust test suite
    #[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

  • • Manual string_of_* functions at each level
  • • Nested record types with explicit inner/source fields
  • • Chain display by recursive function
  • • No standard trait — each project defines its own convention
  • Rust Approach

  • Display trait: human-readable message for THIS error only
  • Error::source(): returns reference to underlying cause
  • • Walk chain with while let Some(e) = current.source()
  • Debug trait: programmer-readable with full structure
  • Comparison Table

    AspectOCamlRust
    Displaystring_of_* functionimpl Display
    Source chainManual field accessError::source() method
    Chain walkingRecursive functionWhile-let loop
    Standard traitNostd::error::Error
    Debug output[@@deriving show] (ppx)#[derive(Debug)]
    Inline formatManual concatenationdisplay_inline via join(" -> ")

    Exercises

  • Add a fourth error level DeploymentError that wraps AppError and format the resulting four-level chain.
  • Write error_chain_to_vec(err: &dyn Error) -> Vec<String> that collects all messages from root to leaf.
  • Implement a Display that renders the chain inline as "outer (caused by: middle (caused by: root))".
  • Open Source Repos