ExamplesBy LevelBy TopicLearning Paths
1079 Expert

Writer Monad — Logging Computation

Monadic Patterns

Tutorial Video

Text description (accessibility)

This video demonstrates the "Writer Monad — Logging Computation" functional Rust example. Difficulty level: Expert. Key concepts covered: Monadic Patterns. Implement a Writer monad that accumulates a log of messages alongside a computation. Key difference from OCaml: 1. **Operator overloading:** OCaml defines `>>=` easily; Rust uses method chaining instead (operator overloading is possible but less ergonomic for monads)

Tutorial

The Problem

Implement a Writer monad that accumulates a log of messages alongside a computation. Chain operations that produce both a result and log entries, combining them transparently.

🎯 Learning Outcomes

  • • Writer monad as a pattern for structured logging without side effects
  • • Monadic bind in Rust via method chaining vs OCaml's >>= operator
  • • Generic monoid abstraction for the log type
  • • How Rust's ownership makes bind naturally consume the previous state
  • 🦀 The Rust Way

    Rust implements Writer as a generic struct with bind and map methods. Method chaining (.bind(half).bind(...)) replaces OCaml's >>= operator. A generic version parameterized by a Monoid trait shows how the pattern generalizes beyond Vec<String>.

    Code Example

    pub struct Writer<A> {
        pub value: A,
        pub log: Vec<String>,
    }
    
    impl<A> Writer<A> {
        pub fn new(value: A) -> Self { Writer { value, log: Vec::new() } }
    
        pub fn bind<B, F>(self, f: F) -> Writer<B>
        where F: FnOnce(A) -> Writer<B> {
            let mut result = f(self.value);
            let mut combined = self.log;
            combined.append(&mut result.log);
            Writer { value: result.value, log: combined }
        }
    }
    
    pub fn compute(x: i64) -> Writer<i64> {
        Writer::new(x)
            .bind(half)
            .bind(|n| tell(format!("result is {n}")).map(|()| n))
    }

    Key Differences

  • Operator overloading: OCaml defines >>= easily; Rust uses method chaining instead (operator overloading is possible but less ergonomic for monads)
  • Ownership: Rust's bind consumes self, making it clear the old Writer is gone. OCaml's bind copies/shares the log via GC
  • Monoid abstraction: OCaml uses @ (list append) directly; Rust can abstract over the log type with a Monoid trait
  • Type inference: OCaml infers everything from usage; Rust sometimes needs explicit type annotations on closures
  • OCaml Approach

    OCaml defines Writer as a record with value and log fields. The >>= operator (bind) applies a function to the value and concatenates the logs. tell creates a log-only entry. The pipeline reads naturally with >>= and fun closures.

    Full Source

    #![allow(clippy::all)]
    //! Writer Monad — Logging Computation
    //!
    //! The Writer monad accumulates a log alongside a computation.
    //! In OCaml, this is a record `{ value: 'a; log: string list }`.
    //! In Rust, we use a generic struct and implement monadic operations.
    
    // ── Solution 1: Idiomatic Rust — struct with method chaining ──
    
    /// A Writer that carries a value and a log of messages.
    /// OCaml: `type 'a writer = { value: 'a; log: string list }`
    #[derive(Debug, Clone, PartialEq)]
    pub struct Writer<A> {
        pub value: A,
        pub log: Vec<String>,
    }
    
    impl<A> Writer<A> {
        /// Wrap a value with an empty log (monadic return/pure).
        /// OCaml: `let return x = { value = x; log = [] }`
        pub fn new(value: A) -> Self {
            Writer {
                value,
                log: Vec::new(),
            }
        }
    
        /// Monadic bind: apply a function to the value, combining logs.
        /// OCaml: `let bind w f = let w' = f w.value in { value = w'.value; log = w.log @ w'.log }`
        pub fn bind<B, F>(self, f: F) -> Writer<B>
        where
            F: FnOnce(A) -> Writer<B>,
        {
            let mut result = f(self.value);
            let mut combined_log = self.log;
            combined_log.append(&mut result.log);
            Writer {
                value: result.value,
                log: combined_log,
            }
        }
    
        /// Map a function over the value without adding to the log.
        /// This is the functor `fmap` operation.
        pub fn map<B, F>(self, f: F) -> Writer<B>
        where
            F: FnOnce(A) -> B,
        {
            Writer {
                value: f(self.value),
                log: self.log,
            }
        }
    }
    
    /// Add a message to the log without changing the value.
    /// OCaml: `let tell msg = { value = (); log = [msg] }`
    pub fn tell(msg: impl Into<String>) -> Writer<()> {
        Writer {
            value: (),
            log: vec![msg.into()],
        }
    }
    
    /// Half a number, logging the operation.
    /// OCaml: `let half x = { value = x / 2; log = [Printf.sprintf "halved %d to %d" x (x / 2)] }`
    pub fn half(x: i64) -> Writer<i64> {
        let result = x / 2;
        Writer {
            value: result,
            log: vec![format!("halved {x} to {result}")],
        }
    }
    
    /// The composed computation from the OCaml example.
    /// OCaml: `let compute x = return x >>= fun n -> half n >>= fun n -> tell ... >>= fun () -> return n`
    pub fn compute(x: i64) -> Writer<i64> {
        Writer::new(x)
            .bind(half)
            .bind(|n| tell(format!("result is {n}")).map(|()| n))
    }
    
    // ── Solution 2: Generic Writer with any monoid log ──
    //
    // OCaml's Writer uses `string list` but conceptually any monoid works.
    
    /// A generic writer where the log type is any type that supports append.
    #[derive(Debug, Clone, PartialEq)]
    pub struct GenericWriter<W, A> {
        pub value: A,
        pub log: W,
    }
    
    /// Trait for monoid-like types (identity + combine).
    pub trait Monoid: Default {
        fn combine(self, other: Self) -> Self;
    }
    
    impl Monoid for Vec<String> {
        fn combine(mut self, mut other: Self) -> Self {
            self.append(&mut other);
            self
        }
    }
    
    impl Monoid for String {
        fn combine(mut self, other: Self) -> Self {
            self.push_str(&other);
            self
        }
    }
    
    impl<W: Monoid, A> GenericWriter<W, A> {
        pub fn pure(value: A) -> Self {
            GenericWriter {
                value,
                log: W::default(),
            }
        }
    
        pub fn bind<B, F>(self, f: F) -> GenericWriter<W, B>
        where
            F: FnOnce(A) -> GenericWriter<W, B>,
        {
            let result = f(self.value);
            GenericWriter {
                value: result.value,
                log: self.log.combine(result.log),
            }
        }
    }
    
    // ── Solution 3: Functional composition with closures ──
    
    /// A logged computation is just a function that returns a Writer.
    /// We can compose them with `and_then`.
    pub fn and_then<A, B>(first: Writer<A>, f: impl FnOnce(A) -> Writer<B>) -> Writer<B> {
        first.bind(f)
    }
    
    #[cfg(test)]
    mod tests {
        use super::*;
    
        #[test]
        fn test_new_has_empty_log() {
            let w: Writer<i64> = Writer::new(42);
            assert_eq!(w.value, 42);
            assert!(w.log.is_empty());
        }
    
        #[test]
        fn test_tell_adds_message() {
            let w = tell("hello");
            assert_eq!(w.value, ());
            assert_eq!(w.log, vec!["hello"]);
        }
    
        #[test]
        fn test_half_logs() {
            let w = half(100);
            assert_eq!(w.value, 50);
            assert_eq!(w.log, vec!["halved 100 to 50"]);
        }
    
        #[test]
        fn test_compute_full_pipeline() {
            let result = compute(100);
            assert_eq!(result.value, 50);
            assert_eq!(result.log, vec!["halved 100 to 50", "result is 50"]);
        }
    
        #[test]
        fn test_bind_combines_logs() {
            let w = Writer::new(10).bind(half).bind(half);
            assert_eq!(w.value, 2);
            assert_eq!(w.log, vec!["halved 10 to 5", "halved 5 to 2"]);
        }
    
        #[test]
        fn test_map_preserves_log() {
            let w = half(10).map(|n| n * 3);
            assert_eq!(w.value, 15);
            assert_eq!(w.log, vec!["halved 10 to 5"]);
        }
    
        #[test]
        fn test_generic_writer_string_monoid() {
            let w: GenericWriter<String, i32> = GenericWriter::pure(42);
            let result = w.bind(|n| GenericWriter {
                value: n + 1,
                log: format!("incremented {n}; "),
            });
            assert_eq!(result.value, 43);
            assert_eq!(result.log, "incremented 42; ");
        }
    
        #[test]
        fn test_and_then_composition() {
            let result = and_then(Writer::new(20), half);
            assert_eq!(result.value, 10);
            assert_eq!(result.log, vec!["halved 20 to 10"]);
        }
    }
    ✓ Tests Rust test suite
    #[cfg(test)]
    mod tests {
        use super::*;
    
        #[test]
        fn test_new_has_empty_log() {
            let w: Writer<i64> = Writer::new(42);
            assert_eq!(w.value, 42);
            assert!(w.log.is_empty());
        }
    
        #[test]
        fn test_tell_adds_message() {
            let w = tell("hello");
            assert_eq!(w.value, ());
            assert_eq!(w.log, vec!["hello"]);
        }
    
        #[test]
        fn test_half_logs() {
            let w = half(100);
            assert_eq!(w.value, 50);
            assert_eq!(w.log, vec!["halved 100 to 50"]);
        }
    
        #[test]
        fn test_compute_full_pipeline() {
            let result = compute(100);
            assert_eq!(result.value, 50);
            assert_eq!(result.log, vec!["halved 100 to 50", "result is 50"]);
        }
    
        #[test]
        fn test_bind_combines_logs() {
            let w = Writer::new(10).bind(half).bind(half);
            assert_eq!(w.value, 2);
            assert_eq!(w.log, vec!["halved 10 to 5", "halved 5 to 2"]);
        }
    
        #[test]
        fn test_map_preserves_log() {
            let w = half(10).map(|n| n * 3);
            assert_eq!(w.value, 15);
            assert_eq!(w.log, vec!["halved 10 to 5"]);
        }
    
        #[test]
        fn test_generic_writer_string_monoid() {
            let w: GenericWriter<String, i32> = GenericWriter::pure(42);
            let result = w.bind(|n| GenericWriter {
                value: n + 1,
                log: format!("incremented {n}; "),
            });
            assert_eq!(result.value, 43);
            assert_eq!(result.log, "incremented 42; ");
        }
    
        #[test]
        fn test_and_then_composition() {
            let result = and_then(Writer::new(20), half);
            assert_eq!(result.value, 10);
            assert_eq!(result.log, vec!["halved 20 to 10"]);
        }
    }

    Deep Comparison

    OCaml vs Rust: Writer Monad — Logging Computation

    Side-by-Side Code

    OCaml

    type 'a writer = { value: 'a; log: string list }
    
    let return x = { value = x; log = [] }
    let bind w f =
      let w' = f w.value in
      { value = w'.value; log = w.log @ w'.log }
    let ( >>= ) = bind
    let tell msg = { value = (); log = [msg] }
    
    let half x =
      { value = x / 2; log = [Printf.sprintf "halved %d to %d" x (x / 2)] }
    
    let compute x =
      return x >>= fun n ->
      half n >>= fun n ->
      tell (Printf.sprintf "result is %d" n) >>= fun () ->
      return n
    

    Rust (idiomatic)

    pub struct Writer<A> {
        pub value: A,
        pub log: Vec<String>,
    }
    
    impl<A> Writer<A> {
        pub fn new(value: A) -> Self { Writer { value, log: Vec::new() } }
    
        pub fn bind<B, F>(self, f: F) -> Writer<B>
        where F: FnOnce(A) -> Writer<B> {
            let mut result = f(self.value);
            let mut combined = self.log;
            combined.append(&mut result.log);
            Writer { value: result.value, log: combined }
        }
    }
    
    pub fn compute(x: i64) -> Writer<i64> {
        Writer::new(x)
            .bind(half)
            .bind(|n| tell(format!("result is {n}")).map(|()| n))
    }
    

    Rust (generic monoid)

    pub trait Monoid: Default {
        fn combine(self, other: Self) -> Self;
    }
    
    pub struct GenericWriter<W, A> {
        pub value: A,
        pub log: W,
    }
    

    Type Signatures

    ConceptOCamlRust
    Writer typetype 'a writer = { value: 'a; log: string list }struct Writer<A> { value: A, log: Vec<String> }
    Return/pureval return : 'a -> 'a writerfn new(value: A) -> Writer<A>
    Bindval bind : 'a writer -> ('a -> 'b writer) -> 'b writerfn bind<B>(self, f: FnOnce(A) -> Writer<B>) -> Writer<B>
    Tellval tell : string -> unit writerfn tell(msg: impl Into<String>) -> Writer<()>

    Key Insights

  • **OCaml's >>= reads like a pipeline** — return x >>= half >>= ... flows left-to-right. Rust's .bind(half).bind(...) achieves the same with method chaining.
  • **Rust's self consumption is monadic by nature** — bind(self, f) takes ownership, which mirrors the monad law that each bind transforms the entire computation, not just the value.
  • Log concatenation differs — OCaml uses @ (list append, O(n)). Rust uses Vec::append which is amortized O(1) because it moves the buffer pointer.
  • The Monoid abstraction generalizes the pattern — by parameterizing the log type with a Monoid trait, Rust can use String, Vec<T>, or any accumulator. OCaml achieves this with module functors.
  • Both avoid side effects — the log is part of the return value, not a mutable global. This makes the computation pure and testable.
  • When to Use Each Style

    Use Writer monad when: You need structured, composable logging that's part of the return type — audit trails, computation traces, query plan explanations. Use simple method chaining when: You just need basic logging and the full monadic abstraction is overkill — Rust's log crate or tracing is often simpler for real applications.

    Exercises

  • Extend the Writer monad to use a generic log type that implements Monoid (not just String), and use it to accumulate a structured audit log as a Vec<LogEntry>.
  • Implement censor — a function that transforms the accumulated log entries using a provided function — and use it to redact sensitive values from a computation log.
  • Build a multi-step computation pipeline using the writer monad that tracks both a string log and a numeric performance metric (operation count) simultaneously, using a product monoid.
  • Open Source Repos