ExamplesBy LevelBy TopicLearning Paths
1005 Intermediate

1005 — Error Chaining

Functional Programming

Tutorial

The Problem

Add context to errors as they propagate up the call stack. When a low-level read_file returns Err(IoError::NotFound), the higher-level load_config wraps it in AppError { context: "loading /path", source: e }. Implement a WithContext extension trait for ergonomic chaining. Compare with OCaml's manual wrapping pattern.

🎯 Learning Outcomes

  • • Use .map_err(|e| AppError { context: "…", source: e }) to add context to errors
  • • Implement a WithContext<T> trait with fn with_context(self, ctx: impl FnOnce() -> String)
  • • Use a lazy closure impl FnOnce() -> String for context to avoid allocation on success paths
  • • Implement std::error::Error::source to expose the original error for chain inspection
  • • Map Rust's map_err to OCaml's match … Error e -> Error { context; cause = e }
  • • Recognise the anyhow::Context pattern as the production implementation of this idea
  • Code Example

    #![allow(clippy::all)]
    // 1005: Error Chaining
    // Chain errors with context using map_err
    
    use std::fmt;
    
    #[derive(Debug)]
    enum IoError {
        NotFound,
        Corrupted(String),
    }
    
    impl fmt::Display for IoError {
        fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
            match self {
                IoError::NotFound => write!(f, "not found"),
                IoError::Corrupted(s) => write!(f, "corrupted: {}", s),
            }
        }
    }
    
    #[derive(Debug)]
    struct AppError {
        context: String,
        source: IoError,
    }
    
    impl fmt::Display for AppError {
        fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
            write!(f, "{}: {}", self.context, self.source)
        }
    }
    
    impl std::error::Error for AppError {
        fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
            None // IoError doesn't impl Error in this example for simplicity
        }
    }
    
    // Low-level function returning raw error
    fn read_file(path: &str) -> Result<String, IoError> {
        match path {
            "/missing" => Err(IoError::NotFound),
            "/bad" => Err(IoError::Corrupted("invalid utf-8".into())),
            _ => Ok("data".into()),
        }
    }
    
    // Approach 1: map_err to add context
    fn load_config(path: &str) -> Result<String, AppError> {
        read_file(path).map_err(|e| AppError {
            context: format!("loading {}", path),
            source: e,
        })
    }
    
    // Approach 2: Generic context extension trait
    trait WithContext<T> {
        fn with_context(self, ctx: impl FnOnce() -> String) -> Result<T, AppError>;
    }
    
    impl<T> WithContext<T> for Result<T, IoError> {
        fn with_context(self, ctx: impl FnOnce() -> String) -> Result<T, AppError> {
            self.map_err(|e| AppError {
                context: ctx(),
                source: e,
            })
        }
    }
    
    fn load_config_ext(path: &str) -> Result<String, AppError> {
        read_file(path).with_context(|| format!("loading {}", path))
    }
    
    #[cfg(test)]
    mod tests {
        use super::*;
    
        #[test]
        fn test_success() {
            assert_eq!(load_config("/ok").unwrap(), "data");
        }
    
        #[test]
        fn test_map_err_context() {
            let err = load_config("/missing").unwrap_err();
            assert_eq!(err.context, "loading /missing");
            assert!(matches!(err.source, IoError::NotFound));
        }
    
        #[test]
        fn test_corrupted_context() {
            let err = load_config("/bad").unwrap_err();
            assert!(err.to_string().contains("corrupted"));
            assert!(err.to_string().contains("loading /bad"));
        }
    
        #[test]
        fn test_extension_trait() {
            assert_eq!(load_config_ext("/ok").unwrap(), "data");
            let err = load_config_ext("/missing").unwrap_err();
            assert_eq!(err.context, "loading /missing");
        }
    
        #[test]
        fn test_display_format() {
            let err = load_config("/missing").unwrap_err();
            assert_eq!(err.to_string(), "loading /missing: not found");
        }
    }

    Key Differences

    AspectRustOCaml
    Add context.map_err(\|e\| AppError { context, source: e })Result.map_error (fun cause -> { context; cause })
    Lazy contextimpl FnOnce() -> StringManual if error { sprintf … }
    Extension traitimpl WithContext<T> for Result<T, IoError>Function with_context
    Error chainError::source() → inner errorManual e.cause field access
    Production libanyhow::ContextResult.error_to_exn or custom
    VerbosityMediumLow

    Error chaining is how production Rust code provides actionable error messages: "failed to start server: failed to read config: file not found: /etc/app.toml". Each layer adds context. The anyhow crate automates this pattern; understanding the manual version clarifies the underlying mechanics.

    OCaml Approach

    OCaml's load_with_context path matches on read_file path: on Error e, it returns Error { context = …; cause = e }. There is no lazy context — Printf.sprintf is always called. The with_context helper can be defined as let with_context ctx = Result.map_error (fun cause -> { context = ctx; cause }). This is the same pattern as Rust's map_err, just without trait integration.

    Full Source

    #![allow(clippy::all)]
    // 1005: Error Chaining
    // Chain errors with context using map_err
    
    use std::fmt;
    
    #[derive(Debug)]
    enum IoError {
        NotFound,
        Corrupted(String),
    }
    
    impl fmt::Display for IoError {
        fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
            match self {
                IoError::NotFound => write!(f, "not found"),
                IoError::Corrupted(s) => write!(f, "corrupted: {}", s),
            }
        }
    }
    
    #[derive(Debug)]
    struct AppError {
        context: String,
        source: IoError,
    }
    
    impl fmt::Display for AppError {
        fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
            write!(f, "{}: {}", self.context, self.source)
        }
    }
    
    impl std::error::Error for AppError {
        fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
            None // IoError doesn't impl Error in this example for simplicity
        }
    }
    
    // Low-level function returning raw error
    fn read_file(path: &str) -> Result<String, IoError> {
        match path {
            "/missing" => Err(IoError::NotFound),
            "/bad" => Err(IoError::Corrupted("invalid utf-8".into())),
            _ => Ok("data".into()),
        }
    }
    
    // Approach 1: map_err to add context
    fn load_config(path: &str) -> Result<String, AppError> {
        read_file(path).map_err(|e| AppError {
            context: format!("loading {}", path),
            source: e,
        })
    }
    
    // Approach 2: Generic context extension trait
    trait WithContext<T> {
        fn with_context(self, ctx: impl FnOnce() -> String) -> Result<T, AppError>;
    }
    
    impl<T> WithContext<T> for Result<T, IoError> {
        fn with_context(self, ctx: impl FnOnce() -> String) -> Result<T, AppError> {
            self.map_err(|e| AppError {
                context: ctx(),
                source: e,
            })
        }
    }
    
    fn load_config_ext(path: &str) -> Result<String, AppError> {
        read_file(path).with_context(|| format!("loading {}", path))
    }
    
    #[cfg(test)]
    mod tests {
        use super::*;
    
        #[test]
        fn test_success() {
            assert_eq!(load_config("/ok").unwrap(), "data");
        }
    
        #[test]
        fn test_map_err_context() {
            let err = load_config("/missing").unwrap_err();
            assert_eq!(err.context, "loading /missing");
            assert!(matches!(err.source, IoError::NotFound));
        }
    
        #[test]
        fn test_corrupted_context() {
            let err = load_config("/bad").unwrap_err();
            assert!(err.to_string().contains("corrupted"));
            assert!(err.to_string().contains("loading /bad"));
        }
    
        #[test]
        fn test_extension_trait() {
            assert_eq!(load_config_ext("/ok").unwrap(), "data");
            let err = load_config_ext("/missing").unwrap_err();
            assert_eq!(err.context, "loading /missing");
        }
    
        #[test]
        fn test_display_format() {
            let err = load_config("/missing").unwrap_err();
            assert_eq!(err.to_string(), "loading /missing: not found");
        }
    }
    ✓ Tests Rust test suite
    #[cfg(test)]
    mod tests {
        use super::*;
    
        #[test]
        fn test_success() {
            assert_eq!(load_config("/ok").unwrap(), "data");
        }
    
        #[test]
        fn test_map_err_context() {
            let err = load_config("/missing").unwrap_err();
            assert_eq!(err.context, "loading /missing");
            assert!(matches!(err.source, IoError::NotFound));
        }
    
        #[test]
        fn test_corrupted_context() {
            let err = load_config("/bad").unwrap_err();
            assert!(err.to_string().contains("corrupted"));
            assert!(err.to_string().contains("loading /bad"));
        }
    
        #[test]
        fn test_extension_trait() {
            assert_eq!(load_config_ext("/ok").unwrap(), "data");
            let err = load_config_ext("/missing").unwrap_err();
            assert_eq!(err.context, "loading /missing");
        }
    
        #[test]
        fn test_display_format() {
            let err = load_config("/missing").unwrap_err();
            assert_eq!(err.to_string(), "loading /missing: not found");
        }
    }

    Deep Comparison

    Error Chaining — Comparison

    Core Insight

    Both languages can wrap errors with context, but Rust's map_err and extension traits make it idiomatic and composable at the type level.

    OCaml Approach

  • • Wrap errors in records or variant constructors manually
  • • Can write with_context helpers that pattern-match on Result
  • • No standard trait system for error chaining
  • • Context is ad-hoc — each project invents its own pattern
  • Rust Approach

  • map_err(|e| ...) transforms errors inline during propagation
  • • Extension traits like WithContext mimic what anyhow provides
  • • The Error::source() method creates a standard chain
  • • Pattern is so common that crates like anyhow and thiserror standardize it
  • Comparison Table

    AspectOCamlRust
    Context additionManual record wrappingmap_err / extension trait
    Inline ergonomicsVerbose matchFluent .map_err(...)
    Error chainCustom nestingError::source() standard
    Ecosystem supportAd-hocanyhow, thiserror crates
    Lazy contextClosure in helperFnOnce in extension trait

    Exercises

  • Add a wrap_io_err(op: &str) -> impl FnOnce(IoError) -> AppError factory function for reusable context strings.
  • Implement Error::source for AppError by making IoError also implement std::error::Error.
  • Write a print_error_chain(e: &dyn Error) function that traverses the .source() chain and prints each level.
  • Use the anyhow crate's .context("…") method and compare it with the manual WithContext trait.
  • In OCaml, implement a chain_error functor that adds context to any ('a, 'b) result error type, parameterised over the error type.
  • Open Source Repos