How does thiserror::Error::source implement error chain inspection for debugging?

thiserror::Error::source automatically implements the std::error::Error::source method for error types by leveraging the #[source] attribute, enabling error chain traversal for debugging and logging. When an error wraps another error, the source method returns a reference to the underlying error, allowing tools to walk the entire error chain. This chain inspection reveals the complete causal sequence from a high-level error down to the root cause, which is essential for understanding complex failures in production systems. The thiserror derive macro generates the source() implementation automatically, extracting source errors from fields marked with #[source] or fields named source.

Basic Error with Source

use thiserror::Error;
 
#[derive(Error, Debug)]
#[error("Failed to process file")]
struct FileError {
    #[source]
    inner: std::io::Error,
}
 
fn main() {
    let err = FileError {
        inner: std::io::Error::new(std::io::ErrorKind::NotFound, "file not found"),
    };
    
    // source() returns the underlying error
    if let Some(source) = err.source() {
        println!("Caused by: {}", source);
        // Caused by: file not found
    }
}

The #[source] attribute tells thiserror to implement source() returning this field.

Automatic Source Detection

use thiserror::Error;
 
// Field named "source" is automatically used
#[derive(Error, Debug)]
#[error("Processing failed")]
struct ProcessingError {
    source: std::io::Error,  // No #[source] needed
}
 
// Field named "source" in enum variants
#[derive(Error, Debug)]
enum AppError {
    #[error("IO error occurred")]
    Io {
        source: std::io::Error,  // Automatically used as source
    },
    
    #[error("Parsing failed")]
    Parse {
        #[source]
        inner: serde_json::Error,  // Explicit #[source]
    },
}
 
fn main() {
    let err = AppError::Io {
        source: std::io::Error::new(std::io::ErrorKind::PermissionDenied, "access denied"),
    };
    
    // source() works automatically
    println!("Caused by: {}", err.source().unwrap());
}

Fields named source are automatically detected; other names require #[source].

Walking Error Chains

use thiserror::Error;
use std::error::Error;
 
#[derive(Error, Debug)]
#[error("Configuration error")]
struct ConfigError {
    #[source]
    inner: ParseError,
}
 
#[derive(Error, Debug)]
#[error("Parse error at line {line}")]
struct ParseError {
    line: usize,
    #[source]
    inner: LexerError,
}
 
#[derive(Error, Debug)]
#[error("Unexpected character '{char}'")]
struct LexerError {
    char: char,
}
 
fn main() {
    let err = ConfigError {
        inner: ParseError {
            line: 42,
            inner: LexerError { char: '!' },
        },
    };
    
    // Walk the entire error chain
    let mut current: &dyn Error = &err;
    
    println!("Error: {}", current);
    
    while let Some(source) = current.source() {
        println!("Caused by: {}", source);
        current = source;
    }
    
    // Output:
    // Error: Configuration error
    // Caused by: Parse error at line 42
    // Caused by: Unexpected character '!'
}

Walking source() recursively reveals the complete error chain.

Standard Error Trait Integration

use thiserror::Error;
use std::error::Error;
 
#[derive(Error, Debug)]
#[error("Database error")]
struct DatabaseError {
    #[source]
    inner: ConnectionError,
}
 
#[derive(Error, Debug)]
#[error("Connection failed to {host}")]
struct ConnectionError {
    host: String,
    #[source]
    inner: std::io::Error,
}
 
fn main() {
    let err = DatabaseError {
        inner: ConnectionError {
            host: "localhost".to_string(),
            inner: std::io::Error::new(
                std::io::ErrorKind::ConnectionRefused,
                "connection refused"
            ),
        },
    };
    
    // Use std::error::Error methods
    let err: &dyn Error = &err;
    
    // Display shows the error message
    println!("Error: {}", err);
    
    // source() shows the immediate cause
    if let Some(source) = err.source() {
        println!("Source: {}", source);
    }
    
    // Chain::new() (in some crates) or manual iteration walks the chain
    let mut chain = vec![err.to_string()];
    let mut current = err.source();
    
    while let Some(source) = current {
        chain.push(source.to_string());
        current = source.source();
    }
    
    println!("Full chain: {:?}", chain);
}

thiserror integrates seamlessly with std::error::Error for tooling compatibility.

Error Chain with anyhow

use thiserror::Error;
use anyhow::{Context, Result};
 
#[derive(Error, Debug)]
#[error("User lookup failed")]
struct UserLookupError {
    #[source]
    inner: DatabaseError,
}
 
#[derive(Error, Debug)]
#[error("Database error")]
struct DatabaseError {
    #[source]
    inner: std::io::Error,
}
 
