299: Adding Context to Errors
Tutorial Video
Text description (accessibility)
This video demonstrates the "299: Adding Context to Errors" functional Rust example. Difficulty level: Intermediate. Key concepts covered: Functional Programming. Bare error messages like "file not found" or "parse failed" are unhelpful without context about what was being attempted. Key difference from OCaml: 1. **Structured vs string**: Rust's `Context<E>` preserves the original error as a typed value accessible via `source()`; OCaml typically flattens context into a combined error string.
Tutorial
The Problem
Bare error messages like "file not found" or "parse failed" are unhelpful without context about what was being attempted. Context wrapping adds layers of "where" and "why" information around errors: "while loading config: while reading /etc/app.conf: file not found". This is the anyhow::context() pattern — each operation wraps a lower-level error in a higher-level description, building an error chain that reads as a call-stack narrative.
🎯 Learning Outcomes
Context<E> wrapper that adds a message to any errorsource() to expose the wrapped error for chain traversalCode Example
#[derive(Debug)]
struct Context<E> {
message: String,
source: E,
}
impl<E: Error + 'static> Error for Context<E> {
fn source(&self) -> Option<&(dyn Error + 'static)> {
Some(&self.source)
}
}
// Usage
read_file(path).context("loading config")Key Differences
Context<E> preserves the original error as a typed value accessible via source(); OCaml typically flattens context into a combined error string.source() chain enables iterating through all context layers programmatically; OCaml's string approach loses structure.Context<ParseError> preserves the exact error type; Box<dyn Error> in anyhow erases it for flexibility.anyhow::Context trait provides .context("msg") and .with_context(|| msg) as ergonomic extension methods — the idiomatic production approach.OCaml Approach
OCaml's Result.map_error can wrap errors with context strings, but there is no standard chaining mechanism. Libraries like Error_monad provide dedicated context operations:
let with_context msg = Result.map_error (fun e ->
Printf.sprintf "%s: %s" msg (string_of_error e))
This flattens the chain into a single string rather than preserving the original error as a structured value.
Full Source
#![allow(clippy::all)]
//! # Adding Context to Errors
//!
//! Context wrapping adds layers of "where/why" around errors via the `source()` chain.
use std::error::Error;
use std::fmt;
/// Generic context wrapper
#[derive(Debug)]
pub struct Context<E> {
pub message: String,
pub source: E,
}
impl<E: fmt::Display> fmt::Display for Context<E> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.message)
}
}
impl<E: Error + 'static> Error for Context<E> {
fn source(&self) -> Option<&(dyn Error + 'static)> {
Some(&self.source)
}
}
/// Extension trait to add context to any Result
pub trait WithContext<T, E> {
fn with_context<F: FnOnce() -> String>(self, f: F) -> Result<T, Context<E>>;
fn context(self, msg: &str) -> Result<T, Context<E>>;
}
impl<T, E: Error> WithContext<T, E> for Result<T, E> {
fn with_context<F: FnOnce() -> String>(self, f: F) -> Result<T, Context<E>> {
self.map_err(|e| Context {
message: f(),
source: e,
})
}
fn context(self, msg: &str) -> Result<T, Context<E>> {
self.with_context(|| msg.to_string())
}
}
/// Simple IO error for demonstration
#[derive(Debug)]
pub struct IoError(pub String);
impl fmt::Display for IoError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
}
}
impl Error for IoError {}
/// Simulate reading a file
pub fn read_file(path: &str) -> Result<String, IoError> {
if path.ends_with(".missing") {
Err(IoError(format!("{}: not found", path)))
} else {
Ok(format!("contents of {}", path))
}
}
/// Load config with context
pub fn load_config(path: &str) -> Result<String, Context<IoError>> {
read_file(path).context(&format!("loading config from '{}'", path))
}
/// Print full error chain
pub fn format_error_chain(e: &dyn Error) -> String {
let mut result = format!("Error: {}", e);
let mut cause = e.source();
while let Some(c) = cause {
result.push_str(&format!("\n Caused by: {}", c));
cause = c.source();
}
result
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_context_ok() {
let result = read_file("test.toml").context("reading test file");
assert!(result.is_ok());
}
#[test]
fn test_context_preserves_source() {
let result = load_config("x.missing");
assert!(result.is_err());
let e = result.unwrap_err();
assert!(e.source().is_some());
}
#[test]
fn test_context_message() {
let result: Result<(), IoError> = Err(IoError("fail".to_string()));
let ctx = result.context("doing something");
let e = ctx.unwrap_err();
assert!(format!("{}", e).contains("doing something"));
}
#[test]
fn test_with_context_lazy() {
let mut called = false;
let result: Result<i32, IoError> = Ok(42);
let _ = result.with_context(|| {
called = true;
"should not be called".to_string()
});
assert!(!called); // closure not called on Ok
}
#[test]
fn test_error_chain_format() {
let result = load_config("app.missing");
let e = result.unwrap_err();
let chain = format_error_chain(&e);
assert!(chain.contains("loading config"));
assert!(chain.contains("Caused by"));
}
}#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_context_ok() {
let result = read_file("test.toml").context("reading test file");
assert!(result.is_ok());
}
#[test]
fn test_context_preserves_source() {
let result = load_config("x.missing");
assert!(result.is_err());
let e = result.unwrap_err();
assert!(e.source().is_some());
}
#[test]
fn test_context_message() {
let result: Result<(), IoError> = Err(IoError("fail".to_string()));
let ctx = result.context("doing something");
let e = ctx.unwrap_err();
assert!(format!("{}", e).contains("doing something"));
}
#[test]
fn test_with_context_lazy() {
let mut called = false;
let result: Result<i32, IoError> = Ok(42);
let _ = result.with_context(|| {
called = true;
"should not be called".to_string()
});
assert!(!called); // closure not called on Ok
}
#[test]
fn test_error_chain_format() {
let result = load_config("app.missing");
let e = result.unwrap_err();
let chain = format_error_chain(&e);
assert!(chain.contains("loading config"));
assert!(chain.contains("Caused by"));
}
}
Deep Comparison
OCaml vs Rust: Error Context
Pattern: Context Wrapper
Rust
#[derive(Debug)]
struct Context<E> {
message: String,
source: E,
}
impl<E: Error + 'static> Error for Context<E> {
fn source(&self) -> Option<&(dyn Error + 'static)> {
Some(&self.source)
}
}
// Usage
read_file(path).context("loading config")
OCaml
type 'e context = { message: string; source: 'e }
let with_context msg result =
match result with
| Ok v -> Ok v
| Error e -> Error { message = msg; source = e }
Pattern: Error Chain Traversal
Rust
let mut cause = e.source();
while let Some(c) = cause {
println!(" Caused by: {}", c);
cause = c.source();
}
OCaml
let rec print_chain = function
| { message; source = None } -> Printf.printf "%s\n" message
| { message; source = Some e } ->
Printf.printf "%s\n Caused by: " message;
print_chain e
Key Differences
| Concept | OCaml | Rust |
|---|---|---|
| Wrapping | Manual tuple/record | Struct with Error::source() |
| Chain traversal | Manual recursion | Standard source() linked list |
| Extension method | N/A | .context() trait method |
| Display vs cause | Combined | Separate concerns |
Exercises
ResultExt trait with .context(msg) and .with_context(|| msg) methods on any Result<T, E: Error>.Context<E> chain with a flat string-concatenated approach for the same error scenario.