Viscosity
Review
Part IX · Rust for TypeScript developers · Day 35

138. Arenas and typed indices

drip-rs never lets one object own another. Every object lives in a big Vec owned by the heap, and a reference is a 4-byte slot number with its type attached. Cycles stop mattering, borrows get simple, and the garbage collector is yours again.

About 17 minutes, including the exercises and quiz
By the end you will be able to
  • Build an arena, a Vec of slots with a free list, and explain what insert and remove do.
  • Use a newtype index, and a generic handle like drip-rs’s Gc<T>, so the compiler rejects mixed-up indices.
  • Work with the borrow checker in an arena by copying handles out before writing.
  • Compare drip-rs’s handles, mark bitmap and sweep with the TypeScript VM’s object references.

Pointers become numbers

Chapter 137 ended with three problems. A VM built from Rc<RefCell<..>> leaks every cycle, panics when a program aliases the wrong objects, and hands the question “when does this object die?” to reference counting instead of to the collector you wrote in chapter 58.

drip-rs’s answer is the first of its design notes: arenas and handles, not Rc<RefCell<..>>. No object owns another. One owner, the heap, holds them all. And a reference from one object to another stops being a pointer and becomes a number.

Intuition

A cloakroom. You hand over your coat and get a numbered ticket. The cloakroom owns every coat; the ticket is small, and you can photocopy it as often as you like without creating a second coat. Coats can “point at” each other by having tickets in their pockets, and nobody cares if two pockets hold each other’s tickets.

An arena is a Vec of slots

Here is the arena from drip-rs’s object.rs, slightly shortened:

pub struct Arena<T> {
    slots: Vec<Option<T>>,
    free: Vec<u32>,
}

impl<T> Arena<T> {
    pub fn insert(&mut self, o: T)
        -> Gc<T> {
        if let Some(i) = self.free.pop() {
            let i = i as usize;
            self.slots[i] = Some(o);
            return Gc::new(i);
        }
        self.slots.push(Some(o));
        Gc::new(self.slots.len() - 1)
    }
}

Each slot is an Option<T>: Some(object) while it is live, None once the collector frees it. free lists the empty slots, so insert fills a hole before it grows the Vec. remove(index, reuse) calls take() on the slot, which leaves None behind, and puts the index on the free list only if reuse is true. Step through it:

Step throughSlots, a free list and handles
let a = arena.insert(A);
let b = arena.insert(B);
let c = arena.insert(C);
arena.remove(b.index(), reuse);
let d = arena.insert(D);
arena[b] // which object?
An arena of four slots, its free list, and handles pointing at slots by numberslots: Vec<Option<T>>0Some(A)123free: []handlesa = Gc(0)
insert pushes A into a new slot, 0, and returns the handle Gc(0). The arena owns A; a is just the number 0, typed as “an index into this arena”.
1/9

drip-rs’s heap has one arena per kind of object: strings, functions, upvalues, closures, classes, instances and so on. Asking the instances arena for slot 7 gives you an ObjInstance, or a panic if slot 7 is empty.

Definition

An arena is one collection, here a Vec, that owns many objects of one type. Objects are added and removed through the arena, and everything else refers to them by index instead of by pointer. A free list of empty slots lets new objects reuse old space. A stale index, kept after its object was freed, reads whatever moved in: wrong, but never undefined behaviour.

Typed indices

A slot number could be a bare usize. Then nothing stops you using a closure’s number to read the classes arena, and getting some unrelated class back. The fix is a type that wraps the number:

#[derive(Copy, Clone, PartialEq, Eq)]
pub struct ValueId(pub u32);

That is real: engine/rust’s IR keeps its instructions, blocks, functions and bindings in Vecs and names them with ValueId, BlockId, FuncId and BindingId, each a u32 in its own newtype. Pass a BlockId where a ValueId is wanted and it doesn’t compile.

drip-rs has eight object types, so it writes the wrapper once, as a generic:

pub struct Gc<T> {
    index: u32,
    marker: PhantomData<fn() -> T>,
}

The struct has to mention T somewhere, or Rust rejects the unused parameter. PhantomData is a standard-library marker that takes no space and says “act as if a T were involved”. So a Gc<ObjClass> is 4 bytes, just a u32, yet a different type from Gc<ObjClosure>. The arena implements the standard Index trait, which is what makes the arena[handle] syntax call your code, only for handles of its own type:

