ExamplesBy LevelBy TopicLearning Paths
860 Expert

Reader Monad

Functional Programming

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

  • • Understand Reader<R, A> as a wrapper around Fn(&R) -> A
  • • Implement ask() which retrieves the full environment
  • • Implement local(f, reader) which runs a computation with a modified environment
  • • Implement monadic bind: reader.then(|a| next_reader) composing environment-reading computations
  • • Recognize the connection to dependency injection: Reader monad = explicit DI in types
  • Code 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

    AspectRustOCaml
    TypeBox<dyn FnOnce(&R) -> A>Reader of ('r -> 'a)
    Environment accessask() returns R: Cloneask = Reader Fun.id
    Field extractionasks(|env| env.field)asks = Reader f
    Local modificationlocal(f, reader)let local f (Reader g)
    Lifetime'a ties to env lifetimeNo lifetime issues
    DI frameworkReader monad + trait objectsReader 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);
        }
    }
    ✓ Tests Rust test suite
    #[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

  • Implement monadic bind for Reader and compose three configuration-reading computations without explicit passing.
  • Use local to run a subcomputation with a modified environment: test a function with a mock environment.
  • Implement a database query builder where Reader's environment is the database connection.
  • Compare Reader monad with Rust's conventional approach of passing &Config explicitly — show when Reader adds value.
  • Implement sequence_readers(readers: Vec<Reader<R, A>>) -> Reader<R, Vec<A>> that combines multiple readers.
  • Open Source Repos