291: Result Combinators
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
map() to transform the Ok value while preserving Errmap_err() to transform the Err value while preserving Okand_then() — the monadic bind for Resultor_else() and unwrap_or_else()Code Example
let doubled: Result<i32, String> = Ok(5).map(|x| x * 2);
// Ok(10)Key Differences
and_then (from Haskell convention); OCaml uses bind and let* syntax.map_err() for transforming the error type; OCaml uses Result.map_error.or_else() in Rust; OCaml uses Result.fold or pattern matching.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
}
}#[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
| Concept | OCaml | Rust |
|---|---|---|
| Map Ok | Result.map f r | r.map(f) |
| Chain fallible | Result.bind r f | r.and_then(f) |
| Map error | Result.map_error f r | r.map_err(f) |
| Fallback | Custom match | r.or_else(f) |
| Default value | Result.value ~default r | r.unwrap_or(default) |
Exercises
and_then() without any match expressions, and verify the error from the second failure propagates correctly."name:age" using and_then() to split, parse the age, and validate it is between 0 and 150.or_else() to implement a "try primary, fallback to secondary" pattern where a primary parse is retried with a fallback parser on failure.