ExamplesBy LevelBy TopicLearning Paths
216 Expert

Fix Point — How Recursive Types Work Under the Hood

Functional Programming

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

  • • Understand the fixed point of a functor: Fix<F> ≅ F<Fix<F>>
  • • Learn how ListF<A> and TreeF<A> are base functors for lists and trees
  • • See how Fix<F> builds full recursive structures from non-recursive base functors
  • • Understand why this abstraction enables the recursion scheme patterns (examples 217-225)
  • Code 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 requirement: Rust needs Box<ListF<FixList>> to break the recursive size cycle; OCaml's GC-managed values don't need this.
  • Functor map: Both require implementing map for the base functor to enable generic recursion schemes; this is the only per-type requirement.
  • Practical use: The fix-point pattern is primarily educational/library-design territory; recursion-schemes crate in Rust and OCaml provide production implementations.
  • Performance: Each layer of a 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);
        }
    }
    ✓ Tests Rust test suite
    #[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

    ConceptOCamlRust
    Shape functortype 'a list_f = NilF \| ConsF of int * 'aenum ListF<A> { NilF, ConsF(i64, A) }
    Fix pointtype fix_list = FixL of fix_list list_fstruct FixList(Box<ListF<FixList>>)
    Peel one layerlet unfix_l (FixL f) = ffn unfix(self) -> ListF<FixList> { *self.0 }
    Algebra typelist_f 'a -> 'aimpl Fn(ListF<A>) -> A
    Catamorphism('a list_f -> 'a) -> fix_list -> 'afn cata_list<A>(FixList, &impl Fn(ListF<A>) -> A) -> A
    Functorial mapmap_list_f : ('a -> 'b) -> 'a list_f -> 'b list_ffn map<B>(self, f: impl FnOnce(A) -> B) -> ListF<B>

    Key Insights

  • Shape vs recursion: OCaml and Rust both use the same trick — define a non-recursive F<A> shape, then tie the knot by substituting A = Fix<F>. The shape and the recursion are cleanly separated in both languages.
  • Box for size: OCaml's 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.
  • Passing algebras by reference: OCaml closures are heap-allocated and implicitly shared, so 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.
  • No HKT, but same power: Rust lacks higher-kinded types, so we cannot write a single universal 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

  • Implement ExprF<A> { LitF(i64), AddF(A, A), MulF(A, A) } as a base functor with map.
  • Build the corresponding FixExpr type and create the expression (2 + 3) * 4.
  • Implement a size function on FixList that counts elements using the fold pattern.
  • Open Source Repos