How does thiserror::Error::source implementation enable error chain inspection?
The std::error::Error::source method provides a standardized way to access the underlying cause of an error, enabling error chain traversal for debugging, logging, and error reporting. When thiserror derives the Error trait, it automatically implements source based on any field annotated with #[source] or fields named source. This creates a linked list of errors where each error points to its immediate cause, allowing tools and libraries to walk the entire chain from a top-level error down to the root cause. Unlike simple error messages that lose context, source chains preserve the complete causal relationship between errors.
Basic Error Source
use std::error::Error;
use std::fmt;
#[derive(Debug)]
struct DatabaseError {
message: String,
source: io::Error,
}
impl fmt::Display for DatabaseError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "Database error: {}", self.message)
}
}
impl Error for DatabaseError {
fn source(&self) -> Option<&(dyn Error + 'static)> {
Some(&self.source)
}
}
fn inspect_source() {
let io_err = io::Error::new(io::ErrorKind::ConnectionRefused, "connection refused");
let db_err = DatabaseError {
message: "Failed to connect to database".to_string(),
source: io_err,
};
// Access the source
if let Some(source) = db_err.source() {
println!("Caused by: {}", source);
}
}The source() method returns the underlying error, enabling chain traversal.
thiserror Source Annotation
use thiserror::Error;
#[derive(Error, Debug)]
pub enum AppError {
#[error("Database operation failed")]
Database {
#[source]
inner: std::io::Error,
},
#[error("Configuration error: {0}")]
Config(String),
#[error("Network error")]
Network {
#[source]
inner: reqwest::Error,
},
}
// thiserror automatically implements Error::source for variants
// with #[source] annotated fields#[source] tells thiserror which field contains the underlying error.
Automatic Source Detection
use thiserror::Error;
// thiserror automatically detects fields named "source"
#[derive(Error, Debug)]
pub enum DataStoreError {
#[error("IO error: {0}")]
Io(#[source] std::io::Error),
#[error("Connection error")]
Connection {
source: std::io::Error, // Automatically detected
},
#[error("Query failed")]
Query {
#[source]
inner: sql::Error, // Explicitly annotated
},
#[error("Not found: {0}")]
NotFound(String), // No source - returns None
}
// For Io, Connection, and Query variants, source() returns the inner error
// For NotFound, source() returns NoneFields named source are automatically used; #[source] overrides this for other names.
Walking the Error Chain
use std::error::Error;
fn print_error_chain(err: &dyn Error) {
let mut level = 0;
let mut current = Some(err);
while let Some(error) = current {
println!("{}: {}", level, error);
level += 1;
current = error.source();
}
}
fn example_chain() {
use thiserror::Error;
#[derive(Error, Debug)]
#[error("Root cause")]
struct RootError;
#[derive(Error, Debug)]
#[error("Middle layer")]
struct MiddleError {
#[source]
inner: RootError,
}
#[derive(Error, Debug)]
#[error("Top level")]
struct TopError {
#[source]
inner: MiddleError,
}
let error = TopError { inner: MiddleError { inner: RootError } };
print_error_chain(&error);
// Output:
// 0: Top level
// 1: Middle layer
// 2: Root cause
}Walking the chain reveals all error layers from top to bottom.
Error Chain for Debugging
use std::error::Error;
use thiserror::Error;
#[derive(Error, Debug)]
pub enum ServiceError {
#[error("User service failed")]
UserService {
#[source]
inner: DatabaseError,
},
}
#[derive(Error, Debug)]
pub enum DatabaseError {
#[error("Connection pool exhausted")]
PoolExhausted {
#[source]
inner: TimeoutError,
},
}
#[derive(Error, Debug)]
#[error("Operation timed out after {ms}ms")]
struct TimeoutError {
ms: u64,
}
fn debug_chain_example() {
let timeout = TimeoutError { ms: 5000 };
let db_error = DatabaseError::PoolExhausted { inner: timeout };
let service_error = ServiceError::UserService { inner: db_error };
// Walk chain for debugging
let mut chain: Vec<String> = Vec::new();
let mut current: Option<&dyn Error> = Some(&service_error);
while let Some(err) = current {
chain.push(err.to_string());
current = err.source();
}
assert_eq!(chain, vec![
"User service failed",
"Connection pool exhausted",
"Operation timed out after 5000ms",
]);
}The complete error context is preserved through the chain.
Optional Source
use thiserror::Error;
#[derive(Error, Debug)]
pub enum ConfigError {
#[error("File not found: {path}")]
FileNotFound {
path: String,
// No source - returns None
},
#[error("Invalid configuration")]
Invalid {
#[source]
parse_error: serde_json::Error,
},
#[error("Permission denied")]
Permission {
#[source]
io_error: Option<std::io::Error>,
},
}
fn optional_source() {
let error1 = ConfigError::FileNotFound { path: "/etc/config".into() };
assert!(error1.source().is_none());
// error.source() returns None for FileNotFound variant
}Not all error variants need a source; those without return None.
Multiple Error Types
use thiserror::Error;
use std::io;
#[derive(Error, Debug)]
pub enum AppError {
#[error("IO failed")]
Io {
#[source]
inner: io::Error,
},
#[error("JSON parsing failed")]
Json {
#[source]
inner: serde_json::Error,
},
#[error("HTTP request failed")]
Http {
#[source]
inner: reqwest::Error,
},
}
fn inspect_typed_source() {
let io_err = io::Error::new(io::ErrorKind::NotFound, "file not found");
let app_err = AppError::Io { inner: io_err };
// source() returns &dyn Error
let source: Option<&(dyn Error + 'static)> = app_err.source();
// Can downcast to concrete type if needed
if let Some(err) = source.and_then(|e| e.downcast_ref::<io::Error>()) {
println!("IO error kind: {:?}", err.kind());
}
}The source is returned as &dyn Error, requiring downcast for type-specific access.
Error Reporting with Sources
use std::error::Error;
use std::fmt;
fn format_error_report(err: &dyn Error) -> String {
let mut report = format!("Error: {}", err);
let mut current = err.source();
let mut level = 1;
while let Some(source) = current {
report.push_str(&format!("\n {}. {}", level, source));
level += 1;
current = source.source();
}
report
}
fn error_report_example() {
#[derive(Error, Debug)]
#[error("Application startup failed")]
struct StartupError {
#[source]
inner: ConfigError,
}
#[derive(Error, Debug)]
#[error("Invalid configuration")]
struct ConfigError {
#[source]
inner: ParseError,
}
#[derive(Error, Debug)]
#[error("Syntax error at line {line}")]
struct ParseError {
line: usize,
}
let error = StartupError {
inner: ConfigError {
inner: ParseError { line: 42 },
},
};
let report = format_error_report(&error);
println!("{}", report);
// Error: Application startup failed
// 1. Invalid configuration
// 2. Syntax error at line 42
}Error reports gain depth when including all sources in the chain.
Comparing with backtrace
use std::error::Error;
use thiserror::Error;
#[derive(Error, Debug)]
#[error("Service error")]
struct ServiceError {
#[source]
inner: DatabaseError,
#[backtrace]
backtrace: std::backtrace::Backtrace,
}
#[derive(Error, Debug)]
#[error("Database error")]
struct DatabaseError {
#[source]
inner: io::Error,
}
fn source_vs_backtrace() {
// source: causal chain (what went wrong)
// backtrace: where it went wrong in code
// source chain: ServiceError -> DatabaseError -> io::Error
// backtrace: shows the call stack where error was created
}source is about causality; backtrace is about code location.
thiserror's Generated Implementation
use thiserror::Error;
// What thiserror generates:
#[derive(Error, Debug)]
#[error("IO error: {0}")]
struct IoError(#[source] std::io::Error);
// Approximately generates:
impl std::error::Error for IoError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
Some(&self.0)
}
}
// For multiple fields, #[source] specifies which one:
#[derive(Error, Debug)]
#[error("Complex error")]
struct ComplexError {
message: String,
#[source]
cause: std::io::Error,
other_data: i32,
}Only one field can be the source; thiserror enforces this.
Source with Error Enums
use thiserror::Error;
#[derive(Error, Debug)]
pub enum NetworkError {
#[error("DNS resolution failed")]
Dns {
#[source]
inner: dns::Error,
},
#[error("TCP connection failed")]
Tcp {
#[source]
inner: std::io::Error,
},
#[error("TLS handshake failed")]
Tls {
#[source]
inner: tls::Error,
},
#[error("Request timeout")]
Timeout {
duration_ms: u64,
// No source for this variant
},
}
fn inspect_enum_source() {
let dns_err = NetworkError::Dns { inner: dns::Error::new("NXDOMAIN") };
// source() returns the inner error
assert!(dns_err.source().is_some());
let timeout_err = NetworkError::Timeout { duration_ms: 5000 };
// source() returns None for Timeout variant
assert!(timeout_err.source().is_none());
}Each enum variant can have its own source implementation.
Chaining with anyhow vs thiserror
use thiserror::Error;
// thiserror: explicit source chain
#[derive(Error, Debug)]
#[error("Service unavailable")]
struct ServiceUnavailableError {
#[source]
inner: ConnectionPoolError,
}
#[derive(Error, Debug)]
#[error("Connection pool exhausted")]
struct ConnectionPoolError {
#[source]
inner: TimeoutError,
}
#[derive(Error, Debug)]
#[error("Connection timed out")]
struct TimeoutError;
// anyhow: implicit context chain
use anyhow::{Context, Result};
fn with_anyhow() -> Result<()> {
let result = std::fs::read_to_string("config.json")
.context("Failed to read configuration")?;
Ok(())
}
// thiserror source is for library errors
// anyhow context is for application error reportingthiserror::source is explicit and type-safe; anyhow::Context adds implicit context.
Library vs Application Errors
use thiserror::Error;
// Library errors: use #[source] for clean error chains
#[derive(Error, Debug)]
pub enum LibraryError {
#[error("Deserialization failed")]
Deserialize {
#[source]
inner: serde_json::Error,
},
#[error("Validation failed: {field}")]
Validation {
field: String,
// No source for validation errors
},
}
// Application code can wrap library errors
#[derive(Error, Debug)]
pub enum AppError {
#[error("Configuration error")]
Config {
#[source]
inner: LibraryError,
},
#[error("Database error")]
Database {
#[source]
inner: sql::Error,
},
}Libraries expose typed errors with sources; applications aggregate them.
Testing Error Chains
use std::error::Error;
use thiserror::Error;
#[derive(Error, Debug)]
#[error("Top error")]
struct TopError {
#[source]
inner: MiddleError,
}
#[derive(Error, Debug)]
#[error("Middle error")]
struct MiddleError {
#[source]
inner: BottomError,
}
#[derive(Error, Debug)]
#[error("Bottom error")]
struct BottomError;
#[test]
fn test_error_chain() {
let bottom = BottomError;
let middle = MiddleError { inner: bottom };
let top = TopError { inner: middle };
// Test source chain
assert!(top.source().is_some());
let middle = top.source().unwrap();
assert!(middle.source().is_some());
let bottom = middle.source().unwrap();
assert!(bottom.source().is_none());
// Test chain depth
fn chain_depth(mut err: &dyn Error) -> usize {
let mut depth = 0;
while let Some(source) = err.source() {
depth += 1;
err = source;
}
depth
}
assert_eq!(chain_depth(&top), 2);
}Error chains can be tested by walking sources programmatically.
Real-World Example: HTTP Client Errors
use thiserror::Error;
use std::error::Error;
#[derive(Error, Debug)]
pub enum HttpClientError {
#[error("DNS resolution failed for {host}")]
DnsResolution {
host: String,
#[source]
inner: dns::Error,
},
#[error("TCP connection failed")]
TcpConnection {
#[source]
inner: std::io::Error,
},
#[error("TLS certificate error")]
TlsCertificate {
#[source]
inner: rustls::Error,
},
#[error("HTTP response error: status {status}")]
HttpResponse {
status: u16,
body: String,
},
#[error("Request timed out after {timeout_ms}ms")]
Timeout {
timeout_ms: u64,
},
}
impl HttpClientError {
pub fn is_retryable(&self) -> bool {
// Examine source chain to determine retryability
match self {
Self::DnsResolution { .. } => true,
Self::TcpConnection { inner } => {
// Check if the IO error is retryable
matches!(inner.kind(), std::io::ErrorKind::ConnectionRefused)
}
Self::TlsCertificate { .. } => false,
Self::HttpResponse { status, .. } => *status >= 500,
Self::Timeout { .. } => true,
}
}
pub fn root_cause(&self) -> &(dyn Error + 'static) {
let mut current = self as &(dyn Error + 'static);
while let Some(source) = current.source() {
current = source;
}
current
}
}HTTP clients need detailed error chains for debugging network issues.
Real-World Example: Database Transaction Errors
use thiserror::Error;
use std::error::Error;
#[derive(Error, Debug)]
pub enum TransactionError {
#[error("Transaction begin failed")]
BeginFailed {
#[source]
inner: ConnectionError,
},
#[error("Query execution failed")]
QueryFailed {
query: String,
#[source]
inner: QueryError,
},
#[error("Commit failed")]
CommitFailed {
#[source]
inner: ConnectionError,
},
#[error("Constraint violation: {constraint}")]
ConstraintViolation {
constraint: String,
},
}
#[derive(Error, Debug)]
pub enum ConnectionError {
#[error("Pool exhausted")]
PoolExhausted,
#[error("Connection lost")]
ConnectionLost {
#[source]
inner: std::io::Error,
},
}
#[derive(Error, Debug)]
pub enum QueryError {
#[error("Syntax error: {message}")]
Syntax { message: String },
#[error("Permission denied")]
Permission {
#[source]
inner: AuthError,
},
}
// Walking the chain gives complete context
fn diagnose_transaction_error(err: &TransactionError) -> String {
let mut diagnosis = err.to_string();
if let Some(source) = err.source() {
diagnosis.push_str(&format!(" caused by {}", source));
if let Some(deeper) = source.source() {
diagnosis.push_str(&format!(" (due to {})", deeper));
}
}
diagnosis
}Database errors often have deep chains revealing the root cause.
Synthesis
Key aspects:
| Aspect | Description |
|---|---|
#[source] attribute |
Marks the field containing the underlying error |
source() method |
Returns Option<&(dyn Error + 'static)> |
| Automatic detection | Fields named source are automatically used |
| Single source | Only one field can be the source per error |
| Optional source | Variants without source return None |
Source chain traversal:
| Operation | Code |
|---|---|
| Get immediate source | err.source() |
| Walk entire chain | while let Some(s) = err.source() { ... } |
| Get root cause | Walk until source() returns None |
| Downcast to type | err.source()?.downcast_ref::<ConcreteError>() |
thiserror vs manual implementation:
| Approach | Code |
|---|---|
| thiserror | #[source] inner: OtherError |
| Manual | fn source(&self) -> Option<&(dyn Error + 'static)> { Some(&self.inner) } |
Key insight: The thiserror::Error::source implementation creates a causality chain through errors, where each error points to its immediate cause. This enables full error context without losing information through message-only wrapping. The #[source] attribute tells thiserror which field contains the underlying error, and the derived code implements std::error::Error::source() to return that field. The source chain can be traversed programmatically for logging, error reports, or selective error handlingâwalking from the top-level error through each source() call until reaching None. For library authors, this pattern provides type-safe error composition: each layer adds context while preserving the original error. For application authors, it enables comprehensive error reporting that shows the complete chain from user-facing error down to root cause. Unlike anyhow::Context which adds implicit context, thiserror::source creates explicit, typed error relationships that are part of the public API.
