1016-error-context — Error Context
Tutorial
The Problem
When an error bubbles up through several layers of a call stack, the raw error message often lacks enough information to diagnose the problem. "file not found" tells you what failed but not why the file was being read or what operation was in progress. Adding context at each propagation point — "loading config -> reading file -> file not found" — produces error messages that pinpoint the root cause without a debugger.
The anyhow crate provides .context("...") and .with_context(|| ...) as an extension trait on Result. This example builds the same mechanism from scratch using a wrapper struct, so the mechanics are transparent.
🎯 Learning Outcomes
ErrorWithContext struct that carries a message and a context chainContext extension trait for Result<T, ErrorWithContext>.context(str) and lazy .with_context(|| str)anyhow::Context generalises this to any error typeCode Example
//! 1016: Error Context — attaching a context chain to errors.
//!
//! The OCaml original defines `error_with_context = { message; context }`
//! and threads context manually through every `match` arm (Approach 1) or
//! via a custom `>>|` operator (Approach 2). In Rust, the `?` operator
//! combined with an extension trait on `Result` collapses both approaches
//! into a single idiom:
//!
//! ```text
//! let content = read_file(path).context("reading config")?;
//! ```
//!
//! The machinery below is the same pattern that the `anyhow` crate ships
//! as `anyhow::Context`. Rolling it by hand shows that the only ingredients
//! are a wrapper struct carrying a context stack and a trait that calls
//! `map_err` at each layer.
use std::fmt;
/// Error carrying a stack of context strings.
///
/// `message` is the root cause. Each call to [`ErrorWithContext::with_context`]
/// pushes a new label onto `context`; [`fmt::Display`] renders the chain
/// outer-to-inner, mirroring OCaml's `display_error` which reverses the list
/// before joining with `" -> "`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ErrorWithContext {
/// The root-cause message.
pub message: String,
/// Context strings pushed from innermost to outermost call site.
pub context: Vec<String>,
}
impl ErrorWithContext {
/// Build a fresh error with no context, like OCaml's `make_error`.
pub fn new(message: impl Into<String>) -> Self {
Self {
message: message.into(),
context: Vec::new(),
}
}
/// Push another layer of context onto the chain, like OCaml's
/// `add_context`. Returns `self` so it chains in builder style.
pub fn with_context(mut self, ctx: impl Into<String>) -> Self {
self.context.push(ctx.into());
self
}
}
impl fmt::Display for ErrorWithContext {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
if self.context.is_empty() {
write!(f, "{}", self.message)
} else {
// Contexts are pushed innermost-first; render outermost-first.
let mut first = true;
for ctx in self.context.iter().rev() {
if !first {
write!(f, " -> ")?;
}
write!(f, "{}", ctx)?;
first = false;
}
write!(f, ": {}", self.message)
}
}
}
impl std::error::Error for ErrorWithContext {}
/// Extension trait that attaches context to any `Result<T, ErrorWithContext>`.
///
/// Replaces OCaml's custom `>>|` operator. The eager `context` variant always
/// allocates the label; the lazy `with_context` variant defers the closure
/// until an error actually occurs, avoiding format overhead on the happy path.
pub trait Context<T> {
/// Attach a fixed context label on error.
fn context(self, ctx: impl Into<String>) -> Result<T, ErrorWithContext>;
/// Attach a context label produced lazily, only if an error occurred.
fn with_context<F, S>(self, f: F) -> Result<T, ErrorWithContext>
where
F: FnOnce() -> S,
S: Into<String>;
}
impl<T> Context<T> for Result<T, ErrorWithContext> {
fn context(self, ctx: impl Into<String>) -> Result<T, ErrorWithContext> {
self.map_err(|e| e.with_context(ctx))
}
fn with_context<F, S>(self, f: F) -> Result<T, ErrorWithContext>
where
F: FnOnce() -> S,
S: Into<String>,
{
self.map_err(|e| e.with_context(f()))
}
}
/// Mock file reader — mirrors OCaml's `read_file`.
pub fn read_file(path: &str) -> Result<String, ErrorWithContext> {
if path == "/missing" {
Err(ErrorWithContext::new("file not found"))
} else {
Ok("42".into())
}
}
/// Parse a config string as an integer — mirrors OCaml's `parse_config`.
pub fn parse_config(content: &str) -> Result<i64, ErrorWithContext> {
content
.parse::<i64>()
.map_err(|_| ErrorWithContext::new("invalid number"))
}
/// Middle layer: read + parse, attaching one context label per step.
///
/// This is the direct Rust analogue of OCaml's `load_setting` (Approach 1)
/// and `load_setting_pipe` (Approach 2) collapsed into a single `?`-based
/// flow.
pub fn load_setting(path: &str) -> Result<i64, ErrorWithContext> {
let content = read_file(path).context("reading config")?;
let value = parse_config(&content).context("parsing config")?;
Ok(value)
}
/// Outer layer that stamps the whole chain with a top-level label.
pub fn init_system(path: &str) -> Result<i64, ErrorWithContext> {
load_setting(path).context("system init")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn display_without_context_shows_just_message() {
let err = ErrorWithContext::new("oops");
assert_eq!(err.to_string(), "oops");
}
#[test]
fn display_with_single_context_prefixes_message() {
let err = ErrorWithContext::new("oops").with_context("loading");
assert_eq!(err.to_string(), "loading: oops");
}
#[test]
fn nested_context_renders_outer_to_inner() {
let err = init_system("/missing").unwrap_err();
assert_eq!(err.context.len(), 2);
assert_eq!(
err.to_string(),
"system init -> reading config: file not found"
);
}
#[test]
fn success_passes_through_every_layer() {
assert_eq!(init_system("/ok").unwrap(), 42);
}
#[test]
fn context_trait_chains_across_map_err() {
let result: Result<i64, ErrorWithContext> = Err(ErrorWithContext::new("base"));
let result = result
.context("layer1")
.map_err(|e| e.with_context("layer2"));
let err = result.unwrap_err();
assert_eq!(
err.context,
vec!["layer1".to_string(), "layer2".to_string()]
);
assert_eq!(err.to_string(), "layer2 -> layer1: base");
}
#[test]
fn with_context_is_lazy_and_supports_formatting() {
let result: Result<i64, ErrorWithContext> = Err(ErrorWithContext::new("base"));
let err = result
.with_context(|| format!("dynamic context {}", 42))
.unwrap_err();
assert!(err.to_string().contains("dynamic context 42"));
}
#[test]
fn with_context_closure_not_called_on_ok() {
let mut called = false;
let result: Result<i64, ErrorWithContext> = Ok(7);
let _ = result.with_context(|| {
called = true;
"unused"
});
assert!(!called);
}
#[test]
fn parse_config_rejects_non_numeric() {
let err = parse_config("not a number").unwrap_err();
assert_eq!(err.message, "invalid number");
}
}Key Differences
with_context takes a closure to avoid string formatting when no error occurs; OCaml's Error.tag is lazy by default via Info.t.Context trait is implemented on Result<T, E> using a blanket impl; OCaml uses module functions.Vec and reversing on display; OCaml's Error tree is structured differently but renders similarly.anyhow vs custom**: Production Rust uses anyhow::Context which works with any error type via Box<dyn Error>; OCaml's Or_error is the equivalent standard library choice.OCaml Approach
OCaml's Base.Error type carries a lazy tree of error messages. The error_s and tag functions annotate errors with context:
let with_context label f =
match f () with
| Ok v -> Ok v
| Error e -> Error (Error.tag e ~tag:label)
Libraries like Core_kernel provide Or_error.tag for exactly this pattern. Unlike Rust's struct approach, OCaml's Error is a lazy Info.t tree that is only rendered when displayed.
Full Source
//! 1016: Error Context — attaching a context chain to errors.
//!
//! The OCaml original defines `error_with_context = { message; context }`
//! and threads context manually through every `match` arm (Approach 1) or
//! via a custom `>>|` operator (Approach 2). In Rust, the `?` operator
//! combined with an extension trait on `Result` collapses both approaches
//! into a single idiom:
//!
//! ```text
//! let content = read_file(path).context("reading config")?;
//! ```
//!
//! The machinery below is the same pattern that the `anyhow` crate ships
//! as `anyhow::Context`. Rolling it by hand shows that the only ingredients
//! are a wrapper struct carrying a context stack and a trait that calls
//! `map_err` at each layer.
use std::fmt;
/// Error carrying a stack of context strings.
///
/// `message` is the root cause. Each call to [`ErrorWithContext::with_context`]
/// pushes a new label onto `context`; [`fmt::Display`] renders the chain
/// outer-to-inner, mirroring OCaml's `display_error` which reverses the list
/// before joining with `" -> "`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ErrorWithContext {
/// The root-cause message.
pub message: String,
/// Context strings pushed from innermost to outermost call site.
pub context: Vec<String>,
}
impl ErrorWithContext {
/// Build a fresh error with no context, like OCaml's `make_error`.
pub fn new(message: impl Into<String>) -> Self {
Self {
message: message.into(),
context: Vec::new(),
}
}
/// Push another layer of context onto the chain, like OCaml's
/// `add_context`. Returns `self` so it chains in builder style.
pub fn with_context(mut self, ctx: impl Into<String>) -> Self {
self.context.push(ctx.into());
self
}
}
impl fmt::Display for ErrorWithContext {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
if self.context.is_empty() {
write!(f, "{}", self.message)
} else {
// Contexts are pushed innermost-first; render outermost-first.
let mut first = true;
for ctx in self.context.iter().rev() {
if !first {
write!(f, " -> ")?;
}
write!(f, "{}", ctx)?;
first = false;
}
write!(f, ": {}", self.message)
}
}
}
impl std::error::Error for ErrorWithContext {}
/// Extension trait that attaches context to any `Result<T, ErrorWithContext>`.
///
/// Replaces OCaml's custom `>>|` operator. The eager `context` variant always
/// allocates the label; the lazy `with_context` variant defers the closure
/// until an error actually occurs, avoiding format overhead on the happy path.
pub trait Context<T> {
/// Attach a fixed context label on error.
fn context(self, ctx: impl Into<String>) -> Result<T, ErrorWithContext>;
/// Attach a context label produced lazily, only if an error occurred.
fn with_context<F, S>(self, f: F) -> Result<T, ErrorWithContext>
where
F: FnOnce() -> S,
S: Into<String>;
}
impl<T> Context<T> for Result<T, ErrorWithContext> {
fn context(self, ctx: impl Into<String>) -> Result<T, ErrorWithContext> {
self.map_err(|e| e.with_context(ctx))
}
fn with_context<F, S>(self, f: F) -> Result<T, ErrorWithContext>
where
F: FnOnce() -> S,
S: Into<String>,
{
self.map_err(|e| e.with_context(f()))
}
}
/// Mock file reader — mirrors OCaml's `read_file`.
pub fn read_file(path: &str) -> Result<String, ErrorWithContext> {
if path == "/missing" {
Err(ErrorWithContext::new("file not found"))
} else {
Ok("42".into())
}
}
/// Parse a config string as an integer — mirrors OCaml's `parse_config`.
pub fn parse_config(content: &str) -> Result<i64, ErrorWithContext> {
content
.parse::<i64>()
.map_err(|_| ErrorWithContext::new("invalid number"))
}
/// Middle layer: read + parse, attaching one context label per step.
///
/// This is the direct Rust analogue of OCaml's `load_setting` (Approach 1)
/// and `load_setting_pipe` (Approach 2) collapsed into a single `?`-based
/// flow.
pub fn load_setting(path: &str) -> Result<i64, ErrorWithContext> {
let content = read_file(path).context("reading config")?;
let value = parse_config(&content).context("parsing config")?;
Ok(value)
}
/// Outer layer that stamps the whole chain with a top-level label.
pub fn init_system(path: &str) -> Result<i64, ErrorWithContext> {
load_setting(path).context("system init")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn display_without_context_shows_just_message() {
let err = ErrorWithContext::new("oops");
assert_eq!(err.to_string(), "oops");
}
#[test]
fn display_with_single_context_prefixes_message() {
let err = ErrorWithContext::new("oops").with_context("loading");
assert_eq!(err.to_string(), "loading: oops");
}
#[test]
fn nested_context_renders_outer_to_inner() {
let err = init_system("/missing").unwrap_err();
assert_eq!(err.context.len(), 2);
assert_eq!(
err.to_string(),
"system init -> reading config: file not found"
);
}
#[test]
fn success_passes_through_every_layer() {
assert_eq!(init_system("/ok").unwrap(), 42);
}
#[test]
fn context_trait_chains_across_map_err() {
let result: Result<i64, ErrorWithContext> = Err(ErrorWithContext::new("base"));
let result = result
.context("layer1")
.map_err(|e| e.with_context("layer2"));
let err = result.unwrap_err();
assert_eq!(
err.context,
vec!["layer1".to_string(), "layer2".to_string()]
);
assert_eq!(err.to_string(), "layer2 -> layer1: base");
}
#[test]
fn with_context_is_lazy_and_supports_formatting() {
let result: Result<i64, ErrorWithContext> = Err(ErrorWithContext::new("base"));
let err = result
.with_context(|| format!("dynamic context {}", 42))
.unwrap_err();
assert!(err.to_string().contains("dynamic context 42"));
}
#[test]
fn with_context_closure_not_called_on_ok() {
let mut called = false;
let result: Result<i64, ErrorWithContext> = Ok(7);
let _ = result.with_context(|| {
called = true;
"unused"
});
assert!(!called);
}
#[test]
fn parse_config_rejects_non_numeric() {
let err = parse_config("not a number").unwrap_err();
assert_eq!(err.message, "invalid number");
}
}#[cfg(test)]
mod tests {
use super::*;
#[test]
fn display_without_context_shows_just_message() {
let err = ErrorWithContext::new("oops");
assert_eq!(err.to_string(), "oops");
}
#[test]
fn display_with_single_context_prefixes_message() {
let err = ErrorWithContext::new("oops").with_context("loading");
assert_eq!(err.to_string(), "loading: oops");
}
#[test]
fn nested_context_renders_outer_to_inner() {
let err = init_system("/missing").unwrap_err();
assert_eq!(err.context.len(), 2);
assert_eq!(
err.to_string(),
"system init -> reading config: file not found"
);
}
#[test]
fn success_passes_through_every_layer() {
assert_eq!(init_system("/ok").unwrap(), 42);
}
#[test]
fn context_trait_chains_across_map_err() {
let result: Result<i64, ErrorWithContext> = Err(ErrorWithContext::new("base"));
let result = result
.context("layer1")
.map_err(|e| e.with_context("layer2"));
let err = result.unwrap_err();
assert_eq!(
err.context,
vec!["layer1".to_string(), "layer2".to_string()]
);
assert_eq!(err.to_string(), "layer2 -> layer1: base");
}
#[test]
fn with_context_is_lazy_and_supports_formatting() {
let result: Result<i64, ErrorWithContext> = Err(ErrorWithContext::new("base"));
let err = result
.with_context(|| format!("dynamic context {}", 42))
.unwrap_err();
assert!(err.to_string().contains("dynamic context 42"));
}
#[test]
fn with_context_closure_not_called_on_ok() {
let mut called = false;
let result: Result<i64, ErrorWithContext> = Ok(7);
let _ = result.with_context(|| {
called = true;
"unused"
});
assert!(!called);
}
#[test]
fn parse_config_rejects_non_numeric() {
let err = parse_config("not a number").unwrap_err();
assert_eq!(err.message, "invalid number");
}
}
Deep Comparison
Error Context — Comparison
Core Insight
Raw errors like "file not found" are useless without knowing which file, in which operation, at which layer. Context wrapping builds a breadcrumb trail.
OCaml Approach
context: string list accumulates breadcrumbs>>| operator adds context in pipelinesRust Approach
Vec<String> context chainContext on Result — .context("msg")?.with_context(|| format!(...))?anyhow::Context trait does exactly thisComparison Table
| Aspect | OCaml | Rust |
|---|---|---|
| Context accumulator | string list field | Vec<String> field |
| Adding context | Custom >>| operator | .context() trait method |
| Lazy context | Thunk fun () -> ... | Closure \|\| format!(...) |
| Standard library | No | No (but anyhow is de facto standard) |
| Display format | Manual String.concat | Custom Display impl |
Exercises
context_if(predicate: bool, msg: &str) combinator that only attaches context when the predicate is true.source() chain walker that prints all context levels in a numbered list.anyhow::Result and anyhow::Context from the anyhow crate and compare the implementation complexity.