ExamplesBy LevelBy TopicLearning Paths
1021 Intermediate

1021-error-propagation-depth — Error Propagation Depth

Functional Programming

Tutorial

The Problem

Real applications have deep call stacks: a user request flows through authentication, configuration loading, parsing, validation, and service calls — each of which can fail with a different type of error. Manually handling each error at every call site creates enormous boilerplate and buries the application logic.

Rust's ? operator enables error propagation across multiple layers with minimal syntax. This example demonstrates a five-level deep pipeline where each layer uses ? to propagate errors upward, all while preserving type safety and the ability to pattern-match on specific errors at the top level.

🎯 Learning Outcomes

  • • Compose five layers of fallible functions using ?
  • • Design a unified AppError enum that wraps errors from all subsystems
  • • Understand how From implementations enable ? across error type boundaries
  • • Trace an error from the innermost layer to the outermost handler
  • • Know the trade-offs between a unified error enum and Box<dyn Error>
  • Code Example

    #![allow(clippy::all)]
    // 1021: Error Propagation Depth
    // 5-level error propagation with ?
    
    use std::fmt;
    
    #[derive(Debug, PartialEq)]
    enum AppError {
        ConfigMissing(String),
        ParseFailed(String),
        ValidationFailed(String),
        ServiceUnavailable(String),
        Timeout,
    }
    
    impl fmt::Display for AppError {
        fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
            match self {
                AppError::ConfigMissing(s) => write!(f, "config missing: {}", s),
                AppError::ParseFailed(s) => write!(f, "parse failed: {}", s),
                AppError::ValidationFailed(s) => write!(f, "validation: {}", s),
                AppError::ServiceUnavailable(s) => write!(f, "service unavailable: {}", s),
                AppError::Timeout => write!(f, "timeout"),
            }
        }
    }
    impl std::error::Error for AppError {}
    
    // Level 1: Config layer
    fn read_config(key: &str) -> Result<String, AppError> {
        if key == "missing" {
            Err(AppError::ConfigMissing(key.into()))
        } else {
            Ok("8080".into())
        }
    }
    
    // Level 2: Parse layer
    fn parse_port(s: &str) -> Result<u16, AppError> {
        s.parse::<u16>()
            .map_err(|_| AppError::ParseFailed(s.into()))
    }
    
    // Level 3: Validation layer
    fn validate_port(port: u16) -> Result<u16, AppError> {
        if port == 0 {
            Err(AppError::ValidationFailed(format!("port {} invalid", port)))
        } else {
            Ok(port)
        }
    }
    
    // Level 4: Connection layer
    fn connect(_host: &str, port: u16) -> Result<String, AppError> {
        if port == 9999 {
            Err(AppError::ServiceUnavailable("connection refused".into()))
        } else {
            Ok(format!("connected:{}", port))
        }
    }
    
    // Level 5: Application layer — chains all with ?
    fn start_service(key: &str, host: &str) -> Result<String, AppError> {
        let raw = read_config(key)?; // Level 1
        let port = parse_port(&raw)?; // Level 2
        let valid = validate_port(port)?; // Level 3
        let conn = connect(host, valid)?; // Level 4
        Ok(conn) // Level 5 success
    }
    
    #[cfg(test)]
    mod tests {
        use super::*;
    
        #[test]
        fn test_full_success() {
            assert_eq!(
                start_service("port", "localhost"),
                Ok("connected:8080".into())
            );
        }
    
        #[test]
        fn test_level1_config_error() {
            let err = start_service("missing", "localhost").unwrap_err();
            assert!(matches!(err, AppError::ConfigMissing(_)));
        }
    
        #[test]
        fn test_level2_parse_error() {
            // parse_port directly
            let err = parse_port("abc").unwrap_err();
            assert!(matches!(err, AppError::ParseFailed(_)));
        }
    
        #[test]
        fn test_level3_validation_error() {
            let err = validate_port(0).unwrap_err();
            assert!(matches!(err, AppError::ValidationFailed(_)));
        }
    
        #[test]
        fn test_level4_connection_error() {
            let err = connect("host", 9999).unwrap_err();
            assert!(matches!(err, AppError::ServiceUnavailable(_)));
        }
    
        #[test]
        fn test_error_display() {
            let err = AppError::ConfigMissing("db_url".into());
            assert_eq!(err.to_string(), "config missing: db_url");
    
            let err = AppError::Timeout;
            assert_eq!(err.to_string(), "timeout");
        }
    
        #[test]
        fn test_question_mark_propagates_correctly() {
            // Each ? passes the error through unchanged
            fn layer_test() -> Result<(), AppError> {
                let _ = read_config("missing")?;
                Ok(())
            }
            assert!(matches!(layer_test(), Err(AppError::ConfigMissing(_))));
        }
    
        #[test]
        fn test_all_layers_independent() {
            assert!(read_config("ok").is_ok());
            assert!(parse_port("8080").is_ok());
            assert!(validate_port(80).is_ok());
            assert!(connect("localhost", 80).is_ok());
        }
    }

    Key Differences

  • **From requirement**: Rust's ? requires From<SourceError> for AppError; OCaml's let* requires the error type to already be the same.
  • Error enum exhaustiveness: Rust's match on AppError is checked exhaustively at compile time; OCaml pattern matching is also exhaustive but the type system is structurally typed.
  • **Five layers of ?**: Rust reads left-to-right linearly with ? at each step; OCaml reads top-to-bottom with let* bindings.
  • Type-level error info: Rust's typed AppError retains the failure category in the type; Box<dyn Error> erases it but is easier to compose.
  • OCaml Approach

    OCaml achieves the same effect with let* and a unified error type:

    let ( let* ) = Result.bind
    
    let startup () =
      let* config = read_config "port" in
      let* port = parse_port config in
      let* _ = validate_port port in
      check_service port
    

    Each let* short-circuits on Error. Unlike Rust, OCaml does not require From impls because the error type is unified at the module boundary and OCaml's structural type system handles matching.

    Full Source

    #![allow(clippy::all)]
    // 1021: Error Propagation Depth
    // 5-level error propagation with ?
    
    use std::fmt;
    
    #[derive(Debug, PartialEq)]
    enum AppError {
        ConfigMissing(String),
        ParseFailed(String),
        ValidationFailed(String),
        ServiceUnavailable(String),
        Timeout,
    }
    
    impl fmt::Display for AppError {
        fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
            match self {
                AppError::ConfigMissing(s) => write!(f, "config missing: {}", s),
                AppError::ParseFailed(s) => write!(f, "parse failed: {}", s),
                AppError::ValidationFailed(s) => write!(f, "validation: {}", s),
                AppError::ServiceUnavailable(s) => write!(f, "service unavailable: {}", s),
                AppError::Timeout => write!(f, "timeout"),
            }
        }
    }
    impl std::error::Error for AppError {}
    
    // Level 1: Config layer
    fn read_config(key: &str) -> Result<String, AppError> {
        if key == "missing" {
            Err(AppError::ConfigMissing(key.into()))
        } else {
            Ok("8080".into())
        }
    }
    
    // Level 2: Parse layer
    fn parse_port(s: &str) -> Result<u16, AppError> {
        s.parse::<u16>()
            .map_err(|_| AppError::ParseFailed(s.into()))
    }
    
    // Level 3: Validation layer
    fn validate_port(port: u16) -> Result<u16, AppError> {
        if port == 0 {
            Err(AppError::ValidationFailed(format!("port {} invalid", port)))
        } else {
            Ok(port)
        }
    }
    
    // Level 4: Connection layer
    fn connect(_host: &str, port: u16) -> Result<String, AppError> {
        if port == 9999 {
            Err(AppError::ServiceUnavailable("connection refused".into()))
        } else {
            Ok(format!("connected:{}", port))
        }
    }
    
    // Level 5: Application layer — chains all with ?
    fn start_service(key: &str, host: &str) -> Result<String, AppError> {
        let raw = read_config(key)?; // Level 1
        let port = parse_port(&raw)?; // Level 2
        let valid = validate_port(port)?; // Level 3
        let conn = connect(host, valid)?; // Level 4
        Ok(conn) // Level 5 success
    }
    
    #[cfg(test)]
    mod tests {
        use super::*;
    
        #[test]
        fn test_full_success() {
            assert_eq!(
                start_service("port", "localhost"),
                Ok("connected:8080".into())
            );
        }
    
        #[test]
        fn test_level1_config_error() {
            let err = start_service("missing", "localhost").unwrap_err();
            assert!(matches!(err, AppError::ConfigMissing(_)));
        }
    
        #[test]
        fn test_level2_parse_error() {
            // parse_port directly
            let err = parse_port("abc").unwrap_err();
            assert!(matches!(err, AppError::ParseFailed(_)));
        }
    
        #[test]
        fn test_level3_validation_error() {
            let err = validate_port(0).unwrap_err();
            assert!(matches!(err, AppError::ValidationFailed(_)));
        }
    
        #[test]
        fn test_level4_connection_error() {
            let err = connect("host", 9999).unwrap_err();
            assert!(matches!(err, AppError::ServiceUnavailable(_)));
        }
    
        #[test]
        fn test_error_display() {
            let err = AppError::ConfigMissing("db_url".into());
            assert_eq!(err.to_string(), "config missing: db_url");
    
            let err = AppError::Timeout;
            assert_eq!(err.to_string(), "timeout");
        }
    
        #[test]
        fn test_question_mark_propagates_correctly() {
            // Each ? passes the error through unchanged
            fn layer_test() -> Result<(), AppError> {
                let _ = read_config("missing")?;
                Ok(())
            }
            assert!(matches!(layer_test(), Err(AppError::ConfigMissing(_))));
        }
    
        #[test]
        fn test_all_layers_independent() {
            assert!(read_config("ok").is_ok());
            assert!(parse_port("8080").is_ok());
            assert!(validate_port(80).is_ok());
            assert!(connect("localhost", 80).is_ok());
        }
    }
    ✓ Tests Rust test suite
    #[cfg(test)]
    mod tests {
        use super::*;
    
        #[test]
        fn test_full_success() {
            assert_eq!(
                start_service("port", "localhost"),
                Ok("connected:8080".into())
            );
        }
    
        #[test]
        fn test_level1_config_error() {
            let err = start_service("missing", "localhost").unwrap_err();
            assert!(matches!(err, AppError::ConfigMissing(_)));
        }
    
        #[test]
        fn test_level2_parse_error() {
            // parse_port directly
            let err = parse_port("abc").unwrap_err();
            assert!(matches!(err, AppError::ParseFailed(_)));
        }
    
        #[test]
        fn test_level3_validation_error() {
            let err = validate_port(0).unwrap_err();
            assert!(matches!(err, AppError::ValidationFailed(_)));
        }
    
        #[test]
        fn test_level4_connection_error() {
            let err = connect("host", 9999).unwrap_err();
            assert!(matches!(err, AppError::ServiceUnavailable(_)));
        }
    
        #[test]
        fn test_error_display() {
            let err = AppError::ConfigMissing("db_url".into());
            assert_eq!(err.to_string(), "config missing: db_url");
    
            let err = AppError::Timeout;
            assert_eq!(err.to_string(), "timeout");
        }
    
        #[test]
        fn test_question_mark_propagates_correctly() {
            // Each ? passes the error through unchanged
            fn layer_test() -> Result<(), AppError> {
                let _ = read_config("missing")?;
                Ok(())
            }
            assert!(matches!(layer_test(), Err(AppError::ConfigMissing(_))));
        }
    
        #[test]
        fn test_all_layers_independent() {
            assert!(read_config("ok").is_ok());
            assert!(parse_port("8080").is_ok());
            assert!(validate_port(80).is_ok());
            assert!(connect("localhost", 80).is_ok());
        }
    }

    Deep Comparison

    Error Propagation Depth — Comparison

    Core Insight

    Deep call stacks need error propagation that scales. Both let* (OCaml) and ? (Rust) keep the code flat regardless of depth.

    OCaml Approach

  • let* chains keep code linear through any number of layers
  • • Single error type shared across layers
  • • Each let* is one potential early exit point
  • • Without let*: deeply nested match expressions
  • Rust Approach

  • ? on each fallible call — one character per layer
  • • Shared error enum or From impls for automatic conversion
  • • Each ? is an early-return point
  • • Without ?: deeply nested match or try! macro
  • Comparison Table

    AspectOCaml let*Rust ?
    Syntax per layerlet* x = f inlet x = f?;
    Depth scalingLinearLinear
    Error typeMust match or wrapFrom auto-converts
    Without sugarNested matchNested match
    Readability at 5 levelsGoodGood
    PerformanceZero-costZero-cost

    Exercises

  • Add a sixth layer log_startup(port: u16) -> Result<(), AppError> that simulates a logging failure and chain it into startup.
  • Refactor the example to use anyhow::Result and .context() instead of a typed AppError. Compare readability and the loss of pattern-matching ability.
  • Write an integration test that calls startup with various failing configurations and asserts the specific AppError variant returned.
  • Open Source Repos