ExamplesBy LevelBy TopicLearning Paths
291 Intermediate

291: Result Combinators

Functional Programming

Tutorial Video

Text description (accessibility)

This video demonstrates the "291: Result Combinators" functional Rust example. Difficulty level: Intermediate. Key concepts covered: Functional Programming. Imperative error handling with nested `if/else` or try/catch blocks forces control flow restructuring whenever an operation might fail. Key difference from OCaml: 1. **Naming**: Rust uses `and_then` (from Haskell convention); OCaml uses `bind` and `let*` syntax.

Tutorial

The Problem

Imperative error handling with nested if/else or try/catch blocks forces control flow restructuring whenever an operation might fail. The Result<T, E> type in Rust models explicit failure as a value, and its combinator methods enable functional-style error handling: transform successes, chain operations, and recover from failures — all without breaking out of an expression context. This mirrors OCaml's Result.bind and Haskell's Either monad.

🎯 Learning Outcomes

  • • Use map() to transform the Ok value while preserving Err
  • • Use map_err() to transform the Err value while preserving Ok
  • • Chain fallible operations with and_then() — the monadic bind for Result
  • • Recover from errors with or_else() and unwrap_or_else()
  • Code Example

    let doubled: Result<i32, String> = Ok(5).map(|x| x * 2);
    // Ok(10)

    Key Differences

  • Naming: Rust uses and_then (from Haskell convention); OCaml uses bind and let* syntax.
  • Error transformation: Rust provides map_err() for transforming the error type; OCaml uses Result.map_error.
  • Recovery: or_else() in Rust; OCaml uses Result.fold or pattern matching.
  • Type conversion: from() and ? operator automate error type conversion in Rust; OCaml requires explicit wrapping.
  • OCaml Approach

    OCaml's Result module provides Result.map, Result.bind (and_then equivalent), and Result.map_error. The let* syntax sugar (OCaml 4.08+) desugars to bind:

    let parse_and_divide s divisor =
      let* n = parse_int s in
      let* q = divide n divisor in
      Ok (q * 2)
    (* let* desugars to Result.bind *)
    

    Full Source

    #![allow(clippy::all)]
    //! # Result Combinators
    //!
    //! Transform, chain, and recover from errors using `.map()`, `.and_then()`, and `.or_else()`.
    
    /// Parse a string into an integer with a custom error message
    pub fn parse_int(s: &str) -> Result<i32, String> {
        s.parse::<i32>().map_err(|e| format!("parse error: {}", e))
    }
    
    /// Divide two numbers, returning an error for division by zero
    pub fn divide(a: i32, b: i32) -> Result<i32, String> {
        if b == 0 {
            Err("division by zero".to_string())
        } else {
            Ok(a / b)
        }
    }
    
    /// Chain parse and divide operations
    pub fn parse_and_divide(s: &str, divisor: i32) -> Result<i32, String> {
        parse_int(s).and_then(|n| divide(n, divisor))
    }
    
    /// Map on Ok value
    pub fn double_result(r: Result<i32, String>) -> Result<i32, String> {
        r.map(|x| x * 2)
    }
    
    /// Recover from error with a default
    pub fn with_default(r: Result<i32, String>, default: i32) -> Result<i32, String> {
        r.or_else(|_| Ok(default))
    }
    
    /// Add context to error messages
    pub fn with_context(r: Result<i32, String>, context: &str) -> Result<i32, String> {
        r.map_err(|e| format!("{}: {}", context, e))
    }
    
    /// Full pipeline example
    pub fn full_pipeline(s: &str) -> Result<i32, String> {
        parse_int(s)
            .and_then(|n| divide(n, 4))
            .map(|n| n + 1)
            .map_err(|e| format!("Pipeline failed: {}", e))
    }
    
    #[cfg(test)]
    mod tests {
        use super::*;
    
        #[test]
        fn test_parse_int_ok() {
            assert_eq!(parse_int("42"), Ok(42));
        }
    
        #[test]
        fn test_parse_int_err() {
            assert!(parse_int("abc").is_err());
        }
    
        #[test]
        fn test_divide_ok() {
            assert_eq!(divide(10, 2), Ok(5));
        }
    
        #[test]
        fn test_divide_by_zero() {
            assert_eq!(divide(10, 0), Err("division by zero".to_string()));
        }
    
        #[test]
        fn test_map_ok() {
            let r: Result<i32, String> = Ok(5);
            assert_eq!(r.map(|x| x * 2), Ok(10));
        }
    
        #[test]
        fn test_and_then_chain() {
            let r = parse_and_divide("10", 2);
            assert_eq!(r, Ok(5));
        }
    
        #[test]
        fn test_and_then_short_circuit() {
            let r = parse_and_divide("abc", 2);
            assert!(r.is_err());
        }
    
        #[test]
        fn test_or_else_recovery() {
            let r: Result<i32, String> = Err("bad".to_string());
            let recovered = with_default(r, 42);
            assert_eq!(recovered, Ok(42));
        }
    
        #[test]
        fn test_map_err() {
            let r: Result<i32, String> = Err("bad".to_string());
            let mapped = with_context(r, "Error");
            assert_eq!(mapped, Err("Error: bad".to_string()));
        }
    
        #[test]
        fn test_full_pipeline() {
            assert_eq!(full_pipeline("20"), Ok(6)); // 20/4 + 1 = 6
        }
    }
    ✓ Tests Rust test suite
    #[cfg(test)]
    mod tests {
        use super::*;
    
        #[test]
        fn test_parse_int_ok() {
            assert_eq!(parse_int("42"), Ok(42));
        }
    
        #[test]
        fn test_parse_int_err() {
            assert!(parse_int("abc").is_err());
        }
    
        #[test]
        fn test_divide_ok() {
            assert_eq!(divide(10, 2), Ok(5));
        }
    
        #[test]
        fn test_divide_by_zero() {
            assert_eq!(divide(10, 0), Err("division by zero".to_string()));
        }
    
        #[test]
        fn test_map_ok() {
            let r: Result<i32, String> = Ok(5);
            assert_eq!(r.map(|x| x * 2), Ok(10));
        }
    
        #[test]
        fn test_and_then_chain() {
            let r = parse_and_divide("10", 2);
            assert_eq!(r, Ok(5));
        }
    
        #[test]
        fn test_and_then_short_circuit() {
            let r = parse_and_divide("abc", 2);
            assert!(r.is_err());
        }
    
        #[test]
        fn test_or_else_recovery() {
            let r: Result<i32, String> = Err("bad".to_string());
            let recovered = with_default(r, 42);
            assert_eq!(recovered, Ok(42));
        }
    
        #[test]
        fn test_map_err() {
            let r: Result<i32, String> = Err("bad".to_string());
            let mapped = with_context(r, "Error");
            assert_eq!(mapped, Err("Error: bad".to_string()));
        }
    
        #[test]
        fn test_full_pipeline() {
            assert_eq!(full_pipeline("20"), Ok(6)); // 20/4 + 1 = 6
        }
    }

    Deep Comparison

    OCaml vs Rust: Result Combinators

    Pattern 1: Map Ok Value

    OCaml

    let ok = Ok 5 in
    let mapped = Result.map (fun x -> x * 2) ok
    (* Ok 10 *)
    

    Rust

    let doubled: Result<i32, String> = Ok(5).map(|x| x * 2);
    // Ok(10)
    

    Pattern 2: Chain Fallible Operations

    OCaml

    let result = 
      parse "10" 
      |> Result.bind (fun n -> divide n 2)
    

    Rust

    let result = parse_int("10").and_then(|n| divide(n, 2));
    

    Pattern 3: Transform Error

    OCaml

    let rich_error = 
      Result.map_error (fun e -> "Parse failed: " ^ e) (parse "abc")
    

    Rust

    let rich = "bad".parse::<i32>()
        .map_err(|e| format!("Validation failed: {}", e));
    

    Key Differences

    ConceptOCamlRust
    Map OkResult.map f rr.map(f)
    Chain fallibleResult.bind r fr.and_then(f)
    Map errorResult.map_error f rr.map_err(f)
    FallbackCustom matchr.or_else(f)
    Default valueResult.value ~default rr.unwrap_or(default)

    Exercises

  • Chain five fallible operations using only and_then() without any match expressions, and verify the error from the second failure propagates correctly.
  • Implement a parser pipeline that parses a string as "name:age" using and_then() to split, parse the age, and validate it is between 0 and 150.
  • Use or_else() to implement a "try primary, fallback to secondary" pattern where a primary parse is retried with a fallback parser on failure.
  • Open Source Repos