Reader Monad
Tutorial
The Problem
Functions that need a shared configuration or environment — database connection, logger, feature flags, request context — pass it as the first argument. As this environment grows or more functions need it, threading it explicitly becomes tedious and brittle. The Reader monad encapsulates this: Reader<R, A> represents a computation R -> A that reads from environment R and produces A. Computations are composed without explicit environment passing — the monad threads it automatically. This is dependency injection made explicit in the type system. It appears in: web request handlers, database query builders, configuration-driven computations, and compiler passes reading symbol tables.
🎯 Learning Outcomes
Reader<R, A> as a wrapper around Fn(&R) -> Aask() which retrieves the full environmentlocal(f, reader) which runs a computation with a modified environmentreader.then(|a| next_reader) composing environment-reading computationsCode Example
struct Reader<R, A> { run: Box<dyn FnOnce(&R) -> A> }
fn ask<R: Clone>() -> Reader<R, R> { Reader::new(|env| env.clone()) }
fn asks<R, A>(f: impl FnOnce(&R) -> A) -> Reader<R, A> { Reader::new(f) }Key Differences
| Aspect | Rust | OCaml |
|---|---|---|
| Type | Box<dyn FnOnce(&R) -> A> | Reader of ('r -> 'a) |
| Environment access | ask() returns R: Clone | ask = Reader Fun.id |
| Field extraction | asks(|env| env.field) | asks = Reader f |
| Local modification | local(f, reader) | let local f (Reader g) |
| Lifetime | 'a ties to env lifetime | No lifetime issues |
| DI framework | Reader monad + trait objects | Reader monad or GADTs |
OCaml Approach
OCaml's Reader: type ('r, 'a) reader = Reader of ('r -> 'a). run_reader (Reader f) env = f env. ask = Reader (fun env -> env). asks f = Reader f. Monadic bind: let bind (Reader f) k = Reader (fun env -> let a = f env in let Reader g = k a in g env). OCaml's partial application makes asks cleaner: asks = Fun.id |> Reader. The local function: let local f (Reader g) = Reader (fun env -> g (f env)) runs with a modified environment.
Full Source
#![allow(clippy::all)]
// Example 061: Reader Monad
// Dependency injection via implicit environment passing
// Approach 1: Reader as a wrapper struct
struct Reader<'a, R, A> {
run: Box<dyn FnOnce(&R) -> A + 'a>,
}
impl<'a, R: 'a, A: 'a> Reader<'a, R, A> {
fn new(f: impl FnOnce(&R) -> A + 'a) -> Self {
Reader { run: Box::new(f) }
}
fn run(self, env: &R) -> A {
(self.run)(env)
}
fn map<B: 'a>(self, f: impl FnOnce(A) -> B + 'a) -> Reader<'a, R, B> {
Reader::new(move |env| f(self.run(env)))
}
fn and_then<B: 'a>(self, f: impl FnOnce(A) -> Reader<'a, R, B> + 'a) -> Reader<'a, R, B> {
Reader::new(move |env: &R| {
// We need to read env twice, so we use a pointer trick
let env_ptr = env as *const R;
let a = self.run(env);
f(a).run(unsafe { &*env_ptr })
})
}
}
fn ask<'a, R: 'a + Clone>() -> Reader<'a, R, R> {
Reader::new(|env: &R| env.clone())
}
fn asks<'a, R: 'a, A: 'a>(f: impl FnOnce(&R) -> A + 'a) -> Reader<'a, R, A> {
Reader::new(f)
}
// Approach 2: Closures as readers (idiomatic Rust)
struct Config {
db_host: String,
db_port: u16,
debug: bool,
}
fn get_connection_string(config: &Config) -> String {
format!("{}:{}", config.db_host, config.db_port)
}
fn get_log_prefix(config: &Config) -> &str {
if config.debug {
"[DEBUG] "
} else {
"[INFO] "
}
}
fn format_message(msg: &str, config: &Config) -> String {
format!(
"{}{} (connected to {})",
get_log_prefix(config),
msg,
get_connection_string(config)
)
}
// Approach 3: Trait-based dependency injection (most idiomatic Rust)
trait HasDb {
fn db_url(&self) -> String;
}
trait HasLogger {
fn log_prefix(&self) -> &str;
}
impl HasDb for Config {
fn db_url(&self) -> String {
format!("{}:{}", self.db_host, self.db_port)
}
}
impl HasLogger for Config {
fn log_prefix(&self) -> &str {
if self.debug {
"[DEBUG] "
} else {
"[INFO] "
}
}
}
fn format_msg_generic<E: HasDb + HasLogger>(msg: &str, env: &E) -> String {
format!(
"{}{} (connected to {})",
env.log_prefix(),
msg,
env.db_url()
)
}
#[cfg(test)]
mod tests {
use super::*;
fn test_config(debug: bool) -> Config {
Config {
db_host: "localhost".into(),
db_port: 5432,
debug,
}
}
#[test]
fn test_format_message_debug() {
let cfg = test_config(true);
assert_eq!(
format_message("Starting", &cfg),
"[DEBUG] Starting (connected to localhost:5432)"
);
}
#[test]
fn test_format_message_info() {
let cfg = test_config(false);
assert_eq!(
format_message("Starting", &cfg),
"[INFO] Starting (connected to localhost:5432)"
);
}
#[test]
fn test_trait_di() {
let cfg = test_config(true);
assert_eq!(
format_msg_generic("Starting", &cfg),
"[DEBUG] Starting (connected to localhost:5432)"
);
}
#[test]
fn test_reader_asks() {
let cfg = test_config(true);
let r = asks(|c: &Config| c.debug);
assert_eq!(r.run(&cfg), true);
}
#[test]
fn test_reader_map() {
let cfg = test_config(false);
let r = asks(|c: &Config| c.db_port).map(|p| p * 2);
assert_eq!(r.run(&cfg), 10864);
}
}#[cfg(test)]
mod tests {
use super::*;
fn test_config(debug: bool) -> Config {
Config {
db_host: "localhost".into(),
db_port: 5432,
debug,
}
}
#[test]
fn test_format_message_debug() {
let cfg = test_config(true);
assert_eq!(
format_message("Starting", &cfg),
"[DEBUG] Starting (connected to localhost:5432)"
);
}
#[test]
fn test_format_message_info() {
let cfg = test_config(false);
assert_eq!(
format_message("Starting", &cfg),
"[INFO] Starting (connected to localhost:5432)"
);
}
#[test]
fn test_trait_di() {
let cfg = test_config(true);
assert_eq!(
format_msg_generic("Starting", &cfg),
"[DEBUG] Starting (connected to localhost:5432)"
);
}
#[test]
fn test_reader_asks() {
let cfg = test_config(true);
let r = asks(|c: &Config| c.debug);
assert_eq!(r.run(&cfg), true);
}
#[test]
fn test_reader_map() {
let cfg = test_config(false);
let r = asks(|c: &Config| c.db_port).map(|p| p * 2);
assert_eq!(r.run(&cfg), 10864);
}
}
Deep Comparison
Comparison: Reader Monad
Reader Type
OCaml:
type ('r, 'a) reader = Reader of ('r -> 'a)
let ask = Reader (fun env -> env)
let asks f = Reader (fun env -> f env)
Rust:
struct Reader<R, A> { run: Box<dyn FnOnce(&R) -> A> }
fn ask<R: Clone>() -> Reader<R, R> { Reader::new(|env| env.clone()) }
fn asks<R, A>(f: impl FnOnce(&R) -> A) -> Reader<R, A> { Reader::new(f) }
Environment-Based Computation
OCaml:
let format_message msg =
asks (fun c -> if c.debug then "[DEBUG] " else "[INFO] ") >>= fun prefix ->
asks (fun c -> c.db_host ^ ":" ^ string_of_int c.db_port) >>= fun conn ->
return_ (prefix ^ msg ^ " (" ^ conn ^ ")")
Rust (idiomatic — just pass &Config):
fn format_message(msg: &str, config: &Config) -> String {
format!("{}{} (connected to {}:{})",
if config.debug { "[DEBUG] " } else { "[INFO] " },
msg, config.db_host, config.db_port)
}
Rust (trait-based DI):
trait HasDb { fn db_url(&self) -> String; }
trait HasLogger { fn log_prefix(&self) -> &str; }
fn format_msg<E: HasDb + HasLogger>(msg: &str, env: &E) -> String {
format!("{}{} (connected to {})", env.log_prefix(), msg, env.db_url())
}
Exercises
local to run a subcomputation with a modified environment: test a function with a mock environment.&Config explicitly — show when Reader adds value.sequence_readers(readers: Vec<Reader<R, A>>) -> Reader<R, Vec<A>> that combines multiple readers.