fn main() -> Result<()> {
    // anyhow preserves error chains with context
    let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "user not found");
    
    let db_err = DatabaseError { inner: io_err };
    let user_err = UserLookupError { inner: db_err };
    
    // anyhow can wrap thiserror types
    let err: anyhow::Error = user_err.into();
    
    // Print full chain
    for cause in err.chain() {
        println!("Caused by: {}", cause);
    }
    
    Ok(())
}

thiserror errors work with anyhow for enhanced error handling.

Multiple Source Fields

use thiserror::Error;
 
#[derive(Error, Debug)]
enum MultiError {
    #[error("Failed to load config")]
    Config {
        #[source]
        parse_error: serde_json::Error,
    },
    
    #[error("Failed to connect")]
    Connection {
        #[source]
        io_error: std::io::Error,
    },
    
    #[error("Failed to validate")]
    Validation {
        #[source]
        validation_error: ValidationError,
    },
}
 
#[derive(Error, Debug)]
#[error("Validation failed: {message}")]
struct ValidationError {
    message: String,
}
 
fn main() {
    let err = MultiError::Config {
        parse_error: serde_json::from_str::<serde_json::Value>("invalid").unwrap_err(),
    };
    
    if let Some(source) = err.source() {
        println!("Parse error: {}", source);
    }
}

Each variant can have its own source error type.

Optional Source Errors

use thiserror::Error;
 
#[derive(Error, Debug)]
#[error("Operation failed")]
struct OperationError {
    #[source]
    inner: Option<std::io::Error>,
}
 
// thiserror handles Option<Error> correctly
// source() returns Some only when the Option is Some
 
fn main() {
    // With source
    let err_with_source = OperationError {
        inner: Some(std::io::Error::new(
            std::io::ErrorKind::TimedOut,
            "timeout"
        )),
    };
    
    println!("Has source: {}", err_with_source.source().is_some());
    
    // Without source
    let err_without_source = OperationError { inner: None };
    
    println!("Has source: {}", err_without_source.source().is_none());
}

Option<Error> fields work correctly with source().

Box Sources

use thiserror::Error;
use std::error::Error;
 
#[derive(Error, Debug)]
#[error("Application error")]
struct AppError {
    #[source]
    inner: Box<dyn Error + Send + Sync>,
}
 
fn main() {
    let err = AppError {
        inner: Box::new(std::io::Error::new(
            std::io::ErrorKind::NotFound,
            "resource not found"
        )),
    };
    
    // source() returns the boxed error
    if let Some(source) = err.source() {
        println!("Caused by: {}", source);
    }
}

Boxed errors work as sources, enabling type-erased error chains.

Source Attribute Variations

use thiserror::Error;
 
// #[source] attribute
#[derive(Error, Debug)]
#[error("IO error")]
struct IoError {
    #[source]
    underlying: std::io::Error,
}
 
// #[source] with explicit type
#[derive(Error, Debug)]
#[error("Network error")]
struct NetworkError {
    #[source]
    io: std::io::Error,
}
 
// Automatic detection by name
#[derive(Error, Debug)]
#[error("Parse error")]
struct ParseError {
    source: serde_json::Error,  // Automatically detected
}
 
// Transparent error forwarding
#[derive(Error, Debug)]
#[error(transparent)]  // Forwards both Display and source
struct TransparentError(#[source] std::io::Error);
 
fn main() {
    let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "not found");
    
    let err1 = IoError { underlying: io_err };
    let err2 = NetworkError { io: std::io::Error::new(std::io::ErrorKind::Other, "other") };
    let err3 = ParseError { source: serde_json::from_str("invalid").unwrap_err() };
    let err4 = TransparentError(std::io::Error::new(std::io::ErrorKind::BrokenPipe, "broken"));
    
    // All implement source() correctly
    println!("Source: {:?}", err1.source());
    println!("Source: {:?}", err2.source());
    println!("Source: {:?}", err3.source());
    println!("Source: {:?}", err4.source());
}

Multiple syntax options for declaring source errors.

Transparent Errors

use thiserror::Error;
 
