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

    #![allow(clippy::all)]
    // 1016: Error Context
    // Add context/backtrace to errors manually using wrapper structs
    
    use std::fmt;
    
    // Approach 1: Context wrapper struct
    #[derive(Debug)]
    struct ErrorWithContext {
        message: String,
        context: Vec<String>,
    }
    
    impl ErrorWithContext {
        fn new(message: impl Into<String>) -> Self {
            ErrorWithContext {
                message: message.into(),
                context: Vec::new(),
            }
        }
    
        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 {
                let chain: Vec<&str> = self.context.iter().rev().map(|s| s.as_str()).collect();
                write!(f, "{}: {}", chain.join(" -> "), self.message)
            }
        }
    }
    
    impl std::error::Error for ErrorWithContext {}
    
    // Extension trait for adding context to any Result
    trait Context<T> {
        fn context(self, ctx: impl Into<String>) -> Result<T, ErrorWithContext>;
        fn with_context(self, f: impl FnOnce() -> String) -> Result<T, ErrorWithContext>;
    }
    
    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(self, f: impl FnOnce() -> String) -> Result<T, ErrorWithContext> {
            self.map_err(|e| e.with_context(f()))
        }
    }
    
    // Low-level functions
    fn read_file(path: &str) -> Result<String, ErrorWithContext> {
        if path == "/missing" {
            Err(ErrorWithContext::new("file not found"))
        } else {
            Ok("42".into())
        }
    }
    
    fn parse_config(content: &str) -> Result<i64, ErrorWithContext> {
        content
            .parse::<i64>()
            .map_err(|e| ErrorWithContext::new(format!("invalid number: {}", e)))
    }
    
    // Approach 2: Chain contexts through layers
    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)
    }
    
    fn init_system(path: &str) -> Result<i64, ErrorWithContext> {
        load_setting(path).context("system init")
    }
    
    #[cfg(test)]
    mod tests {
        use super::*;
    
        #[test]
        fn test_no_context() {
            let err = ErrorWithContext::new("oops");
            assert_eq!(err.to_string(), "oops");
        }
    
        #[test]
        fn test_single_context() {
            let err = ErrorWithContext::new("oops").with_context("loading");
            assert_eq!(err.to_string(), "loading: oops");
        }
    
        #[test]
        fn test_nested_context() {
            let result = init_system("/missing");
            let err = result.unwrap_err();
            assert_eq!(err.context.len(), 2);
            assert!(err.to_string().contains("system init"));
            assert!(err.to_string().contains("reading config"));
            assert!(err.to_string().contains("file not found"));
        }
    
        #[test]
        fn test_success_passes_through() {
            assert_eq!(init_system("/ok").unwrap(), 42);
        }
    
        #[test]
        fn test_context_trait() {
            let result: Result<i64, ErrorWithContext> = Err(ErrorWithContext::new("base"));
            let result = result.context("layer1");
            let result = result.map_err(|e| e.with_context("layer2"));
            let err = result.unwrap_err();
            assert_eq!(err.context.len(), 2);
        }
    
        #[test]
        fn test_lazy_context() {
            let result: Result<i64, ErrorWithContext> = Err(ErrorWithContext::new("base"));
            let result = result.with_context(|| format!("dynamic context {}", 42));
            let err = result.unwrap_err();
            assert!(err.to_string().contains("dynamic context 42"));
        }
    }

    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

    #![allow(clippy::all)]
    // 1016: Error Context
    // Add context/backtrace to errors manually using wrapper structs
    
    use std::fmt;
    
    // Approach 1: Context wrapper struct
    #[derive(Debug)]
    struct ErrorWithContext {
        message: String,
        context: Vec<String>,
    }
    
    impl ErrorWithContext {
        fn new(message: impl Into<String>) -> Self {
            ErrorWithContext {
                message: message.into(),
                context: Vec::new(),
            }
        }
    
        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 {
                let chain: Vec<&str> = self.context.iter().rev().map(|s| s.as_str()).collect();
                write!(f, "{}: {}", chain.join(" -> "), self.message)
            }
        }
    }
    
    impl std::error::Error for ErrorWithContext {}
    
    // Extension trait for adding context to any Result
    trait Context<T> {
        fn context(self, ctx: impl Into<String>) -> Result<T, ErrorWithContext>;
        fn with_context(self, f: impl FnOnce() -> String) -> Result<T, ErrorWithContext>;
    }
    
    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(self, f: impl FnOnce() -> String) -> Result<T, ErrorWithContext> {
            self.map_err(|e| e.with_context(f()))
        }
    }
    
    // Low-level functions
    fn read_file(path: &str) -> Result<String, ErrorWithContext> {
        if path == "/missing" {
            Err(ErrorWithContext::new("file not found"))
        } else {
            Ok("42".into())
        }
    }
    
    fn parse_config(content: &str) -> Result<i64, ErrorWithContext> {
        content
            .parse::<i64>()
            .map_err(|e| ErrorWithContext::new(format!("invalid number: {}", e)))
    }
    
    // Approach 2: Chain contexts through layers
    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)
    }
    
    fn init_system(path: &str) -> Result<i64, ErrorWithContext> {
        load_setting(path).context("system init")
    }
    
    #[cfg(test)]
    mod tests {
        use super::*;
    
        #[test]
        fn test_no_context() {
            let err = ErrorWithContext::new("oops");
            assert_eq!(err.to_string(), "oops");
        }
    
        #[test]
        fn test_single_context() {
            let err = ErrorWithContext::new("oops").with_context("loading");
            assert_eq!(err.to_string(), "loading: oops");
        }
    
        #[test]
        fn test_nested_context() {
            let result = init_system("/missing");
            let err = result.unwrap_err();
            assert_eq!(err.context.len(), 2);
            assert!(err.to_string().contains("system init"));
            assert!(err.to_string().contains("reading config"));
            assert!(err.to_string().contains("file not found"));
        }
    
        #[test]
        fn test_success_passes_through() {
            assert_eq!(init_system("/ok").unwrap(), 42);
        }
    
        #[test]
        fn test_context_trait() {
            let result: Result<i64, ErrorWithContext> = Err(ErrorWithContext::new("base"));
            let result = result.context("layer1");
            let result = result.map_err(|e| e.with_context("layer2"));
            let err = result.unwrap_err();
            assert_eq!(err.context.len(), 2);
        }
    
        #[test]
        fn test_lazy_context() {
            let result: Result<i64, ErrorWithContext> = Err(ErrorWithContext::new("base"));
            let result = result.with_context(|| format!("dynamic context {}", 42));
            let err = result.unwrap_err();
            assert!(err.to_string().contains("dynamic context 42"));
        }
    }
    ✓ Tests Rust test suite
    #[cfg(test)]
    mod tests {
        use super::*;
    
        #[test]
        fn test_no_context() {
            let err = ErrorWithContext::new("oops");
            assert_eq!(err.to_string(), "oops");
        }
    
        #[test]
        fn test_single_context() {
            let err = ErrorWithContext::new("oops").with_context("loading");
            assert_eq!(err.to_string(), "loading: oops");
        }
    
        #[test]
        fn test_nested_context() {
            let result = init_system("/missing");
            let err = result.unwrap_err();
            assert_eq!(err.context.len(), 2);
            assert!(err.to_string().contains("system init"));
            assert!(err.to_string().contains("reading config"));
            assert!(err.to_string().contains("file not found"));
        }
    
        #[test]
        fn test_success_passes_through() {
            assert_eq!(init_system("/ok").unwrap(), 42);
        }
    
        #[test]
        fn test_context_trait() {
            let result: Result<i64, ErrorWithContext> = Err(ErrorWithContext::new("base"));
            let result = result.context("layer1");
            let result = result.map_err(|e| e.with_context("layer2"));
            let err = result.unwrap_err();
            assert_eq!(err.context.len(), 2);
        }
    
        #[test]
        fn test_lazy_context() {
            let result: Result<i64, ErrorWithContext> = Err(ErrorWithContext::new("base"));
            let result = result.with_context(|| format!("dynamic context {}", 42));
            let err = result.unwrap_err();
            assert!(err.to_string().contains("dynamic context 42"));
        }
    }

    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