Enabling Recursive Types with Boxes

How Box<T> solves the compile-time size problem for recursive data structures in Rust, with cons list and binary tree examples

A recursive type is one that contains another value of the same type within itself. A linked list node that holds a reference to the next node, or a tree node that points to child nodes—these are recursive definitions. They are natural in many data structures, but they collide with a fundamental rule in Rust: every type must have a size known at compile time. A value that can contain another value of the same type, which in turn can contain another, has no finite upper bound on its size. The compiler rejects it.

Box<T> resolves this deadlock. By storing the recursive part on the heap behind a pointer of fixed size, we give the compiler a concrete size to work with while keeping the recursive structure intact. This section explains why the problem arises, how a box fixes it, and where the pattern appears in real Rust code.

Why Recursive Types Fail Without Indirection

Imagine a simple cons list—a linked list built from nested pairs of a value and the rest of the list. An enum that tries to express this directly looks like the definition that every Rust beginner attempts at least once:

// This does not compile
enum List {
    Cons(i32, List),
    Nil,
}

The compiler produces an error that points to the heart of the issue:

error[E0072]: recursive type `List` has infinite size
 --> src/main.rs:1:1
  |
1 | enum List {
  | ^^^^^^^^^ recursive type has infinite size
2 |     Cons(i32, List),
  |            ---- recursive without indirection
  |
  = help: insert some indirection (e.g., a `Box`, `Rc`, or `&`) to make `List` representable

To understand why the size is “infinite,” it helps to see how the compiler computes sizes for enums. For a non‑recursive enum like this:

enum Message {
    Quit,
    Move { x: i32, y: i32 },
    Write(String),
    ChangeColor(i32, i32, i32),
}

Rust examines each variant and determines the one that requires the most space. Quit needs no data, Move needs two i32s, Write needs a String (a pointer, length, and capacity), and ChangeColor needs three i32s. The compiler picks the largest variant as the size of the whole Message. The calculation finishes because each variant’s size is finite.

Now run that same algorithm on our List enum. The Cons variant contains an i32 and a List. To compute the size of Cons, the compiler must add the size of i32 to the size of List. But to compute the size of List, it must look inside Cons again—and the cycle repeats forever. There is no base case that stops the recursion, so the size becomes effectively infinite.

Compilation will fail without indirection:

If you define a recursive enum or struct without a pointer-like wrapper, you will always get the E0072 error. The code cannot compile until the recursion is broken. The compiler’s suggestion to insert a Box, Rc, or & is exactly what you need.

How Box<T> Creates a Known Size

A Box<T> is a smart pointer that stores the T on the heap and keeps a fixed‑size pointer on the stack. No matter how large or complex the heap data becomes, the box itself is always the size of a single pointer (8 bytes on a 64‑bit system). When we wrap the recursive field in a Box, we replace an unbounded amount of inline data with a pointer of known size.

The fixed definition becomes:

enum List {
    Cons(i32, Box<List>),
    Nil,
}

Now the compiler can compute sizes. The Cons variant needs space for an i32 and a Box<List>. A Box<List> is just a pointer, so its size is known. The Nil variant needs no space. The total size of a List value is the maximum of those two variants, which is size_of::<i32>() + size_of::<usize>()—a perfectly finite number.

The recursion still exists in the values we construct, because a Box<List> on the heap points to another List that can itself hold a Box<List>. But the type’s memory layout is no longer recursive; it always ends at a Nil variant after some number of heap indirections.

A simplified memory diagram for a cons list containing 1, 2, 3 looks like this:

Stack                           Heap
┌─────────────┐
│ Cons(1, ptr)│──────┐
└─────────────┘      │
              ┌─────────────┐
              │ Cons(2, ptr)│───┐
              └─────────────┘   │
                         ┌─────────────┐
                         │ Cons(3, ptr)│───┐
                         └─────────────┘   │
                                    ┌─────────────┐
                                    │     Nil     │
                                    └─────────────┘

Every Cons lives on the heap except the outermost one, which might sit on the stack. The pointer fields are small; the actual i32 values ride along inside each heap allocation alongside the next pointer.

A Complete Cons List Example

With the corrected enum, building and traversing a list becomes straightforward. The following code defines the list 1 -> 2 -> 3 -> Nil and prints it recursively.

enum List {
    Cons(i32, Box<List>),
    Nil,
}
use List::{Cons, Nil};
fn main() {
    let list = Cons(1, Box::new(Cons(2, Box::new(Cons(3, Box::new(Nil))))));
    print_list(&list);
}
fn print_list(list: &List) {
    match list {
        Cons(value, next) => {
            print!("{} -> ", value);
            print_list(next);
        }
        Nil => println!("Nil"),
    }
}

Running the program produces:

1 -> 2 -> 3 -> Nil

Each Cons holds an i32 and a Box<List>. Box::new allocates the next node on the heap and returns a pointer that we store in the current node. The print_list function pattern‑matches on a reference to the list. When it finds a Cons, it prints the value and recursively calls itself on the next node by following the Box. The recursion naturally ends when it hits Nil.

The match in print_list accesses next as a &Box<List>. Rust automatically dereferences through the box when we call print_list(next), because Box<T> implements the Deref trait (the subject of the next section). To a caller, a &Box<List> behaves like a &List—no explicit dereferencing required.

Compilation succeeds and runs correctly:

After replacing List with Box<List> in the Cons variant, the compiler accepts the definition, the list builds, and the recursion prints exactly what we expect. The indirection pattern works for any recursive type that needs a compile‑time fixed size.

Binary Trees: Another Recursive Structure

Cons lists are the canonical teaching example, but the same Box pattern enables trees, graphs, and any data structure where a type holds another instance of itself. A binary tree node is a simple extension:

#[derive(Debug)]
struct TreeNode {
    value: i32,
    left: Option<Box<TreeNode>>,
    right: Option<Box<TreeNode>>,
}
impl TreeNode {
    fn new(value: i32) -> Self {
        TreeNode {
            value,
            left: None,
            right: None,
        }
    }
    fn insert(&mut self, value: i32) {
        if value < self.value {
            match self.left {
                Some(ref mut left) => left.insert(value),
                None => self.left = Some(Box::new(TreeNode::new(value))),
            }
        } else if value > self.value {
            match self.right {
                Some(ref mut right) => right.insert(value),
                None => self.right = Some(Box::new(TreeNode::new(value))),
            }
        }
        // ignore duplicates
    }
    fn contains(&self, value: i32) -> bool {
        if value == self.value {
            true
        } else if value < self.value {
            self.left.as_ref().map_or(false, |left| left.contains(value))
        } else {
            self.right.as_ref().map_or(false, |right| right.contains(value))
        }
    }
}

Each node owns its children through Option<Box<TreeNode>>. The Option handles the case where a child is absent (a leaf), and the Box gives the child a fixed‑size pointer so the compiler can compute size_of::<TreeNode>(). When insert creates a new child, it wraps the new node in Box::new to heap‑allocate it.

Building and querying a small tree:

fn main() {
    let mut root = TreeNode::new(10);
    root.insert(5);
    root.insert(15);
    root.insert(3);
    root.insert(7);
    println!("Contains 7: {}", root.contains(7));   // true
    println!("Contains 12: {}", root.contains(12)); // false
}

The tree’s shape—10 at the root, 5 and 15 as children, with 3 and 7 under 5—is entirely heap‑allocated beyond the root node. The stack holds only the root’s immediate fields; all descendant nodes sit behind box pointers.

The as_ref call in contains is worth noting: self.left is an Option<Box<TreeNode>>. Calling as_ref() converts it to Option<&Box<TreeNode>>. Rust then deref‑coerces the &Box<TreeNode> to &TreeNode when contains is called on it, so the recursion flows seamlessly.

When Not to Reach for Box

The compile error for recursive types is so explicit—insert indirection—that it is tempting to sprinkle Box everywhere recursion appears. But boxing is not free. Every Box::new performs a heap allocation, and dereferencing a box goes through a pointer.

Vec<T> already stores its elements on the heap. If you are modelling a list and Vec satisfies your access patterns, you do not need a cons list at all. Similarly, a String holds its characters on the heap; wrapping a String in a Box adds an extra indirection without benefit.

Another case is when you already have indirection through a reference. If a struct stores a &'a TreeNode, the size is a pointer—the same as a box—but the lifetime must be managed. Boxing is appropriate when you need ownership of the recursive child.

Do not box data that is already heap‑allocated:

Types like Vec<T>, String, and Box<T> themselves already live on the heap (their contents do). Wrapping them in another Box creates a pointer to a pointer to the data, which adds indirection cost with no benefit. Use a box only when you need the indirection that the type does not already provide.

The Mental Model: Indirection Breaks the Size Dependency

The core insight is straightforward: the compiler cannot determine the size of a type that contains itself directly, because that leads to an infinite recursion in the size calculation. Replacing the direct inclusion with a fixed‑size pointer stops the recursion. The data still nests arbitrarily deep at runtime, but the type’s footprint becomes finite.

You can think of Box as a “size firewall.” Everything on the inside can be as large or as recursive as needed; the outside only sees a pointer. This is why the same indirection idea appears later in Rc, Arc, and even trait objects (Box<dyn Trait>)—each uses a pointer to decouple the size of the pointer from the size of the pointed‑to value.

For recursive types specifically, Box is the simplest and most common choice because it provides single‑ownership semantics that map perfectly onto trees and linked lists. If you later need shared ownership (multiple pointers to the same node), Rc or Arc will use the same indirection concept with added bookkeeping.

Other indirection options exist:

The compiler error suggests Box, Rc, or &. & introduces a lifetime that ties the child to some parent scope—awkward for owned structures. Rc and Arc allow shared ownership, which you will see later in this chapter for graphs and other many‑to‑one relationships. For straightforward recursive ownership like a tree or cons list, Box is the idiomatic starting point.

Summary

Recursive types are inescapable when you model linked data structures, and Rust’s requirement for compile‑time known sizes presents a hard barrier. The Box<T> smart pointer removes that barrier by replacing an unbounded embedded value with a pointer of known size. The recursive structure is preserved at runtime through heap allocations, while the type system sees only a finite layout.

The key takeaway is not just the mechanic—swap List for Box<List> and recompile—but the idea that indirection lets you separate a type’s identity from its layout. That principle recurs across Rust: in trait objects, in reference counting, and in any situation where a value’s size cannot be fixed until runtime.