Introduction to Free Monads
Functional Programming
Tutorial
The Problem
Free monads separate the description of a computation from its execution. A program built from free monad operations is a pure data structure — a tree of instructions. Different interpreters can then execute the same program in different ways: one runs it against a real console, another tests it with mocked input/output, a third logs every operation. This separation enables dependency injection at the computation level, pure testing without mocks, and multiple execution strategies for the same program description.
🎯 Learning Outcomes
Console<A> enumPrint and GetLine operations form a tree of deferred computationsCode Example
enum Console<A> {
Pure(A),
Print(String, Box<Console<A>>),
GetLine(Box<dyn FnOnce(String) -> Console<A>>),
}Key Differences
let* syntax**: OCaml's monadic bind notation makes free monad programs readable; Rust lacks similar syntax sugar, requiring explicit closures.Box<dyn FnOnce(String) -> Console<A>> for GetLine; OCaml uses string -> 'a console — structurally identical.Functor instance on the base functor; Rust builds the monad directly as an enum without abstracting over the functor.OCaml Approach
OCaml's free monad uses GADTs for type safety:
type _ console =
| Pure : 'a -> 'a console
| Print : string * (unit -> 'a console) -> 'a console
| GetLine : (string -> 'a console) -> 'a console
let ( >>= ) : 'a console -> ('a -> 'b console) -> 'b console = ...
OCaml's let* syntax makes writing free monad programs feel almost imperative:
let program = let* () = print "Enter name: " in
let* name = get_line () in
print ("Hello, " ^ name)
Full Source
#![allow(clippy::all)]
// Example 184: Introduction to Free Monads
// Separate program description from interpretation
// === Approach 1: Free monad as enum ===
enum Console<A> {
Pure(A),
Print(String, Box<Console<A>>),
GetLine(Box<dyn FnOnce(String) -> Console<A>>),
}
impl<A> Console<A> {
fn pure(a: A) -> Self {
Console::Pure(a)
}
}
fn console_print(msg: &str) -> Console<()> {
Console::Print(msg.to_string(), Box::new(Console::Pure(())))
}
fn console_get_line() -> Console<String> {
Console::GetLine(Box::new(Console::Pure))
}
// bind (flatmap) — chain free monad computations
fn bind<A: 'static, B: 'static>(
ma: Console<A>,
f: impl FnOnce(A) -> Console<B> + 'static,
) -> Console<B> {
match ma {
Console::Pure(a) => f(a),
Console::Print(msg, next) => Console::Print(msg, Box::new(bind(*next, f))),
Console::GetLine(k) => Console::GetLine(Box::new(move |s| bind(k(s), f))),
}
}
// === Approach 2: Pure interpreter (collects output, feeds input) ===
fn interpret_pure(inputs: &[&str], prog: Console<String>) -> (Vec<String>, String) {
let mut outputs = Vec::new();
let mut input_idx = 0;
let mut current = prog;
loop {
match current {
Console::Pure(a) => return (outputs, a),
Console::Print(msg, next) => {
outputs.push(msg);
current = *next;
}
Console::GetLine(k) => {
let input = inputs.get(input_idx).unwrap_or(&"");
input_idx += 1;
current = k(input.to_string());
}
}
}
}
// === Approach 3: Builder-style DSL with macro ===
fn greet_program() -> Console<String> {
bind(console_print("What is your name?"), move |()| {
bind(console_get_line(), move |name: String| {
bind(console_print(&format!("Hello, {}!", name)), move |()| {
Console::pure(name)
})
})
})
}
// Alternate: sequence of operations without result threading
fn log_program(messages: Vec<String>) -> Console<()> {
let mut prog = Console::pure(());
// Build in reverse
for msg in messages.into_iter().rev() {
let next = prog;
prog = Console::Print(msg, Box::new(next));
}
prog
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_pure() {
let (outputs, result) = interpret_pure(&[], Console::pure("hello".to_string()));
assert!(outputs.is_empty());
assert_eq!(result, "hello");
}
#[test]
fn test_greet_alice() {
let (outputs, result) = interpret_pure(&["Alice"], greet_program());
assert_eq!(outputs, vec!["What is your name?", "Hello, Alice!"]);
assert_eq!(result, "Alice");
}
#[test]
fn test_greet_bob() {
let (outputs, result) = interpret_pure(&["Bob"], greet_program());
assert_eq!(outputs, vec!["What is your name?", "Hello, Bob!"]);
assert_eq!(result, "Bob");
}
#[test]
fn test_print_only() {
let prog = bind(console_print("hi"), |()| Console::pure("done".to_string()));
let (outputs, result) = interpret_pure(&[], prog);
assert_eq!(outputs, vec!["hi"]);
assert_eq!(result, "done");
}
#[test]
fn test_get_line() {
let prog = bind(console_get_line(), |s: String| Console::pure(s));
let (_, result) = interpret_pure(&["test"], prog);
assert_eq!(result, "test");
}
}
✓ Tests
Rust test suite
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_pure() {
let (outputs, result) = interpret_pure(&[], Console::pure("hello".to_string()));
assert!(outputs.is_empty());
assert_eq!(result, "hello");
}
#[test]
fn test_greet_alice() {
let (outputs, result) = interpret_pure(&["Alice"], greet_program());
assert_eq!(outputs, vec!["What is your name?", "Hello, Alice!"]);
assert_eq!(result, "Alice");
}
#[test]
fn test_greet_bob() {
let (outputs, result) = interpret_pure(&["Bob"], greet_program());
assert_eq!(outputs, vec!["What is your name?", "Hello, Bob!"]);
assert_eq!(result, "Bob");
}
#[test]
fn test_print_only() {
let prog = bind(console_print("hi"), |()| Console::pure("done".to_string()));
let (outputs, result) = interpret_pure(&[], prog);
assert_eq!(outputs, vec!["hi"]);
assert_eq!(result, "done");
}
#[test]
fn test_get_line() {
let prog = bind(console_get_line(), |s: String| Console::pure(s));
let (_, result) = interpret_pure(&["test"], prog);
assert_eq!(result, "test");
}
}
Deep Comparison
Comparison: Example 184 — Free Monad Introduction
Free Monad Type
OCaml
type 'next console_f =
| Print of string * 'next
| GetLine of (string -> 'next)
type 'a console =
| CPure of 'a
| CFree of 'a console console_f
Rust
enum Console<A> {
Pure(A),
Print(String, Box<Console<A>>),
GetLine(Box<dyn FnOnce(String) -> Console<A>>),
}
Bind (FlatMap)
OCaml
let rec bind m f = match m with
| CPure a -> f a
| CFree (Print (msg, next)) -> CFree (Print (msg, bind next f))
| CFree (GetLine k) -> CFree (GetLine (fun s -> bind (k s) f))
Rust
fn bind<A: 'static, B: 'static>(
ma: Console<A>, f: impl FnOnce(A) -> Console<B> + 'static,
) -> Console<B> {
match ma {
Console::Pure(a) => f(a),
Console::Print(msg, next) => Console::Print(msg, Box::new(bind(*next, f))),
Console::GetLine(k) => Console::GetLine(Box::new(move |s| bind(k(s), f))),
}
}
Program Construction
OCaml
let program =
print "What is your name?" >>= fun () ->
get_line () >>= fun name ->
print ("Hello, " ^ name ^ "!") >>= fun () ->
return_ name
Rust
fn greet_program() -> Console<String> {
bind(Console::print("What is your name?"), move |()| {
bind(Console::get_line(), move |name: String| {
bind(Console::print(&format!("Hello, {}!", name)), move |()| {
Console::pure(name)
})
})
})
}
Pure Interpreter
OCaml
let rec interpret_pure inputs prog = match prog with
| CPure a -> ([], a)
| CFree (Print (msg, next)) ->
let (outputs, result) = interpret_pure inputs next in
(msg :: outputs, result)
| CFree (GetLine k) -> interpret_pure (List.tl inputs) (k (List.hd inputs))
Rust
fn interpret_pure(inputs: &[&str], prog: Console<String>) -> (Vec<String>, String) {
let mut current = prog;
let mut outputs = Vec::new();
let mut idx = 0;
loop {
match current {
Console::Pure(a) => return (outputs, a),
Console::Print(msg, next) => { outputs.push(msg); current = *next; }
Console::GetLine(k) => { current = k(inputs[idx].into()); idx += 1; }
}
}
}
Exercises
ReadFile(path: String, continuation: Box<dyn FnOnce(String) -> Console<A>>) operation.Vec<String>.println!("[LOG] ...") before executing it.