Monad Transformers
Tutorial
The Problem
Real programs need multiple effects simultaneously: a computation might be fallible (Option/Result), AND need to read configuration (Reader), AND accumulate a log (Writer). Composing two monads directly doesn't work — Option<Reader<Config, A>> and Reader<Config, Option<A>> require different bind implementations. Monad transformers solve this by stacking effects: OptionT<E, A> = Result<Option<A>, E> adds optionality to Result. This enables writing code that handles both "not found" (None) and "failed" (Err) without explicit nesting. Used in: web frameworks (request handler: Result for errors, Option for optional fields, Reader for context), compiler pipelines, and effectful DSLs.
🎯 Learning Outcomes
OptionT<A, E> = Result<Option<A>, E> as Option inside Resultlift_option and lift_result to promote values into the transformerErr short-circuits, Ok(None) propagates absenceResult<Option<User>, DbError> for "not found" vs. "db failed"Code Example
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),
}
}Key Differences
| Aspect | Rust | OCaml |
|---|---|---|
| Type | type OptionT<A,E> = Result<Option<A>,E> | Algebraic type or module type |
| Generic transformer | Not expressible (no HKT) | Module functor OptionT(M: MONAD) |
| Bind | Free function bind_opt | Method or functor-generated |
lift_result | r.map(Some) | Result.map Option.some r |
| Three-way match | match ma { Err, Ok(None), Ok(Some) } | Same |
| Usage | Type alias + functions | Module with t, bind, etc. |
OCaml Approach
OCaml's OptionT module: type ('a, 'e) t = ('a option, 'e) result. The bind: let bind ma f = match ma with Error e -> Error e | Ok None -> Ok None | Ok (Some a) -> f a. lift_result r = Result.map Option.some r. lift_option o = Ok o. OCaml module functors parameterize over the base monad: module OptionT(M: MONAD) = struct ... generates the transformer for any monad M. This generality requires HKTs, which OCaml achieves through module functors.
Full Source
#![allow(clippy::all)]
// 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)
}
#[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));
}
}
}#[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));
}
}
}
Deep Comparison
Comparison: Monad Transformers
OptionT Bind
OCaml:
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:
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:
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) }
Exercises
ResultT<A, E, W> = Writer<Result<A, E>, W> — a computation that can fail AND accumulates a log.OptionT<A, E> to implement a database lookup that distinguishes "not found" from "database error."sequence_opt(ops: Vec<OptionT<A, E>>) -> OptionT<Vec<A>, E> that collects all results if none are absent or erroneous.Result<Option<A>, E> with manual match) vs. OptionT's bind_opt for a multi-step pipeline.ReaderT<R, A, E> = Reader<R, Result<A, E>> and implement its bind operation.