๐Ÿฆ€ Functional Rust

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: The transformer, `OptionT`, wraps one monad around another:
OptionT<E, A> = Result<Option<A>, E>
Operations on this type need to handle three cases:
CaseMeaning
`Err(e)`Hard failure โ€” stop everything
`Ok(None)`Soft absence โ€” propagate "not found"
`Ok(Some(a))`Success โ€” continue with `a`
The key insight: `bind` (the `and_then` of this combined monad) threads through all three cases automatically:
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

Key Differences

ConceptOCamlRust
OptionT typeType 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 syntaxModule 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 alternativeFormal 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) }