Advanced Rust Design Patterns
Error-Handling Patterns
Production Rust codebases use two complementary error-handling patterns depending on whether the caller needs to programmatically match and recover from specific failure modes (Library pattern) or propagate, enrich with context, and log errors for human operators (Application pattern).
Library Crates (`src/lib.rs`):
Structured, Exhaustive Error Enums (`#[non_exhaustive] enum StorageError`)
- Implements `core::error::Error + Display + Debug + Send + Sync + 'static`
- Chains underlying cause via `Error::source(&self)`
- Callers can `match` on specific variants (`Error::RateLimited { retry_after }`)!
Application Crates (`src/main.rs` / HTTP Handlers / CLI):
Contextual Error Chains (`with_context(|| format!("failed to load {path}"))`)
- Wraps heterogeneous underlying errors with high-level operational context!
Comparing Library vs Application Error Design
| Dimension | Library Error Pattern (thiserror style) |
Application Error Pattern (anyhow / eyre style) |
|---|---|---|
| Type Representation | Concrete enum (or opaque struct with ErrorKind method) |
Type-erased Box<dyn Error + Send + Sync + 'static> with context stack |
Caller Recovery (match) |
First-class (Callers match on variants/fields without string parsing) | Via runtime .downcast_ref::<ConcreteError>() if needed |
| Adding Call-Site Context | Explicit context fields on enum variants (e.g., Io { path: PathBuf, source: io::Error }) |
Ergonomic .context("...") / .with_context(|| ...) extension trait on Result |
Implementing a Rich Library Error Type with Error::source() and Context Extension
Adding a bare From<io::Error> implementation that discards which file path failed (IoError(io::Error)) produces frustrating "No such file or directory (os error 2)" messages without telling the user which file was missing!
Pairing structured error variants with a lightweight ResultExt context trait attaches operation context lazily only on the Err path:
use std::error::Error;
use std::fmt;
use std::path::{Path, PathBuf};
#[non_exhaustive]
#[derive(Debug)]
pub enum ConfigError {
ReadFile {
path: PathBuf,
source: std::io::Error,
},
MissingRequiredKey {
key: &'static str,
},
}
impl fmt::Display for ConfigError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
ConfigError::ReadFile { path, .. } => {
write!(f, "failed to read configuration file '{}'", path.display())
}
ConfigError::MissingRequiredKey { key } => {
write!(f, "missing required configuration key '{key}'")
}
}
}
}
impl Error for ConfigError {
fn source(&self) -> Option<&(dyn Error + 'static)> {
match self {
ConfigError::ReadFile { source, .. } => Some(source),
ConfigError::MissingRequiredKey { .. } => None,
}
}
}
/// Extension trait for attaching a file path to an `io::Result<T>` lazily on error.
pub trait IoResultExt<T> {
fn with_path_context(self, path: impl AsRef<Path>) -> Result<T, ConfigError>;
}
impl<T> IoResultExt<T> for std::io::Result<T> {
fn with_path_context(self, path: impl AsRef<Path>) -> Result<T, ConfigError> {
self.map_err(|source| ConfigError::ReadFile {
path: path.as_ref().to_path_buf(),
source,
})
}
}
/// Walks and formats the entire `Error::source()` causal chain for logging.
pub fn format_error_chain(err: &dyn Error) -> String {
let mut chain = err.to_string();
let mut current = err.source();
while let Some(cause) = current {
chain.push_str(": caused by: ");
chain.push_str(&cause.to_string());
current = cause.source();
}
chain
}
fn main() {
let missing_path = Path::new("/nonexistent/etc/service.toml");
let result = std::fs::read_to_string(missing_path).with_path_context(missing_path);
let err = result.unwrap_err();
let full_report = format_error_chain(&err);
assert!(full_report.contains("/nonexistent/etc/service.toml"));
assert!(full_report.contains("caused by:"));
println!("Formatted error chain:\n {full_report}");
}
Formatted error chain:
failed to read configuration file '/nonexistent/etc/service.toml': caused by: No such file or directory (os error 2)