ExamplesBy LevelBy TopicLearning Paths
299 Intermediate

299: Adding Context to Errors

Functional Programming

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

  • • Implement a generic Context<E> wrapper that adds a message to any error
  • • Use source() to expose the wrapped error for chain traversal
  • • Understand the layered error context pattern as a stack of descriptive messages
  • • Build a helper function for ergonomic context addition without boilerplate at each call site
  • Code 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

  • 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.
  • Chain traversal: Rust's source() chain enables iterating through all context layers programmatically; OCaml's string approach loses structure.
  • Type precision: Context<ParseError> preserves the exact error type; Box<dyn Error> in anyhow erases it for flexibility.
  • Ecosystem: 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"));
        }
    }
    ✓ Tests Rust test suite
    #[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

    ConceptOCamlRust
    WrappingManual tuple/recordStruct with Error::source()
    Chain traversalManual recursionStandard source() linked list
    Extension methodN/A.context() trait method
    Display vs causeCombinedSeparate concerns

    Exercises

  • Implement a ResultExt trait with .context(msg) and .with_context(|| msg) methods on any Result<T, E: Error>.
  • Build a three-level operation (read → parse → validate) where each level wraps errors in context messages, then traverse and print the full chain.
  • Compare the output of traversing a structured Context<E> chain with a flat string-concatenated approach for the same error scenario.
  • Open Source Repos