error[E0308]: mismatched types
   |     let oops = &insts[k];
   |                       ^ expected
   `Gc<Instance>`, found `Gc<Class>`

That error is from the program below, on rustc 1.97. Try uncommenting its last line.

Try itTyped handles into arenas
use std::marker::PhantomData;
use std::mem::size_of;
use std::ops::{Index, IndexMut};

// Adapted from drip-rs's object.rs.
struct Gc<T> {
    index: u32,
    marker: PhantomData<fn() -> T>,
}

impl<T> Clone for Gc<T> {
    fn clone(&self) -> Self {
        *self
    }
}
impl<T> Copy for Gc<T> {}

struct Arena<T> {
    slots: Vec<Option<T>>,
}

impl<T> Arena<T> {
    fn insert(&mut self, t: T)
        -> Gc<T> {
        self.slots.push(Some(t));
        let i = self.slots.len() - 1;
        Gc {
            index: i as u32,
            marker: PhantomData,
        }
    }
}

impl<T> Index<Gc<T>> for Arena<T> {
    type Output = T;
    fn index(&self, h: Gc<T>) -> &T {
        let i = h.index as usize;
        match &self.slots[i] {
            Some(t) => t,
            None => panic!("freed"),
        }
    }
}

impl<T> IndexMut<Gc<T>> for Arena<T> {
    fn index_mut(&mut self, h: Gc<T>)
        -> &mut T {
        let i = h.index as usize;
        match &mut self.slots[i] {
            Some(t) => t,
            None => panic!("freed"),
        }
    }
}

struct Class {
    name: String,
}
struct Instance {
    class: Gc<Class>,
    hp: f64,
}

fn main() {
    let mut classes =
        Arena { slots: Vec::new() };
    let mut insts =
        Arena { slots: Vec::new() };
    let k = classes.insert(Class {
        name: "Ship".to_string(),
    });
    let a = insts.insert(Instance {
        class: k,
        hp: 10.0,
    });
    let b = insts.insert(Instance {
        class: k,
        hp: 4.0,
    });
    // a.heal(b), then a.heal(a): copy
    // the number out, then write.
    for other in [b, a] {
        let hp = insts[other].hp;
        insts[a].hp += hp / 2.0;
    }
    let c = insts[a].class;
    println!("{} instance, hp {}",
        classes[c].name, insts[a].hp);
    println!("handle size: {} bytes",
        size_of::<Gc<Class>>());
    // Uncomment for E0308:
    // let oops = &insts[k];
}
Open in Rust Playground ↗

One wrinkle: drip-rs writes impl<T> Copy for Gc<T> {} by hand. A #[derive(Clone, Copy)] would add the bounds T: Clone and T: Copy, and an ObjClass is neither. The handle should be copyable whatever it points at.

Definition

A typed index is a number wrapped in its own type, a newtype such as struct NodeId(u32). It costs nothing at run time, but the compiler treats each wrapper as a different type, so indices into different arenas can’t be mixed up. A generic handle like Gc<T> gives every element type its own index type from one definition.

Borrowing one heap

There is still a borrow checker. insts[i] borrows the whole arena, so this is rejected even though the elements differ:

let src = &insts[1];
let dst = &mut insts[0];
dst.hp += src.hp / 2.0;
error[E0502]: cannot borrow `insts` as
  mutable because it is also borrowed
  as immutable

But look at the difference from chapter 137. This is a compile error, and the fix is always the same: copy what you need out first. Handles and drip-rs’s Values are Copy, so copying is cheap. In the program above, a.heal(a) simply works. drip-rs’s GetProperty does it the same way, reading let class = self.heap[instance].class; (a copy of a handle, so the borrow ends at the semicolon) before calling a method that needs &mut self.

The collector uses the idea at a larger scale. Its mark bits don’t live inside the objects; they live in a separate Marks bitmap, one Vec<bool> per arena, so tracing can read the heap through a shared & borrow while marking writes the bits through a &mut to the bitmap.

Side by side with the TypeScript VM

               TypeScript VM  drip-rs
a reference    JS reference   Gc<T>, a u32
mark bit       isMarked field Marks bitmap
find them all  next list      walk arenas
free           unlink, poison slot = None
stale use      throws (debug) panics (debug)

The TypeScript objects carry a header (isMarked, and next for the list of every object, from chapter 50), and sweep follows that list. drip-rs needs no list: an arena already holds every object of its kind, so sweep walks each arena’s slots and frees the live, unmarked ones.

