063: Monad Transformers
Difficulty: โญโญโญ Level: Advanced Stack `Option` inside `Result` to handle computations that can either fail hard (error) or find nothing โ and compare the formal transformer pattern with Rust's idiomatic `?` + early return.The Problem This Solves
You're writing a database lookup function. It can fail in two distinct ways: 1. Hard failure: the database connection is down โ `Err("DB error")` 2. Soft failure: the record doesn't exist โ `None` (not an error, just absent) If you use `Result<T, E>`, you lose the distinction between "not found" and "error". If you use `Option<T>`, you lose error details. If you nest them naively:fn find_user(id: i32) -> Result<Option<User>, DbError> { ... }
fn find_email(user: &User) -> Result<Option<String>, DbError> { ... }
// Composing them is a nightmare:
fn get_user_email(id: i32) -> Result<Option<String>, DbError> {
match find_user(id) {
Err(e) => Err(e),
Ok(None) => Ok(None),
Ok(Some(user)) => match find_email(&user) {
Err(e) => Err(e),
Ok(None) => Ok(None),
Ok(Some(email)) => Ok(Some(email)),
},
}
}
Every additional step adds another nested `match`. The pattern `Err(e) => Err(e)` and `Ok(None) => Ok(None)` repeats everywhere. The actual logic (call `find_user`, then `find_email`) is buried in boilerplate.
Monad transformers formalise this stacking โ and Rust's `?` + early `return Ok(None)` gives you the same benefit idiomatically. This exists to solve exactly that pain.
The Intuition
A monad transformer is a way to stack two monadic effects in a single computation. Think of it layer by layer:- `Result<T, E>` adds "can fail with an error"
- `Option<T>` adds "might be absent"
- `Result<Option<T>, E>` stacks both: can fail hard or find nothing
OptionT<E, A> = Result<Option<A>, E>
Operations on this type need to handle three cases:
| Case | Meaning |
|---|---|
| `Err(e)` | Hard failure โ stop everything |
| `Ok(None)` | Soft absence โ propagate "not found" |
| `Ok(Some(a))` | Success โ continue with `a` |
bind(m, f):
Err(e) โ Err(e) โ propagate hard error
Ok(None) โ Ok(None) โ propagate soft absence
Ok(Some a)โ f(a) โ continue with value
Rust's idiomatic answer: the `?` operator handles `Err(e) โ return Err(e)`. For `Ok(None)`, you write `return Ok(None)` explicitly. This is cleaner than formal transformers in most Rust code โ but understanding transformers explains why `?` plus early return covers all the cases.
Analogy: Monad transformers are like USB adapters stacked on each other. One layer handles power (error), another handles data (option). Each layer adds one capability. But stacking too many layers (3+) becomes unwieldy โ at that point, Rust's custom error enum with `?` is usually the better tool.
How It Works in Rust
// Step 1: The type alias โ OptionT is just Result<Option<A>, E>
type OptionT<A, E> = Result<Option<A>, E>;
// Step 2: The three fundamental operations
mod option_t {
// Return a value โ wraps in Some and Ok
pub fn pure<A, E>(a: A) -> Result<Option<A>, E> { Ok(Some(a)) }
// Return "not found" โ Ok but None
pub fn none<A, E>() -> Result<Option<A>, E> { Ok(None) }
// bind: the key operation โ threads through all three cases
pub fn bind<A, B, E>(
m: Result<Option<A>, E>,
f: impl FnOnce(A) -> Result<Option<B>, E>,
) -> Result<Option<B>, E> {
match m {
Err(e) => Err(e), // hard error: propagate up
Ok(None) => Ok(None), // soft absence: propagate up
Ok(Some(a)) => f(a), // success: continue with a
}
}
// Lift a Result<A, E> into OptionT โ wraps Ok(a) in Some
pub fn lift_result<A, E>(r: Result<A, E>) -> Result<Option<A>, E> { r.map(Some) }
// Lift an Option<A> into OptionT โ wraps it in Ok
pub fn lift_option<A, E>(o: Option<A>) -> Result<Option<A>, E> { Ok(o) }
}
// Step 3: Database functions that return OptionT
fn find_user(id: i32) -> Result<Option<String>, String> {
if id > 0 { Ok(Some(format!("User_{}", id))) } // found
else if id == 0 { Ok(None) } // not found (soft)
else { Err("Invalid ID".into()) } // error (hard)
}
fn find_email(name: &str) -> Result<Option<String>, String> {
match name {
"User_1" => Ok(Some("user1@example.com".into())), // found
"User_2" => Ok(None), // no email on record
_ => Err("DB connection failed".into()), // hard error
}
}
// Step 4: Using bind for composition โ explicit transformer style
fn get_user_email(id: i32) -> Result<Option<String>, String> {
option_t::bind(
find_user(id), // Step 1: find user
|name| find_email(&name), // Step 2: find email (only if user found)
)
// No nested match โ bind handles the three cases automatically
}
get_user_email(1); // Ok(Some("user1@example.com"))
get_user_email(0); // Ok(None) โ user not found (soft)
get_user_email(-1); // Err("Invalid ID") โ hard error
// Step 5: Idiomatic Rust โ same result, cleaner syntax
fn get_user_email_idiomatic(id: i32) -> Result<Option<String>, String> {
// ? handles Err propagation (the "hard failure" case)
let user = match find_user(id)? { // ? propagates Err
Some(u) => u,
None => return Ok(None), // explicit: propagate "not found"
};
find_email(&user)
// Both versions produce identical results โ the idiomatic one is usually preferred
}
// Step 6: map โ transform the inner value without unwrapping
fn map<A, B, E>(m: Result<Option<A>, E>, f: impl FnOnce(A) -> B) -> Result<Option<B>, E> {
match m {
Err(e) => Err(e),
Ok(None) => Ok(None),
Ok(Some(a)) => Ok(Some(f(a))), // apply f only to the Some value
}
}
// Usage:
let upper = map(get_user_email(1), |s| s.to_uppercase());
// Ok(Some("USER1@EXAMPLE.COM"))
What This Unlocks
- Principled error layering โ when a computation has exactly two failure modes (hard error + soft absence), `Result<Option<T>, E>` is the right type, and `bind`/`map` make composition clean without nested matches.
- Understanding `?` โ the `?` operator is sugar for the `Err` arm of `bind`. Knowing transformer `bind` explains why `?` works, and why you need an explicit `return Ok(None)` for the `None` arm.
- Scaling up โ if you need three stacked effects (state + error + logging), formal transformers become unwieldy and Rust's custom error enum with `?` wins. Understanding transformers shows exactly where that threshold is.
Key Differences
| Concept | OCaml | Rust |
|---|---|---|
| OptionT type | Type alias `type ('a, 'e) option_t = ('a option, 'e) result` | `type OptionT<A, E> = Result<Option<A>, E>` |
| `bind` | Pattern match + `>>=` operator | `match` with three arms โ no HKT so no generic `bind` |
| Method syntax | Module functions: `OptionT.bind`, `OptionT.pure` | Free functions in a `mod option_t {}` module (can't add methods to type aliases) |
| Lifting | `lift_result : ('a, 'e) result -> ('a, 'e) option_t` | `lift_result(r: Result<A,E>) -> Result<Option<A>,E>` โ same logic |
| Idiomatic alternative | Formal transformers are natural in OCaml's type class style | `?` + `return Ok(None)` is cleaner in Rust โ prefer it over formal transformers in production code |
// Example 063: Monad Transformers
// Stacking monads: Option inside Result
// OptionT<E, A> = Result<Option<A>, E>
type OptionT<A, E> = Result<Option<A>, E>;
// Approach 1: Helper functions for OptionT
mod option_t {
pub fn pure<A, E>(a: A) -> Result<Option<A>, E> {
Ok(Some(a))
}
pub fn none<A, E>() -> Result<Option<A>, E> {
Ok(None)
}
pub fn bind<A, B, E>(
m: Result<Option<A>, E>,
f: impl FnOnce(A) -> Result<Option<B>, E>,
) -> Result<Option<B>, E> {
match m {
Err(e) => Err(e),
Ok(None) => Ok(None),
Ok(Some(a)) => f(a),
}
}
pub fn map<A, B, E>(
m: Result<Option<A>, E>,
f: impl FnOnce(A) -> B,
) -> Result<Option<B>, E> {
match m {
Err(e) => Err(e),
Ok(None) => Ok(None),
Ok(Some(a)) => Ok(Some(f(a))),
}
}
pub fn lift_result<A, E>(r: Result<A, E>) -> Result<Option<A>, E> {
r.map(Some)
}
pub fn lift_option<A, E>(o: Option<A>) -> Result<Option<A>, E> {
Ok(o)
}
}
// Approach 2: Database operations
fn find_user(id: i32) -> OptionT<String, String> {
if id > 0 {
Ok(Some(format!("User_{}", id)))
} else if id == 0 {
Ok(None)
} else {
Err("Invalid ID".to_string())
}
}
fn find_email(name: &str) -> OptionT<String, String> {
match name {
"User_1" => Ok(Some("user1@example.com".to_string())),
"User_2" => Ok(None),
_ => Err("DB connection failed".to_string()),
}
}
fn get_user_email(id: i32) -> OptionT<String, String> {
option_t::bind(find_user(id), |name| find_email(&name))
}
// Approach 3: Using ? with nested unwrapping (idiomatic Rust)
fn get_user_email_idiomatic(id: i32) -> Result<Option<String>, String> {
let user = match find_user(id)? {
Some(u) => u,
None => return Ok(None),
};
find_email(&user)
}
fn main() {
println!("User 1 email: {:?}", get_user_email(1));
println!("User 0 email: {:?}", get_user_email(0));
println!("User -1 email: {:?}", get_user_email(-1));
println!("Idiomatic: {:?}", get_user_email_idiomatic(1));
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_found_with_email() {
assert_eq!(get_user_email(1), Ok(Some("user1@example.com".to_string())));
}
#[test]
fn test_user_not_found() {
assert_eq!(get_user_email(0), Ok(None));
}
#[test]
fn test_invalid_id() {
assert_eq!(get_user_email(-1), Err("Invalid ID".to_string()));
}
#[test]
fn test_user_no_email() {
assert_eq!(get_user_email(2), Ok(None));
}
#[test]
fn test_map() {
let upper = option_t::map(get_user_email(1), |s| s.to_uppercase());
assert_eq!(upper, Ok(Some("USER1@EXAMPLE.COM".to_string())));
}
#[test]
fn test_lift_result() {
assert_eq!(option_t::lift_result::<_, String>(Ok(42)), Ok(Some(42)));
assert_eq!(option_t::lift_result::<i32, _>(Err("e".to_string())), Err("e".to_string()));
}
#[test]
fn test_lift_option() {
assert_eq!(option_t::lift_option::<_, String>(Some(42)), Ok(Some(42)));
assert_eq!(option_t::lift_option::<i32, String>(None), Ok(None));
}
#[test]
fn test_idiomatic_same_results() {
for id in [-1, 0, 1, 2] {
assert_eq!(get_user_email(id), get_user_email_idiomatic(id));
}
}
}
(* Example 063: Monad Transformers *)
(* Stacking monads: OptionT over Result *)
(* OptionT wraps Result<Option<'a>, 'e> *)
type ('a, 'e) option_t = ('a option, 'e) result
let return_ x : ('a, 'e) option_t = Ok (Some x)
let fail e : ('a, 'e) option_t = Error e
let none : ('a, 'e) option_t = Ok None
let bind (m : ('a, 'e) option_t) (f : 'a -> ('b, 'e) option_t) : ('b, 'e) option_t =
match m with
| Error e -> Error e
| Ok None -> Ok None
| Ok (Some a) -> f a
let ( >>= ) = bind
(* Approach 1: Database operations that may fail or return nothing *)
let find_user id : (string, string) option_t =
if id > 0 then Ok (Some (Printf.sprintf "User_%d" id))
else if id = 0 then Ok None
else Error "Invalid ID"
let find_email name : (string, string) option_t =
if name = "User_1" then Ok (Some "user1@example.com")
else if name = "User_2" then Ok None (* exists but no email *)
else Error "DB connection failed"
let get_user_email id =
find_user id >>= fun name ->
find_email name
(* Approach 2: Lifting from inner monads *)
let lift_result (r : ('a, 'e) result) : ('a, 'e) option_t =
Result.map (fun x -> Some x) r
let lift_option (o : 'a option) : ('a, 'e) option_t =
Ok o
(* Approach 3: Combining with map *)
let map f m = bind m (fun x -> return_ (f x))
let get_upper_email id =
map String.uppercase_ascii (get_user_email id)
let () =
assert (get_user_email 1 = Ok (Some "user1@example.com"));
assert (get_user_email 0 = Ok None);
assert (get_user_email (-1) = Error "Invalid ID");
assert (get_user_email 2 = Ok None);
assert (get_upper_email 1 = Ok (Some "USER1@EXAMPLE.COM"));
assert (lift_result (Ok 42) = Ok (Some 42));
assert (lift_result (Error "e") = (Error "e" : (int, string) option_t));
assert (lift_option (Some 42) = Ok (Some 42));
assert (lift_option None = (Ok None : (int, string) option_t));
Printf.printf "โ All tests passed\n"
๐ Detailed Comparison
Comparison: Monad Transformers
OptionT Bind
OCaml:
๐ช Show OCaml equivalent
let bind m f = match m with
| Error e -> Error e
| Ok None -> Ok None
| Ok (Some a) -> f a
Rust:
fn bind<A, B, E>(m: Result<Option<A>, E>, f: impl FnOnce(A) -> Result<Option<B>, E>) -> Result<Option<B>, E> {
match m {
Err(e) => Err(e),
Ok(None) => Ok(None),
Ok(Some(a)) => f(a),
}
}Chained OptionT
OCaml:
๐ช Show OCaml equivalent
find_user id >>= fun name -> find_email name
Rust (transformer):
option_t::bind(find_user(id), |name| find_email(&name))Rust (idiomatic with ? and early return):
fn get_user_email(id: i32) -> Result<Option<String>, String> {
let user = match find_user(id)? {
Some(u) => u,
None => return Ok(None), // early exit for "not found"
};
find_email(&user)
}Lifting
OCaml:
๐ช Show OCaml equivalent
let lift_result r = Result.map (fun x -> Some x) r
let lift_option o = Ok o
Rust:
fn lift_result<A, E>(r: Result<A, E>) -> Result<Option<A>, E> { r.map(Some) }
fn lift_option<A, E>(o: Option<A>) -> Result<Option<A>, E> { Ok(o) }