062: Writer Monad
Difficulty: 3 Level: Advanced Accumulate a log or audit trail alongside a computation without passing a log buffer to every function.The Problem This Solves
You're building a computation pipeline. You want to log what happened at each step โ for debugging, for auditing, for observability. The simplest approach: add `log: &mut Vec<String>` to every function signature.fn add(x: i32, y: i32, log: &mut Vec<String>) -> i32 {
log.push(format!("Adding {} + {} = {}", x, y, x + y));
x + y
}
fn multiply(x: i32, y: i32, log: &mut Vec<String>) -> i32 {
log.push(format!("Multiplying {} * {} = {}", x, y, x * y));
x * y
}
fn compute(a: i32, b: i32, c: i32, log: &mut Vec<String>) -> i32 {
let sum = add(a, b, log);
let product = multiply(sum, c, log);
product
}
This works. But now the log buffer is tangled into every function signature. Your `add` function is no longer a pure math operation โ it has a side effect dependency. Testing `add` in isolation requires creating a log buffer even if you don't care about the log in that test. Refactoring from `Vec<String>` to a structured log type means touching every function signature.
The Writer monad decouples the accumulation from the computation. Each function returns `(value, log_entries)` โ a pair. The Writer wrapper automatically merges the log entries as you chain functions, so you never thread `&mut log` through anything. The final result carries the complete log, and you read it off at the end.
Conceptually: instead of `fn f(x) -> A`, every step is `fn f(x) -> (A, Log)`. Writer makes chaining these pairs clean. This exists to solve exactly that pain.
The Intuition
Imagine every step of your computation is a machine that produces two outputs: the result and a sticky note describing what it did. You chain the machines: the result of machine A flows into machine B, but the sticky notes from both get stacked together. At the end, you have the final result and a complete stack of sticky notes describing everything that happened.[Machine A: add 3+4] โ value: 7, note: "Adding 3 + 4 = 7"
[Machine B: mul 7ร2] โ value: 14, note: "Multiplying 7 * 2 = 14"
Combined result: value=14, log=["Adding 3 + 4 = 7", "Multiplying 7 * 2 = 14"]
You never had to carry the sticky note stack manually โ the Writer chain handled it.
Jargon decoded:
- Writer monad โ a wrapper around `(value, log)` pairs with a `bind` that automatically merges logs
- `pure(x)` โ wrap `x` with an empty log
- `tell(msg)` โ produce no value (`()`), but emit a log entry
- `and_then` โ chain two `Writer` values: result flows forward, logs get merged
- Monoid โ anything you can "append" to itself with an identity (empty) element. `Vec<String>` is a monoid: you can extend it with more entries, and empty is `vec![]`. The Writer monad works with any monoid as the log type.
How It Works in Rust
// Writer carries both a value and an accumulated log
#[derive(Debug, Clone)]
struct Writer<A> {
value: A,
log: Vec<String>,
}
impl<A> Writer<A> {
// Wrap a plain value โ log starts empty
fn pure(a: A) -> Self {
Writer { value: a, log: vec![] }
}
// Emit a log entry โ value is ()
fn tell(msg: String) -> Writer<()> {
Writer { value: (), log: vec![msg] }
}
// Chain: run f on value, merge both logs together
fn and_then<B>(self, f: impl FnOnce(A) -> Writer<B>) -> Writer<B> {
let Writer { value: b, log: log2 } = f(self.value);
let mut log = self.log;
log.extend(log2); // merge: self's log + f's log
Writer { value: b, log }
}
// Transform value without changing the log
fn map<B>(self, f: impl FnOnce(A) -> B) -> Writer<B> {
Writer { value: f(self.value), log: self.log }
}
}
// Building a logged computation:
fn add_with_log(x: i32, y: i32) -> Writer<i32> {
Writer::tell(format!("Adding {} + {}", x, y))
.and_then(move |()| {
let sum = x + y;
Writer::tell(format!("Result: {}", sum))
.map(move |()| sum) // attach the value after logging
})
}
fn multiply_with_log(x: i32, y: i32) -> Writer<i32> {
Writer::tell(format!("Multiplying {} * {}", x, y))
.map(move |()| x * y) // log first, then produce value
}
// Chain them: logs accumulate automatically
fn computation() -> Writer<i32> {
add_with_log(3, 4)
.and_then(|sum| multiply_with_log(sum, 2))
.and_then(|product| {
Writer::tell("Done!".to_string()).map(move |()| product)
})
}
let w = computation();
println!("Result: {}", w.value); // 14
println!("Log: {:?}", w.log);
// ["Adding 3 + 4", "Result: 7", "Multiplying 7 * 2", "Done!"]
// All steps logged, nothing passed manually
// Writer as a general accumulator โ collect filtered values:
fn gather_evens(xs: &[i32]) -> Writer<()> {
xs.iter().fold(Writer::pure(()), |acc, &x| {
acc.and_then(move |()| {
if x % 2 == 0 {
Writer { value: (), log: vec![format!("{}", x)] }
} else {
Writer::pure(()) // odd numbers contribute nothing
}
})
})
}
let evens = gather_evens(&[1, 2, 3, 4, 5, 6]);
println!("{:?}", evens.log); // ["2", "4", "6"]
// Writer used as a collector, not just a logger
What This Unlocks
- Audit trails: Wrap business logic steps in Writer. The returned log is a complete record of every decision made, ready for serialization or display โ without a logging framework or global logger.
- Computation explanation: An interpreter or calculator can return `(result, Vec<StepExplanation>)` via Writer, letting the caller show the work (like a calculator's history display) without the interpreter knowing anything about UI.
- Filtered collection: Use Writer's log as an output channel for values that might be emitted at each step โ like a stream-to-batch collector. Each step either emits to the log or doesn't, and the chain collects everything.
Key Differences
| Concept | OCaml | Rust |
|---|---|---|
| Type | `type ('a, 'w) writer = 'a 'w` (tuple, no wrapper needed) | `struct Writer<A> { value: A, log: Vec<String> }` |
| Log appending | `@` operator (list concatenation, immutable) | `Vec::extend` (mutate-in-place, more efficient) |
| Generic log type | `('a, 'w) writer` where `'w` is any monoid | Typically specialized to `Vec<String>`; generic version needs a `Monoid` trait (not in stdlib) |
| `tell` | `tell msg = ((), [msg])` | `Writer::tell(msg)` โ creates `Writer { value: (), log: vec![msg] }` |
| Ownership of log | Immutable lists shared freely via GC | Log vector is moved* through the chain โ each `extend` consumes the old log |
| Stdlib support? | No | No โ must implement; `tracing` crate solves this in production |
// Example 062: Writer Monad
// Accumulate a log alongside computation results
// Approach 1: Writer struct with Vec<String> log
#[derive(Debug, Clone)]
struct Writer<A> {
value: A,
log: Vec<String>,
}
impl<A> Writer<A> {
fn pure(a: A) -> Self {
Writer { value: a, log: vec![] }
}
fn and_then<B>(self, f: impl FnOnce(A) -> Writer<B>) -> Writer<B> {
let Writer { value: b, log: log2 } = f(self.value);
let mut log = self.log;
log.extend(log2);
Writer { value: b, log }
}
fn map<B>(self, f: impl FnOnce(A) -> B) -> Writer<B> {
Writer { value: f(self.value), log: self.log }
}
}
/// tell as a free function returning Writer<()>
fn tell(msg: String) -> Writer<()> {
Writer { value: (), log: vec![msg] }
}
fn add_with_log(x: i32, y: i32) -> Writer<i32> {
tell(format!("Adding {} + {}", x, y))
.and_then(move |()| {
let sum = x + y;
tell(format!("Result: {}", sum))
.map(move |()| sum)
})
}
fn multiply_with_log(x: i32, y: i32) -> Writer<i32> {
tell(format!("Multiplying {} * {}", x, y))
.map(move |()| x * y)
}
fn computation() -> Writer<i32> {
add_with_log(3, 4)
.and_then(|sum| multiply_with_log(sum, 2))
.and_then(|product| {
tell("Done!".to_string()).map(move |()| product)
})
}
// Approach 2: Generic Writer with any monoid-like log
#[derive(Debug)]
struct WriterG<W, A> {
value: A,
log: W,
}
impl<A> WriterG<String, A> {
fn str_pure(a: A) -> Self {
WriterG { value: a, log: String::new() }
}
fn str_bind<B>(self, f: impl FnOnce(A) -> WriterG<String, B>) -> WriterG<String, B> {
let w2 = f(self.value);
WriterG { value: w2.value, log: self.log + &w2.log }
}
}
fn str_tell(msg: &str) -> WriterG<String, ()> {
WriterG { value: (), log: msg.to_string() }
}
// Approach 3: Collect values (Writer as accumulator)
fn gather_evens(xs: &[i32]) -> Writer<()> {
xs.iter().fold(Writer::pure(()), |acc, &x| {
acc.and_then(move |()| {
if x % 2 == 0 {
Writer { value: (), log: vec![format!("{}", x)] }
} else {
Writer::pure(())
}
})
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_computation() {
let w = computation();
assert_eq!(w.value, 14);
assert_eq!(w.log.len(), 4);
assert!(w.log[0].contains("Adding 3 + 4"));
}
#[test]
fn test_pure_empty_log() {
let w: Writer<i32> = Writer::pure(42);
assert_eq!(w.value, 42);
assert!(w.log.is_empty());
}
#[test]
fn test_tell() {
let w = tell("hello".into());
assert_eq!(w.log, vec!["hello"]);
}
#[test]
fn test_gather_evens() {
let w = gather_evens(&[1, 2, 3, 4, 5, 6]);
assert_eq!(w.log, vec!["2", "4", "6"]);
}
#[test]
fn test_map() {
let w = Writer::pure(5).map(|x: i32| x * 2);
assert_eq!(w.value, 10);
assert!(w.log.is_empty());
}
#[test]
fn test_and_then_combines_logs() {
let w = tell("a".into())
.and_then(|()| tell("b".into()));
assert_eq!(w.log, vec!["a", "b"]);
}
}
(* Example 062: Writer Monad *)
(* Accumulate a log alongside computation results *)
type ('w, 'a) writer = Writer of ('a * 'w)
let run_writer (Writer (a, w)) = (a, w)
let return_ x = Writer (x, [])
let bind (Writer (a, w1)) f =
let Writer (b, w2) = f a in
Writer (b, w1 @ w2)
let ( >>= ) = bind
let tell w = Writer ((), [w])
(* Approach 1: Logging computation steps *)
let add_with_log x y =
tell (Printf.sprintf "Adding %d + %d" x y) >>= fun () ->
let sum = x + y in
tell (Printf.sprintf "Result: %d" sum) >>= fun () ->
return_ sum
let multiply_with_log x y =
tell (Printf.sprintf "Multiplying %d * %d" x y) >>= fun () ->
return_ (x * y)
let computation =
add_with_log 3 4 >>= fun sum ->
multiply_with_log sum 2 >>= fun product ->
tell "Done!" >>= fun () ->
return_ product
(* Approach 2: Writer with monoid (string concatenation) *)
type 'a str_writer = StrWriter of ('a * string)
let str_tell s = StrWriter ((), s)
let str_return x = StrWriter (x, "")
let str_bind (StrWriter (a, w1)) f =
let StrWriter (b, w2) = f a in
StrWriter (b, w1 ^ w2)
(* Approach 3: Writer for collecting values *)
let collect x = Writer ((), [x])
let gather_evens xs =
List.fold_left (fun acc x ->
acc >>= fun () ->
if x mod 2 = 0 then collect x
else return_ ()
) (return_ ()) xs
let () =
let (result, log) = run_writer computation in
assert (result = 14);
assert (List.length log = 3);
assert (List.hd log = "Adding 3 + 4");
let ((), evens) = run_writer (gather_evens [1;2;3;4;5;6]) in
assert (evens = [2; 4; 6]);
Printf.printf "โ All tests passed\n"
๐ Detailed Comparison
Comparison: Writer Monad
Writer Type
OCaml:
๐ช Show OCaml equivalent
type ('w, 'a) writer = Writer of ('a * 'w)
let tell w = Writer ((), [w])
let return_ x = Writer (x, [])
Rust:
struct Writer<A> { value: A, log: Vec<String> }
impl<A> Writer<A> {
fn pure(a: A) -> Self { Writer { value: a, log: vec![] } }
fn tell(msg: String) -> Writer<()> { Writer { value: (), log: vec![msg] } }
}Bind (Log Accumulation)
OCaml:
๐ช Show OCaml equivalent
let bind (Writer (a, w1)) f =
let Writer (b, w2) = f a in
Writer (b, w1 @ w2) (* list append *)
Rust:
fn and_then<B>(self, f: impl FnOnce(A) -> Writer<B>) -> Writer<B> {
let w2 = f(self.value);
let mut log = self.log;
log.extend(w2.log); // vec extend
Writer { value: w2.value, log }
}Logged Computation
OCaml:
๐ช Show OCaml equivalent
add_with_log 3 4 >>= fun sum ->
multiply_with_log sum 2 >>= fun product ->
tell "Done!" >>= fun () ->
return_ product
Rust:
add_with_log(3, 4)
.and_then(|sum| multiply_with_log(sum, 2))
.and_then(|product| Writer::tell("Done!".into()).map(move |()| product))