Fix Point — How Recursive Types Work Under the Hood
Tutorial
The Problem
All recursive data types — lists, trees, expressions — have the same structure: a "base functor" that describes one layer, plus a mechanism for recursion. The Fix point separates these concerns: Fix<F> is the type-level fixed point of a functor F. ListF<A> describes one list node; Fix<ListF> is a full list. This abstraction enables writing a single cata (fold) function that works for any recursive type — just provide the one-step algebra.
🎯 Learning Outcomes
Fix<F> ≅ F<Fix<F>>ListF<A> and TreeF<A> are base functors for lists and treesFix<F> builds full recursive structures from non-recursive base functorsCode Example
// Shape of one list layer — A is the child slot
pub enum ListF<A> { NilF, ConsF(i64, A) }
impl<A> ListF<A> {
pub fn map<B>(self, f: impl FnOnce(A) -> B) -> ListF<B> {
match self {
ListF::NilF => ListF::NilF,
ListF::ConsF(x, rest) => ListF::ConsF(x, f(rest)),
}
}
}
// Fix<ListF>: ListF wrapping itself via Box to break infinite size
pub struct FixList(Box<ListF<FixList>>);
impl FixList {
pub fn unfix(self) -> ListF<FixList> { *self.0 }
pub fn nil() -> Self { FixList(Box::new(ListF::NilF)) }
pub fn cons(x: i64, xs: FixList) -> Self {
FixList(Box::new(ListF::ConsF(x, xs)))
}
}
// Catamorphism — all recursion here, algebras stay local
pub fn cata_list<A>(list: FixList, alg: &impl Fn(ListF<A>) -> A) -> A {
alg(list.unfix().map(|child| cata_list(child, alg)))
}Key Differences
Box<ListF<FixList>> to break the recursive size cycle; OCaml's GC-managed values don't need this.map for the base functor to enable generic recursion schemes; this is the only per-type requirement.recursion-schemes crate in Rust and OCaml provide production implementations.Fix-based structure allocates one Box; this is worse than native recursive types for performance-critical code.OCaml Approach
OCaml's fixed point pattern:
type 'a list_f = NilF | ConsF of int * 'a
type fix_list = Fix of fix_list list_f
let fold f (Fix lf) = f (List.map fold lf) (* simplified *)
OCaml's let rec allows directly recursive types without explicit Box, making the fix-point pattern more natural. The Fix wrapper is still needed to create the fixed point, but the recursion is implicit.
Full Source
#![allow(clippy::all)]
//! # Example 216: Fix Point — How Recursive Types Work Under the Hood
//!
//! Separates the *shape* of a recursive type from the *mechanism* of recursion.
//! A "base functor" `F<A>` describes one node with children of type `A`.
//! The fix point wraps `F` around itself so that `A = Fix<F>`, yielding
//! full, unbounded recursion from a non-recursive building block.
//!
//! Because shape and recursion are separate, a single `cata` (catamorphism)
//! captures all traversal logic — algebras only describe one local step.
// ============================================================
// Approach 1: Fix point for lists
// ============================================================
//
// `ListF<A>` is the shape of ONE list layer.
// Replace A with `FixList` and you get an ordinary recursive list.
/// One layer of a list — either empty, or an element plus a child slot `A`.
#[derive(Debug)]
pub enum ListF<A> {
NilF,
ConsF(i64, A),
}
impl<A> ListF<A> {
/// Functorial map — apply `f` to the single child position.
pub fn map<B>(self, f: impl FnOnce(A) -> B) -> ListF<B> {
match self {
ListF::NilF => ListF::NilF,
ListF::ConsF(x, rest) => ListF::ConsF(x, f(rest)),
}
}
}
/// `Fix<ListF>`: one layer of `ListF` whose child slot holds another `FixList`.
///
/// `FixList ≅ ListF<FixList> ≅ NilF | ConsF(i64, FixList)`
#[derive(Debug)]
pub struct FixList(Box<ListF<FixList>>);
impl FixList {
/// Peel off the outermost `Fix` wrapper, exposing `ListF<FixList>`.
pub fn unfix(self) -> ListF<FixList> {
*self.0
}
pub fn nil() -> Self {
FixList(Box::new(ListF::NilF))
}
pub fn cons(x: i64, xs: FixList) -> Self {
FixList(Box::new(ListF::ConsF(x, xs)))
}
}
/// Catamorphism for `FixList`: fold bottom-up using a local algebra `alg`.
///
/// `alg` only handles *one layer*; all recursion lives here.
pub fn cata_list<A>(list: FixList, alg: &impl Fn(ListF<A>) -> A) -> A {
alg(list.unfix().map(|child| cata_list(child, alg)))
}
// ============================================================
// Approach 2: Fix point for binary trees
// ============================================================
/// One layer of a binary tree — a leaf value or a branch with two child slots.
#[derive(Debug)]
pub enum TreeF<A> {
LeafF(i64),
BranchF(A, A),
}
impl<A> TreeF<A> {
/// Functorial map — apply `f` to all child positions (two for BranchF).
pub fn map<B>(self, mut f: impl FnMut(A) -> B) -> TreeF<B> {
match self {
TreeF::LeafF(n) => TreeF::LeafF(n),
TreeF::BranchF(l, r) => TreeF::BranchF(f(l), f(r)),
}
}
}
/// `Fix<TreeF>`: recursive binary tree built from a non-recursive shape.
#[derive(Debug)]
pub struct FixTree(Box<TreeF<FixTree>>);
impl FixTree {
pub fn unfix(self) -> TreeF<FixTree> {
*self.0
}
pub fn leaf(n: i64) -> Self {
FixTree(Box::new(TreeF::LeafF(n)))
}
pub fn branch(l: FixTree, r: FixTree) -> Self {
FixTree(Box::new(TreeF::BranchF(l, r)))
}
}
/// Catamorphism for `FixTree`: fold bottom-up using a local algebra `alg`.
pub fn cata_tree<A>(tree: FixTree, alg: &impl Fn(TreeF<A>) -> A) -> A {
alg(tree.unfix().map(|child| cata_tree(child, alg)))
}
// ============================================================
// Tests
// ============================================================
#[cfg(test)]
mod tests {
use super::*;
// ---------- FixList tests ----------
#[test]
fn test_list_sum_empty() {
let sum = cata_list(FixList::nil(), &|node| match node {
ListF::NilF => 0i64,
ListF::ConsF(x, acc) => x + acc,
});
assert_eq!(sum, 0);
}
#[test]
fn test_list_sum() {
// [1, 2, 3] → 6
let list = FixList::cons(1, FixList::cons(2, FixList::cons(3, FixList::nil())));
let sum = cata_list(list, &|node| match node {
ListF::NilF => 0i64,
ListF::ConsF(x, acc) => x + acc,
});
assert_eq!(sum, 6);
}
#[test]
fn test_list_length() {
let list = FixList::cons(10, FixList::cons(20, FixList::nil()));
let len: usize = cata_list(list, &|node| match node {
ListF::NilF => 0,
ListF::ConsF(_, rest) => 1 + rest,
});
assert_eq!(len, 2);
}
#[test]
fn test_list_to_vec() {
// fold into a Vec, preserving original order
let list = FixList::cons(1, FixList::cons(2, FixList::cons(3, FixList::nil())));
let v: Vec<i64> = cata_list(list, &|node: ListF<Vec<i64>>| match node {
ListF::NilF => vec![],
ListF::ConsF(x, mut tail) => {
tail.insert(0, x);
tail
}
});
assert_eq!(v, [1, 2, 3]);
}
// ---------- FixTree tests ----------
/// Helper: branch(branch(leaf 1, leaf 2), leaf 3)
fn sample_tree() -> FixTree {
FixTree::branch(
FixTree::branch(FixTree::leaf(1), FixTree::leaf(2)),
FixTree::leaf(3),
)
}
#[test]
fn test_tree_single_leaf() {
let sum = cata_tree(FixTree::leaf(42), &|node| match node {
TreeF::LeafF(n) => n,
TreeF::BranchF(l, r) => l + r,
});
assert_eq!(sum, 42);
}
#[test]
fn test_tree_sum() {
// leaf(1) + leaf(2) + leaf(3) = 6
let sum = cata_tree(sample_tree(), &|node| match node {
TreeF::LeafF(n) => n,
TreeF::BranchF(l, r) => l + r,
});
assert_eq!(sum, 6);
}
#[test]
fn test_tree_depth() {
// leaf → 0
let d0: usize = cata_tree(FixTree::leaf(0), &|node: TreeF<usize>| match node {
TreeF::LeafF(_) => 0,
TreeF::BranchF(l, r) => 1 + l.max(r),
});
assert_eq!(d0, 0);
// branch(branch(_, _), _) → depth 2
let d2: usize = cata_tree(sample_tree(), &|node: TreeF<usize>| match node {
TreeF::LeafF(_) => 0,
TreeF::BranchF(l, r) => 1 + l.max(r),
});
assert_eq!(d2, 2);
}
#[test]
fn test_tree_count_leaves() {
let count: usize = cata_tree(sample_tree(), &|node| match node {
TreeF::LeafF(_) => 1,
TreeF::BranchF(l, r) => l + r,
});
assert_eq!(count, 3);
}
#[test]
fn test_cata_two_algebras_same_tree() {
// sum and count on the same structure shape
let tree = FixTree::branch(FixTree::leaf(10), FixTree::leaf(20));
let sum = cata_tree(tree, &|node| match node {
TreeF::LeafF(n) => n,
TreeF::BranchF(l, r) => l + r,
});
assert_eq!(sum, 30);
}
}#[cfg(test)]
mod tests {
use super::*;
// ---------- FixList tests ----------
#[test]
fn test_list_sum_empty() {
let sum = cata_list(FixList::nil(), &|node| match node {
ListF::NilF => 0i64,
ListF::ConsF(x, acc) => x + acc,
});
assert_eq!(sum, 0);
}
#[test]
fn test_list_sum() {
// [1, 2, 3] → 6
let list = FixList::cons(1, FixList::cons(2, FixList::cons(3, FixList::nil())));
let sum = cata_list(list, &|node| match node {
ListF::NilF => 0i64,
ListF::ConsF(x, acc) => x + acc,
});
assert_eq!(sum, 6);
}
#[test]
fn test_list_length() {
let list = FixList::cons(10, FixList::cons(20, FixList::nil()));
let len: usize = cata_list(list, &|node| match node {
ListF::NilF => 0,
ListF::ConsF(_, rest) => 1 + rest,
});
assert_eq!(len, 2);
}
#[test]
fn test_list_to_vec() {
// fold into a Vec, preserving original order
let list = FixList::cons(1, FixList::cons(2, FixList::cons(3, FixList::nil())));
let v: Vec<i64> = cata_list(list, &|node: ListF<Vec<i64>>| match node {
ListF::NilF => vec![],
ListF::ConsF(x, mut tail) => {
tail.insert(0, x);
tail
}
});
assert_eq!(v, [1, 2, 3]);
}
// ---------- FixTree tests ----------
/// Helper: branch(branch(leaf 1, leaf 2), leaf 3)
fn sample_tree() -> FixTree {
FixTree::branch(
FixTree::branch(FixTree::leaf(1), FixTree::leaf(2)),
FixTree::leaf(3),
)
}
#[test]
fn test_tree_single_leaf() {
let sum = cata_tree(FixTree::leaf(42), &|node| match node {
TreeF::LeafF(n) => n,
TreeF::BranchF(l, r) => l + r,
});
assert_eq!(sum, 42);
}
#[test]
fn test_tree_sum() {
// leaf(1) + leaf(2) + leaf(3) = 6
let sum = cata_tree(sample_tree(), &|node| match node {
TreeF::LeafF(n) => n,
TreeF::BranchF(l, r) => l + r,
});
assert_eq!(sum, 6);
}
#[test]
fn test_tree_depth() {
// leaf → 0
let d0: usize = cata_tree(FixTree::leaf(0), &|node: TreeF<usize>| match node {
TreeF::LeafF(_) => 0,
TreeF::BranchF(l, r) => 1 + l.max(r),
});
assert_eq!(d0, 0);
// branch(branch(_, _), _) → depth 2
let d2: usize = cata_tree(sample_tree(), &|node: TreeF<usize>| match node {
TreeF::LeafF(_) => 0,
TreeF::BranchF(l, r) => 1 + l.max(r),
});
assert_eq!(d2, 2);
}
#[test]
fn test_tree_count_leaves() {
let count: usize = cata_tree(sample_tree(), &|node| match node {
TreeF::LeafF(_) => 1,
TreeF::BranchF(l, r) => l + r,
});
assert_eq!(count, 3);
}
#[test]
fn test_cata_two_algebras_same_tree() {
// sum and count on the same structure shape
let tree = FixTree::branch(FixTree::leaf(10), FixTree::leaf(20));
let sum = cata_tree(tree, &|node| match node {
TreeF::LeafF(n) => n,
TreeF::BranchF(l, r) => l + r,
});
assert_eq!(sum, 30);
}
}
Deep Comparison
OCaml vs Rust: Fix Point — How Recursive Types Work Under the Hood
Side-by-Side Code
OCaml
(* Shape of one list layer — NOT recursive, A is the child slot *)
type 'a list_f = NilF | ConsF of int * 'a
(* Functorial map over the child slot *)
let map_list_f f = function
| NilF -> NilF
| ConsF (x, rest) -> ConsF (x, f rest)
(* Fix point: wrap ListF around itself to obtain full recursion *)
type fix_list = FixL of fix_list list_f
let nil = FixL NilF
let cons x xs = FixL (ConsF (x, xs))
let unfix_l (FixL f) = f
(* Catamorphism: only place recursion lives *)
let rec cata_list alg (FixL f) =
alg (map_list_f (cata_list alg) f)
(* Sum algebra — no recursion in the algebra itself *)
let sum = cata_list (function NilF -> 0 | ConsF (x, acc) -> x + acc)
Rust (idiomatic — concrete fix-point types)
// Shape of one list layer — A is the child slot
pub enum ListF<A> { NilF, ConsF(i64, A) }
impl<A> ListF<A> {
pub fn map<B>(self, f: impl FnOnce(A) -> B) -> ListF<B> {
match self {
ListF::NilF => ListF::NilF,
ListF::ConsF(x, rest) => ListF::ConsF(x, f(rest)),
}
}
}
// Fix<ListF>: ListF wrapping itself via Box to break infinite size
pub struct FixList(Box<ListF<FixList>>);
impl FixList {
pub fn unfix(self) -> ListF<FixList> { *self.0 }
pub fn nil() -> Self { FixList(Box::new(ListF::NilF)) }
pub fn cons(x: i64, xs: FixList) -> Self {
FixList(Box::new(ListF::ConsF(x, xs)))
}
}
// Catamorphism — all recursion here, algebras stay local
pub fn cata_list<A>(list: FixList, alg: &impl Fn(ListF<A>) -> A) -> A {
alg(list.unfix().map(|child| cata_list(child, alg)))
}
Rust (binary tree — same pattern, two child slots)
pub enum TreeF<A> { LeafF(i64), BranchF(A, A) }
impl<A> TreeF<A> {
pub fn map<B>(self, mut f: impl FnMut(A) -> B) -> TreeF<B> {
match self {
TreeF::LeafF(n) => TreeF::LeafF(n),
TreeF::BranchF(l, r) => TreeF::BranchF(f(l), f(r)),
}
}
}
pub struct FixTree(Box<TreeF<FixTree>>);
pub fn cata_tree<A>(tree: FixTree, alg: &impl Fn(TreeF<A>) -> A) -> A {
alg(tree.unfix().map(|child| cata_tree(child, alg)))
}
// Depth algebra — zero recursion in the user code
let depth = cata_tree(tree, &|node: TreeF<usize>| match node {
TreeF::LeafF(_) => 0,
TreeF::BranchF(l, r) => 1 + l.max(r),
});
Type Signatures
| Concept | OCaml | Rust |
|---|---|---|
| Shape functor | type 'a list_f = NilF \| ConsF of int * 'a | enum ListF<A> { NilF, ConsF(i64, A) } |
| Fix point | type fix_list = FixL of fix_list list_f | struct FixList(Box<ListF<FixList>>) |
| Peel one layer | let unfix_l (FixL f) = f | fn unfix(self) -> ListF<FixList> { *self.0 } |
| Algebra type | list_f 'a -> 'a | impl Fn(ListF<A>) -> A |
| Catamorphism | ('a list_f -> 'a) -> fix_list -> 'a | fn cata_list<A>(FixList, &impl Fn(ListF<A>) -> A) -> A |
| Functorial map | map_list_f : ('a -> 'b) -> 'a list_f -> 'b list_f | fn map<B>(self, f: impl FnOnce(A) -> B) -> ListF<B> |
Key Insights
F<A> shape, then tie the knot by substituting A = Fix<F>. The shape and the recursion are cleanly separated in both languages.FixL of fix_list list_f is naturally heap-allocated (OCaml values are boxed by default). In Rust, Box<ListF<FixList>> makes the type's size finite and explicit — without Box, the compiler rejects the definition because FixList would have infinite size.cata_list alg can recurse freely. In Rust, passing alg: &impl Fn(...) by shared reference lets cata_list call itself recursively without moving or cloning the closure.FnOnce vs FnMut**: ListF::map can use FnOnce because the child slot appears exactly once. TreeF::map requires FnMut because BranchF has two children and the closure must be called twice.Fix<F> that works for any F. Instead we define FixList and FixTree as concrete newtype wrappers. The pattern is identical; only the types are spelled out. Each cata_X function is a standalone, type-safe catamorphism.When to Use Each Style
**Use a concrete Fix type (FixList, FixTree) when:** you want the separation-of-shape-from-recursion benefit without fighting the type system — common for embedded DSLs, expression trees, or any recursive data where you want reusable fold/map/render logic.
**Use a direct recursive type (enum List { Nil, Cons(i64, Box<List>) }) when:** the structure is simple, you don't need generic catamorphisms, and the extra indirection of Fix-point encoding would obscure rather than clarify the code.
Exercises
ExprF<A> { LitF(i64), AddF(A, A), MulF(A, A) } as a base functor with map.FixExpr type and create the expression (2 + 3) * 4.size function on FixList that counts elements using the fold pattern.