ExamplesBy LevelBy TopicLearning Paths
1016 Intermediate

1016-error-context — Error Context

Functional Programming

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

  • • Design an ErrorWithContext struct that carries a message and a context chain
  • • Implement a Context extension trait for Result<T, ErrorWithContext>
  • • Understand the difference between eager .context(str) and lazy .with_context(|| str)
  • • Walk the context chain to produce a human-readable error display
  • • Understand how anyhow::Context generalises this to any error type
  • Code 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

  • Lazy vs eager: Rust's with_context takes a closure to avoid string formatting when no error occurs; OCaml's Error.tag is lazy by default via Info.t.
  • Extension trait: Rust's Context trait is implemented on Result<T, E> using a blanket impl; OCaml uses module functions.
  • Chain direction: Rust builds context by prepending to a 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");
        }
    }
    ✓ Tests Rust test suite
    #[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

  • • Record with context: string list accumulates breadcrumbs
  • • Custom >>| operator adds context in pipelines
  • • No standard library support — each project rolls its own
  • Rust Approach

  • • Wrapper struct with Vec<String> context chain
  • • Extension trait Context on Result.context("msg")?
  • • Lazy variant: .with_context(|| format!(...))?
  • • Real-world: anyhow::Context trait does exactly this
  • Comparison Table

    AspectOCamlRust
    Context accumulatorstring list fieldVec<String> field
    Adding contextCustom >>| operator.context() trait method
    Lazy contextThunk fun () -> ...Closure \|\| format!(...)
    Standard libraryNoNo (but anyhow is de facto standard)
    Display formatManual String.concatCustom Display impl

    Exercises

  • Add a context_if(predicate: bool, msg: &str) combinator that only attaches context when the predicate is true.
  • Implement a source() chain walker that prints all context levels in a numbered list.
  • Refactor the example to use anyhow::Result and anyhow::Context from the anyhow crate and compare the implementation complexity.
  • Open Source Repos