// #[error(transparent)] forwards Display and source
#[derive(Error, Debug)]
#[error(transparent)]
struct WrappedError(#[source] std::io::Error);
 
// Useful for wrapping external errors
#[derive(Error, Debug)]
enum AppError {
    #[error(transparent)]
    Io(#[source] std::io::Error),
    
    #[error(transparent)]
    Json(#[source] serde_json::Error),
    
    #[error("Custom error: {0}")]
    Custom(String),
}
 
fn main() {
    let err = AppError::Io(std::io::Error::new(
        std::io::ErrorKind::NotFound,
        "file missing"
    ));
    
    // Display is forwarded from the source
    println!("Error: {}", err);  // "file missing"
    
    // source() is also forwarded
    if let Some(source) = err.source() {
        println!("Source: {}", source);
    }
}

#[error(transparent)] creates a passthrough for both Display and source.

Custom Source Implementation

use thiserror::Error;
use std::error::Error;
 
#[derive(Error, Debug)]
#[error("Multi-cause error")]
struct MultiCauseError {
    primary: std::io::Error,
    secondary: Option<std::io::Error>,
}
 
// Implement source() manually when needed
impl Error for MultiCauseError {
    fn source(&self) -> Option<&(dyn Error + 'static)> {
        Some(&self.primary)
    }
}
 
// Or let thiserror handle it:
#[derive(Error, Debug)]
#[error("Single cause error")]
struct SingleCauseError {
    #[source]
    cause: std::io::Error,
}
 
fn main() {
    let err = SingleCauseError {
        cause: std::io::Error::new(std::io::ErrorKind::Other, "test"),
    };
    
    // thiserror-generated source()
    println!("Source: {}", err.source().unwrap());
}

You can implement source() manually, but thiserror handles common cases.

Debugging with Error Chains

use thiserror::Error;
use std::error::Error;
 
#[derive(Error, Debug)]
#[error("HTTP request failed")]
struct HttpError {
    url: String,
    #[source]
    inner: NetworkError,
}
 
#[derive(Error, Debug)]
#[error("Network error")]
struct NetworkError {
    #[source]
    inner: std::io::Error,
}
 
fn print_error_chain(err: &dyn Error) {
    println!("Error: {}", err);
    
    let mut level = 1;
    let mut current = err.source();
    
    while let Some(source) = current {
        println!("  {}: {}", level, source);
        level += 1;
        current = source.source();
    }
}
 
fn main() {
    let err = HttpError {
        url: "https://example.com".to_string(),
        inner: NetworkError {
            inner: std::io::Error::new(
                std::io::ErrorKind::ConnectionReset,
                "connection reset by peer"
            ),
        },
    };
    
    print_error_chain(&err);
    // Error: HTTP request failed
    //   1: Network error
    //   2: connection reset by peer
}

Walking the source chain provides complete error context for debugging.

Integration with Logging

use thiserror::Error;
use std::error::Error;
 
#[derive(Error, Debug)]
#[error("Service unavailable: {service}")]
struct ServiceError {
    service: String,
    #[source]
    inner: ConnectionError,
}
 
#[derive(Error, Debug)]
#[error("Connection timeout")]
struct ConnectionError;
 
fn log_error_chain(err: &dyn Error) {
    // Log the top-level error
    eprintln!("ERROR: {}", err);
    
    // Log each cause
    let mut current = err.source();
    while let Some(cause) = current {
        eprintln!("  caused by: {}", cause);
        current = cause.source();
    }
}
 
fn main() {
    let err = ServiceError {
        service: "database".to_string(),
        inner: ConnectionError,
    };
    
    log_error_chain(&err);
}

Error chains integrate naturally with logging for debugging production issues.

Source and Backtraces

use thiserror::Error;
use std::backtrace::Backtrace;
 
#[derive(Error, Debug)]
#[error("Operation failed")]
struct OperationError {
    #[source]
    inner: std::io::Error,
    backtrace: Backtrace,
}
 
fn main() {
    let err = OperationError {
        inner: std::io::Error::new(std::io::ErrorKind::Other, "failed"),
        backtrace: Backtrace::capture(),
    };
    
    // source() returns the error
    if let Some(source) = err.source() {
        println!("Source: {}", source);
    }
    
    // Backtrace is separate (RUST_BACKTRACE=1 to enable)
    // println!("Backtrace: {}", err.backtrace);
}

Source chains and backtraces provide complementary debugging information.

Error Source in Tests

use thiserror::Error;
use std::error::Error;
 
#[derive(Error, Debug)]
#[error("Validation error")]
struct ValidationError {
    field: String,
    #[source]
    inner: ParseError,
}
 
#[derive(Error, Debug)]
#[error("Invalid format")]
struct ParseError;
 
#[cfg(test)]
mod tests {
    use super::*;
    
    #[test]
    fn test_error_source() {
        let err = ValidationError {
            field: "email".to_string(),
            inner: ParseError,
        };
        
        // Verify source chain
        let source = err.source().expect("should have source");
        assert_eq!(source.to_string(), "Invalid format");
    }
    
    #[test]
    fn test_error_chain_length() {
        let err = ValidationError {
            field: "email".to_string(),
            inner: ParseError,
        };
        
        let mut count = 0;
        let mut current = err.source();
        
        while let Some(source) = current {
            count += 1;
            current = source.source();
        }
        
        assert_eq!(count, 1);
    }
}

Test error chains to verify proper error wrapping.

Generic Error Types

use thiserror::Error;
 
#[derive(Error, Debug)]
#[error("Wrapper error")]
struct WrapperError<E: std::error::Error + 'static> {
    #[source]
    inner: E,
}
 
#[derive(Error, Debug)]
#[error("Inner error")]
struct InnerError;
 
fn main() {
    let err = WrapperError { inner: InnerError };
    
    println!("Error: {}", err);
    if let Some(source) = err.source() {
        println!("Source: {}", source);
    }
}

Generic error types can use #[source] with type parameters.

Source with Anyhow Context

use thiserror::Error;
use anyhow::{Context, Result};
 
#[derive(Error, Debug)]
#[error("Processing failed")]
struct ProcessingError {
    #[source]
    inner: std::io::Error,
}
 
fn process_file() -> Result<()> {
    std::fs::read_to_string("config.json")
        .map_err(|e| ProcessingError { inner: e })?;
    
    Ok(())
}
 
fn main() -> Result<()> {
    process_file()
        .context("Failed during startup")?;
    
    Ok(())
}
 
// The error chain now includes:
// 1. "Failed during startup" (from anyhow context)
// 2. "Processing failed" (from thiserror)
// 3. The actual io::Error message

thiserror errors integrate with anyhow context for richer chains.

Error Display vs Source

use thiserror::Error;
 
#[derive(Error, Debug)]
#[error("Failed to load user {user_id}")]
struct UserLoadError {
    user_id: u64,
    #[source]
    db_error: DatabaseError,
}
 
#[derive(Error, Debug)]
#[error("Database query failed")]
struct DatabaseError {
    query: String,
    #[source]
    io_error: std::io::Error,
}
 
fn main() {
    let err = UserLoadError {
        user_id: 42,
        db_error: DatabaseError {
            query: "SELECT * FROM users".to_string(),
            io_error: std::io::Error::new(
                std::io::ErrorKind::TimedOut,
                "query timeout"
            ),
        },
    };
    
    // Display shows the top-level message
    println!("Display: {}", err);
    // "Failed to load user 42"
    
    // source() shows the underlying cause
    println!("Source: {}", err.source().unwrap());
    // "Database query failed"
    
    // Chain walks deeper
    println!("Deep source: {}", err.source().unwrap().source().unwrap());
    // "query timeout"
}

Display shows the immediate error; source() enables traversing to root causes.

API Summary

use thiserror::Error;
use std::error::Error;
 
// Basic source
#[derive(Error, Debug)]
#[error("Error occurred")]
struct BasicError {
    #[source]
    inner: std::io::Error,
}
 
// Automatic source by name
#[derive(Error, Debug)]
#[error("Error occurred")]
struct AutoSourceError {
    source: std::io::Error,  // Automatically detected
}
 
// Optional source
#[derive(Error, Debug)]
#[error("Error occurred")]
struct OptionalSourceError {
    #[source]
    inner: Option<std::io::Error>,
}
 
// Transparent forwarding
#[derive(Error, Debug)]
#[error(transparent)]
struct TransparentError(#[source] std::io::Error);
 
// Use in code
fn main() {
    let err = BasicError {
        inner: std::io::Error::new(std::io::ErrorKind::Other, "test"),
    };
    
    // Access via Error trait
    let err: &dyn Error = &err;
    
    // Walk the chain
    if let Some(source) = err.source() {
        println!("Caused by: {}", source);
    }
}

Synthesis

How thiserror implements source:

Attribute Effect
#[source] on field Implements source() to return that field
Field named source Automatically treated as source
#[error(transparent)] Forwards both Display and source
No source attribute source() returns None

Error chain inspection pattern:

fn print_chain(err: &dyn Error) {
    println!("Error: {}", err);
    
    let mut current = err.source();
    while let Some(cause) = current {
        println!("Caused by: {}", cause);
        current = cause.source();
    }
}

Benefits of source chains:

Benefit Explanation
Root cause analysis Walk from surface error to root cause
Context preservation Each layer adds meaningful context
Debugging Tools can display full error history
Logging Chain provides complete failure picture

Key insight: thiserror::Error automates implementation of std::error::Error::source() by inspecting fields marked with #[source] or named source, generating code that returns a reference to the underlying error. This enables standard error chain traversal: starting from any error, repeatedly calling source() walks down the causal chain to the root cause. The chain provides context for debugging: each error in the chain can represent a different abstraction layer (network → protocol → application → user-facing), with source() connecting them. The #[error(transparent)] attribute creates a passthrough error that forwards both Display (error message) and source, useful for wrapping external errors without adding new context. Error chains integrate with the ecosystem: anyhow preserves chains when adding context, logging frameworks can walk chains to provide complete error output, and test assertions can verify proper error wrapping. The pattern of wrapping errors with additional context at each layer—while preserving the source chain—creates self-documenting failure paths that explain not just what failed, but the complete sequence of events leading to the failure.