1005 — Error Chaining
Tutorial
The Problem
Add context to errors as they propagate up the call stack. When a low-level read_file returns Err(IoError::NotFound), the higher-level load_config wraps it in AppError { context: "loading /path", source: e }. Implement a WithContext extension trait for ergonomic chaining. Compare with OCaml's manual wrapping pattern.
🎯 Learning Outcomes
.map_err(|e| AppError { context: "…", source: e }) to add context to errorsWithContext<T> trait with fn with_context(self, ctx: impl FnOnce() -> String)impl FnOnce() -> String for context to avoid allocation on success pathsstd::error::Error::source to expose the original error for chain inspectionmap_err to OCaml's match … Error e -> Error { context; cause = e }anyhow::Context pattern as the production implementation of this ideaCode Example
#![allow(clippy::all)]
// 1005: Error Chaining
// Chain errors with context using map_err
use std::fmt;
#[derive(Debug)]
enum IoError {
NotFound,
Corrupted(String),
}
impl fmt::Display for IoError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
IoError::NotFound => write!(f, "not found"),
IoError::Corrupted(s) => write!(f, "corrupted: {}", s),
}
}
}
#[derive(Debug)]
struct AppError {
context: String,
source: IoError,
}
impl fmt::Display for AppError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}: {}", self.context, self.source)
}
}
impl std::error::Error for AppError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
None // IoError doesn't impl Error in this example for simplicity
}
}
// Low-level function returning raw error
fn read_file(path: &str) -> Result<String, IoError> {
match path {
"/missing" => Err(IoError::NotFound),
"/bad" => Err(IoError::Corrupted("invalid utf-8".into())),
_ => Ok("data".into()),
}
}
// Approach 1: map_err to add context
fn load_config(path: &str) -> Result<String, AppError> {
read_file(path).map_err(|e| AppError {
context: format!("loading {}", path),
source: e,
})
}
// Approach 2: Generic context extension trait
trait WithContext<T> {
fn with_context(self, ctx: impl FnOnce() -> String) -> Result<T, AppError>;
}
impl<T> WithContext<T> for Result<T, IoError> {
fn with_context(self, ctx: impl FnOnce() -> String) -> Result<T, AppError> {
self.map_err(|e| AppError {
context: ctx(),
source: e,
})
}
}
fn load_config_ext(path: &str) -> Result<String, AppError> {
read_file(path).with_context(|| format!("loading {}", path))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_success() {
assert_eq!(load_config("/ok").unwrap(), "data");
}
#[test]
fn test_map_err_context() {
let err = load_config("/missing").unwrap_err();
assert_eq!(err.context, "loading /missing");
assert!(matches!(err.source, IoError::NotFound));
}
#[test]
fn test_corrupted_context() {
let err = load_config("/bad").unwrap_err();
assert!(err.to_string().contains("corrupted"));
assert!(err.to_string().contains("loading /bad"));
}
#[test]
fn test_extension_trait() {
assert_eq!(load_config_ext("/ok").unwrap(), "data");
let err = load_config_ext("/missing").unwrap_err();
assert_eq!(err.context, "loading /missing");
}
#[test]
fn test_display_format() {
let err = load_config("/missing").unwrap_err();
assert_eq!(err.to_string(), "loading /missing: not found");
}
}Key Differences
| Aspect | Rust | OCaml |
|---|---|---|
| Add context | .map_err(\|e\| AppError { context, source: e }) | Result.map_error (fun cause -> { context; cause }) |
| Lazy context | impl FnOnce() -> String | Manual if error { sprintf … } |
| Extension trait | impl WithContext<T> for Result<T, IoError> | Function with_context |
| Error chain | Error::source() → inner error | Manual e.cause field access |
| Production lib | anyhow::Context | Result.error_to_exn or custom |
| Verbosity | Medium | Low |
Error chaining is how production Rust code provides actionable error messages: "failed to start server: failed to read config: file not found: /etc/app.toml". Each layer adds context. The anyhow crate automates this pattern; understanding the manual version clarifies the underlying mechanics.
OCaml Approach
OCaml's load_with_context path matches on read_file path: on Error e, it returns Error { context = …; cause = e }. There is no lazy context — Printf.sprintf is always called. The with_context helper can be defined as let with_context ctx = Result.map_error (fun cause -> { context = ctx; cause }). This is the same pattern as Rust's map_err, just without trait integration.
Full Source
#![allow(clippy::all)]
// 1005: Error Chaining
// Chain errors with context using map_err
use std::fmt;
#[derive(Debug)]
enum IoError {
NotFound,
Corrupted(String),
}
impl fmt::Display for IoError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
IoError::NotFound => write!(f, "not found"),
IoError::Corrupted(s) => write!(f, "corrupted: {}", s),
}
}
}
#[derive(Debug)]
struct AppError {
context: String,
source: IoError,
}
impl fmt::Display for AppError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}: {}", self.context, self.source)
}
}
impl std::error::Error for AppError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
None // IoError doesn't impl Error in this example for simplicity
}
}
// Low-level function returning raw error
fn read_file(path: &str) -> Result<String, IoError> {
match path {
"/missing" => Err(IoError::NotFound),
"/bad" => Err(IoError::Corrupted("invalid utf-8".into())),
_ => Ok("data".into()),
}
}
// Approach 1: map_err to add context
fn load_config(path: &str) -> Result<String, AppError> {
read_file(path).map_err(|e| AppError {
context: format!("loading {}", path),
source: e,
})
}
// Approach 2: Generic context extension trait
trait WithContext<T> {
fn with_context(self, ctx: impl FnOnce() -> String) -> Result<T, AppError>;
}
impl<T> WithContext<T> for Result<T, IoError> {
fn with_context(self, ctx: impl FnOnce() -> String) -> Result<T, AppError> {
self.map_err(|e| AppError {
context: ctx(),
source: e,
})
}
}
fn load_config_ext(path: &str) -> Result<String, AppError> {
read_file(path).with_context(|| format!("loading {}", path))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_success() {
assert_eq!(load_config("/ok").unwrap(), "data");
}
#[test]
fn test_map_err_context() {
let err = load_config("/missing").unwrap_err();
assert_eq!(err.context, "loading /missing");
assert!(matches!(err.source, IoError::NotFound));
}
#[test]
fn test_corrupted_context() {
let err = load_config("/bad").unwrap_err();
assert!(err.to_string().contains("corrupted"));
assert!(err.to_string().contains("loading /bad"));
}
#[test]
fn test_extension_trait() {
assert_eq!(load_config_ext("/ok").unwrap(), "data");
let err = load_config_ext("/missing").unwrap_err();
assert_eq!(err.context, "loading /missing");
}
#[test]
fn test_display_format() {
let err = load_config("/missing").unwrap_err();
assert_eq!(err.to_string(), "loading /missing: not found");
}
}#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_success() {
assert_eq!(load_config("/ok").unwrap(), "data");
}
#[test]
fn test_map_err_context() {
let err = load_config("/missing").unwrap_err();
assert_eq!(err.context, "loading /missing");
assert!(matches!(err.source, IoError::NotFound));
}
#[test]
fn test_corrupted_context() {
let err = load_config("/bad").unwrap_err();
assert!(err.to_string().contains("corrupted"));
assert!(err.to_string().contains("loading /bad"));
}
#[test]
fn test_extension_trait() {
assert_eq!(load_config_ext("/ok").unwrap(), "data");
let err = load_config_ext("/missing").unwrap_err();
assert_eq!(err.context, "loading /missing");
}
#[test]
fn test_display_format() {
let err = load_config("/missing").unwrap_err();
assert_eq!(err.to_string(), "loading /missing: not found");
}
}
Deep Comparison
Error Chaining — Comparison
Core Insight
Both languages can wrap errors with context, but Rust's map_err and extension traits make it idiomatic and composable at the type level.
OCaml Approach
with_context helpers that pattern-match on ResultRust Approach
map_err(|e| ...) transforms errors inline during propagationWithContext mimic what anyhow providesError::source() method creates a standard chainanyhow and thiserror standardize itComparison Table
| Aspect | OCaml | Rust |
|---|---|---|
| Context addition | Manual record wrapping | map_err / extension trait |
| Inline ergonomics | Verbose match | Fluent .map_err(...) |
| Error chain | Custom nesting | Error::source() standard |
| Ecosystem support | Ad-hoc | anyhow, thiserror crates |
| Lazy context | Closure in helper | FnOnce in extension trait |
Exercises
wrap_io_err(op: &str) -> impl FnOnce(IoError) -> AppError factory function for reusable context strings.Error::source for AppError by making IoError also implement std::error::Error.print_error_chain(e: &dyn Error) function that traverses the .source() chain and prints each level.anyhow crate's .context("…") method and compare it with the manual WithContext trait.chain_error functor that adds context to any ('a, 'b) result error type, parameterised over the error type.