1007-result-combinators — Result Combinators
Tutorial
The Problem
Chaining fallible operations without deeply nested pattern matches is a core challenge in error-handling design. In C, every call site checks a return code, creating pyramids of conditionals. Haskell's Either monad and OCaml's Result type both solve this by providing combinators that thread success values through a pipeline while propagating errors automatically.
Rust's Result<T, E> ships with a rich set of combinators: map, and_then, map_err, or_else, and unwrap_or_else. These methods let you compose fallible computations as cleanly as iterator chains, desugaring to the same match logic you would write by hand.
🎯 Learning Outcomes
and_then implements monadic bind (flatmap) for Resultmap to transform success values without unwrappingmap_err to convert or annotate error typesor_else to substitute a fallback Result on failurematch blocks with expressive combinator pipelinesCode Example
#![allow(clippy::all)]
// 1007: Result Combinators
// and_then, or_else, map, map_err, unwrap_or_else
fn parse_int(s: &str) -> Result<i64, String> {
s.parse::<i64>()
.map_err(|e| format!("not an int: {} ({})", s, e))
}
fn double_if_positive(n: i64) -> Result<i64, String> {
if n > 0 {
Ok(n * 2)
} else {
Err("must be positive".into())
}
}
// Approach 1: Chaining with and_then (flatmap/bind)
fn process_chain(s: &str) -> Result<String, String> {
parse_int(s)
.and_then(double_if_positive)
.map(|n| n.to_string())
}
// Approach 2: Using map, map_err, or_else, unwrap_or_else
fn process_with_fallback(s: &str) -> String {
parse_int(s)
.and_then(double_if_positive)
.map(|n| n.to_string())
.map_err(|e| format!("FALLBACK: {}", e))
.unwrap_or_else(|e| e)
}
fn process_or_else(s: &str) -> Result<i64, String> {
parse_int(s).and_then(double_if_positive).or_else(|_| Ok(0)) // fallback to 0 on any error
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_and_then_success() {
assert_eq!(process_chain("5"), Ok("10".to_string()));
}
#[test]
fn test_and_then_negative() {
assert_eq!(process_chain("-3"), Err("must be positive".to_string()));
}
#[test]
fn test_and_then_parse_fail() {
assert!(process_chain("abc").is_err());
}
#[test]
fn test_map() {
let result: Result<i64, String> = Ok(5);
assert_eq!(result.map(|n| n * 2), Ok(10));
}
#[test]
fn test_map_err() {
let result: Result<i64, &str> = Err("low");
assert_eq!(result.map_err(|e| e.to_uppercase()), Err("LOW".to_string()));
}
#[test]
fn test_or_else() {
assert_eq!(process_or_else("-1"), Ok(0));
assert_eq!(process_or_else("5"), Ok(10));
}
#[test]
fn test_unwrap_or_else() {
let result: Result<i64, String> = Err("fail".into());
assert_eq!(result.unwrap_or_else(|_| 99), 99);
let result: Result<i64, String> = Ok(42);
assert_eq!(result.unwrap_or_else(|_| 99), 42);
}
#[test]
fn test_fallback_string() {
assert_eq!(process_with_fallback("5"), "10");
assert!(process_with_fallback("abc").starts_with("FALLBACK"));
}
}Key Differences
result.and_then(f) method chaining; OCaml uses Result.bind f result piped with |>.and_then to share the same E type; OCaml is structurally typed and more flexible.From conversion**: Rust's ? auto-converts error types via From; OCaml combinators require explicit Result.map_error for type adaptation.Result value by move; OCaml passes values through the GC with no ownership concern.OCaml Approach
OCaml's Result module provides Result.map, Result.bind (equivalent to and_then), and Result.map_error. The |> pipeline operator makes chaining natural:
let process s =
parse_int s
|> Result.bind double_if_positive
|> Result.map string_of_int
OCaml expresses combinators as regular functions passed via |>, while Rust uses method chaining on the Result value.
Full Source
#![allow(clippy::all)]
// 1007: Result Combinators
// and_then, or_else, map, map_err, unwrap_or_else
fn parse_int(s: &str) -> Result<i64, String> {
s.parse::<i64>()
.map_err(|e| format!("not an int: {} ({})", s, e))
}
fn double_if_positive(n: i64) -> Result<i64, String> {
if n > 0 {
Ok(n * 2)
} else {
Err("must be positive".into())
}
}
// Approach 1: Chaining with and_then (flatmap/bind)
fn process_chain(s: &str) -> Result<String, String> {
parse_int(s)
.and_then(double_if_positive)
.map(|n| n.to_string())
}
// Approach 2: Using map, map_err, or_else, unwrap_or_else
fn process_with_fallback(s: &str) -> String {
parse_int(s)
.and_then(double_if_positive)
.map(|n| n.to_string())
.map_err(|e| format!("FALLBACK: {}", e))
.unwrap_or_else(|e| e)
}
fn process_or_else(s: &str) -> Result<i64, String> {
parse_int(s).and_then(double_if_positive).or_else(|_| Ok(0)) // fallback to 0 on any error
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_and_then_success() {
assert_eq!(process_chain("5"), Ok("10".to_string()));
}
#[test]
fn test_and_then_negative() {
assert_eq!(process_chain("-3"), Err("must be positive".to_string()));
}
#[test]
fn test_and_then_parse_fail() {
assert!(process_chain("abc").is_err());
}
#[test]
fn test_map() {
let result: Result<i64, String> = Ok(5);
assert_eq!(result.map(|n| n * 2), Ok(10));
}
#[test]
fn test_map_err() {
let result: Result<i64, &str> = Err("low");
assert_eq!(result.map_err(|e| e.to_uppercase()), Err("LOW".to_string()));
}
#[test]
fn test_or_else() {
assert_eq!(process_or_else("-1"), Ok(0));
assert_eq!(process_or_else("5"), Ok(10));
}
#[test]
fn test_unwrap_or_else() {
let result: Result<i64, String> = Err("fail".into());
assert_eq!(result.unwrap_or_else(|_| 99), 99);
let result: Result<i64, String> = Ok(42);
assert_eq!(result.unwrap_or_else(|_| 99), 42);
}
#[test]
fn test_fallback_string() {
assert_eq!(process_with_fallback("5"), "10");
assert!(process_with_fallback("abc").starts_with("FALLBACK"));
}
}#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_and_then_success() {
assert_eq!(process_chain("5"), Ok("10".to_string()));
}
#[test]
fn test_and_then_negative() {
assert_eq!(process_chain("-3"), Err("must be positive".to_string()));
}
#[test]
fn test_and_then_parse_fail() {
assert!(process_chain("abc").is_err());
}
#[test]
fn test_map() {
let result: Result<i64, String> = Ok(5);
assert_eq!(result.map(|n| n * 2), Ok(10));
}
#[test]
fn test_map_err() {
let result: Result<i64, &str> = Err("low");
assert_eq!(result.map_err(|e| e.to_uppercase()), Err("LOW".to_string()));
}
#[test]
fn test_or_else() {
assert_eq!(process_or_else("-1"), Ok(0));
assert_eq!(process_or_else("5"), Ok(10));
}
#[test]
fn test_unwrap_or_else() {
let result: Result<i64, String> = Err("fail".into());
assert_eq!(result.unwrap_or_else(|_| 99), 99);
let result: Result<i64, String> = Ok(42);
assert_eq!(result.unwrap_or_else(|_| 99), 42);
}
#[test]
fn test_fallback_string() {
assert_eq!(process_with_fallback("5"), "10");
assert!(process_with_fallback("abc").starts_with("FALLBACK"));
}
}
Deep Comparison
Result Combinators — Comparison
Core Insight
Both OCaml and Rust treat Result as a monad with map (functor) and bind/and_then (monadic bind). Rust adds more built-in combinators.
OCaml Approach
Result.map, Result.bind in stdlib (OCaml 4.08+)map_error, or_else typically hand-written|> operatorOption.value ~default for unwrap-with-defaultRust Approach
map, map_err, and_then, or_else, unwrap_or_else, unwrap_or_default. notation? operator as syntactic sugar for and_then + early returnok(), err() to convert between Result and OptionComparison Table
| Combinator | OCaml | Rust |
|---|---|---|
| map | Result.map f r | r.map(f) |
| flatmap/bind | Result.bind r f | r.and_then(f) |
| map error | custom map_error | r.map_err(f) |
| fallback | custom or_else | r.or_else(f) |
| default | Result.value r ~default | r.unwrap_or(v) |
| lazy default | custom | r.unwrap_or_else(f) |
Exercises
clamp_to_range(n: i64, min: i64, max: i64) -> Result<i64, String> function and insert it into process_chain between parsing and doubling.process_with_fallback using the ? operator inside a helper function instead of combinator chaining. Verify all tests still pass.map2 function that takes two Result<T, E> values and a combining function, returning Result<U, E>. Use it to add two parsed integers.