Under the hood

The Option around each slot costs nothing here. On this machine, size_of::<ObjInstance>() and size_of::<Option<ObjInstance>>() are both 64 bytes: Rust found a spare bit pattern inside the instance’s fields to mean None, the same trick that makes Option<&T> as small as &T[3]. The lookup does cost a little: a bounds check on the Vec[2] and a check for None, where clox follows a raw pointer. In exchange, drip-rs has #![forbid(unsafe_code)], and its corpus tests run with freed slots never reused, so a collector bug panics at the first stale use instead of quietly reading another object.

Your turn

ExerciseAn arena with a free list

Finish this arena, a simplified version of drip-rs’s. insert stores a value and returns its Id, reusing a freed slot if there is one. remove empties a slot and remembers it as free, but only if it held something. get is written for you.

// A typed index into an Arena.
#[derive(Clone, Copy, PartialEq, Debug)]
struct Id(u32);

struct Arena<T> {
    slots: Vec<Option<T>>,
    free: Vec<u32>,
}

impl<T> Arena<T> {
    fn new() -> Self {
        Arena {
            slots: Vec::new(),
            free: Vec::new(),
        }
    }

    fn insert(&mut self, t: T) -> Id {
        todo!()
    }

    fn remove(&mut self, id: Id)
        -> Option<T> {
        todo!()
    }

    // None if the slot has been freed.
    fn get(&self, id: Id)
        -> Option<&T> {
        let i = id.0 as usize;
        self.slots[i].as_ref()
    }
}
Open in Rust Playground ↗
Extra practiceA linked list in a Vec

Linked lists are awkward with Box and Rc, and easy with indices. The nodes live in a Vec<Node>, and next is an Option<Id>. Write push_front, which adds a node in front of head and returns the new head, and sum, which follows the next links from head and adds up the values.

#[derive(Clone, Copy, PartialEq, Debug)]
struct Id(u32);

// A linked list whose nodes live in
// one Vec: nodes[id.0] is node id.
struct Node {
    val: i32,
    next: Option<Id>,
}

fn push_front(nodes: &mut Vec<Node>,
    head: Option<Id>, val: i32) -> Id {
    todo!()
}

fn sum(nodes: &[Node],
    head: Option<Id>) -> i32 {
    todo!()
}
Open in Rust Playground ↗

What you learned

  • An arena is a Vec of slots that owns every object of one kind. Objects refer to each other by index, so cycles are just numbers.
  • A free list lets new objects reuse freed slots; a stale index then reads the wrong object, which is why test modes turn reuse off.
  • A typed index like ValueId(u32) or Gc<T> stops indices for different arenas being mixed up, at no run-time cost.
  • In an arena the borrow checker still applies, at compile time: copy handles and values out, then write.
  • drip-rs keeps mark bits in a separate bitmap and sweeps by walking its arenas, where the TypeScript VM uses object headers and a linked list.

Check your understanding

0/6
  1. 1.

    In drip-rs, who owns an ObjInstance?

  2. 2.

    Why is Gc<T> generic instead of one Handle(u32) type for every object?

  3. 3.

    let src = &insts[1]; let dst = &mut insts[0]; dst.hp += src.hp; What does Rust say?

  4. 4.

    A handle points at a freed slot that has since been reused. In drip-rs’s normal mode, using it…

  5. 5.

    How does drip-rs’s sweep find every object?

  6. 6.

    Why does drip-rs keep its mark bits in a separate Marks value instead of in each object?

References

  1. [1] Steve Klabnik, Carol Nichols, Chris Krycho (2026). The Rust Programming Language (3rd edition). No Starch Press; free online at doc.rust-lang.org/book. doc.rust-lang.org/book/ (checked 2026-10-31)
  2. [2] The Rust Project Developers. Struct std::vec::Vec (Rust standard library documentation). Rust standard library API documentation; source in library/alloc/src/vec/mod.rs of github.com/rust-lang/rust. doc.rust-lang.org/std/vec/struct.Vec.html (checked 2026-10-31)
  3. [3] The Rust Project Developers. Module core::option (Rust standard library documentation). Rust standard library API documentation; source in library/core/src/option.rs of github.com/rust-lang/rust. doc.rust-lang.org/std/option/ (checked 2026-10-31)