Recursive Structural Types

A design for recursive types in datalit and Datafun: an inline rec binder, iso-recursive semantics, and recursion that must pass through a collection rather than a box. Written October 2026 in response to mandocs/drafts/recursive-structural-types.md; not implemented. Checked on paper against the AWFY benchmarks that need recursive data. The back-end consequences are inferred from reading the code, not tested.

Contents

The problem

Datalove cannot write a tree. An alias cannot name itself (type L: ?(int, L) is F064), and datalit has no aliases at all, because it is an expression language with no statements. The obvious fixes each break a constraint the draft sets:

So a recursive type needs a name that lives inside one type expression, and an indirection that is already in the language.

The design in brief

rec Name T binds Name within T and nowhere else. Every use of Name must sit under a heap-backed collection between the use and its binder, which keeps the inline size finite without a new type. The types are iso-recursive: a rec type differs from its one-level unfolding. Rolling and unrolling are no-ops at run time, though, and the checker inserts them, so a literal never mentions them. Internally the binder becomes a de Bruijn index. Type equality then remains the derived PartialEq, and salsa hashing is unaffected.

Syntax

: rec Node { name: string, kids: [Node] } / {
  name = "root",
  kids = [
    { name = "a", kids = [] },
    { name = "b", kids = [{ name = "c", kids = [] }] },
  ],
}

The value is ordinary datalit. Only the type is new. JSON, the canonical recursive data, needs nothing further:

type Json: rec J enum {
  atom Null,
  term Bool bool,
  term Num f64,
  term Str string,
  term Arr [J],
  term Obj %{string = J},
}

The guard rule

Each use of a bound name must have at least one of [_], %{_ = _}, #{_} or {| |} between it and its own binder. Structs, tuples, ?, !, terms, enums and inner rec binders do not count.

: rec L { elt: int, next: [L] }        // ok
: rec L { elt: int, next: ?L }         // error: L is not behind a collection
: rec L L                              // error
: rec A { b: rec B { a: A, bs: [B] } } // error: B is guarded, A is not
: rec L [L]                            // ok: [], [[]], ... are all finite

This is Algol 68's "shielding", with the collections in the role of ref. Algol also requires a struct on every cycle (the "yang" half of its yin-yang rule). That requirement exists to rule out ambiguous coercions, and Datalove has no implicit coercions, so the requirement is left out.

The check runs during hint conversion in time linear in the type. A stack records the collection depth at each binder. A use is legal when the current collection depth is greater than the depth at its own binder.

What no box costs

Recursion with a fixed arity has to go through a list:

: rec List { elt: int, next: [List] }     // at most one, by convention
: rec Tree { v: int, kids: [Tree] }       // exactly two, by convention

The type stops enforcing the arity. Each slot also holds a list handle where an option of a pointer would have been. Most recursive data is rose trees, JSON, or ASTs whose children are lists, and none of that pays. Cons lists and binary search trees do pay.

In the AWFY benchmarks the arity went unenforced in List, Towers and Richards, but expressing the structure was never a problem (Against the AWFY benchmarks).

If the cost matters, the smallest fix is to make the guard a slot of its own. That fix is a box under another name, and should be decided as one. This plan does not include it.

Iso-recursive, with roll and unroll inserted

Let T = rec X body. Unfolding T substitutes T for X in body. T and its unfolding are distinct types with the same layout, because rec is not a box. Rolling and unrolling therefore do nothing at run time, and inserting them silently does not hide any work.

The type checker

The changes, against crates/datalove-datalit/src/tycheck/types.rs and the Datafun checker:

  1. Two variants in the datalit Type: Rec(TypeRec { name, body }) and RecVar(u32), a de Bruijn index where 0 is the innermost binder. Var(InternedText), the generic type parameter, is unaffected.

  2. Hint conversion carries a binder stack. It resolves names to indices and runs the guard and unused-binder checks. This is the only place a free RecVar exists.

  3. Every expression's type is closed. Unfolding substitutes a closed Rec, so no index ever needs shifting, and no RecVar escapes its binder. A debug assertion at the conversion boundary enforces this.

  4. Equality is the derived PartialEq and Hash. De Bruijn indices make alpha-equivalence syntactic. Equi-recursive types would instead need a coinductive equality check (Amadio and Cardelli) at every comparison. Salsa needs Hash to agree with Eq, so they would also need every type graph minimized before it is hashed, and the order-insensitive enums make that minimization harder. This is the main reason to choose iso.

  5. The binder name is not part of a type's identity. Otherwise rec A {..A..} and rec B {..B..} would be different types. The name lives in a field whose Eq and Hash ignore it, so diagnostics can print rec Node. The cost: salsa may backdate a memo whose only change is the name, and a diagnostic could then show a stale binder name. The fallback is no stored name and a generated rec Self.

  6. An expose helper unfolds a rec at the head of a type. Every elimination site calls it before matching on the type's shape. Auditing those sites is the main work. A site that is missed reports a spurious mismatch, never an unsound acceptance, and tests over a recursive struct, enum and list will find the misses.

  7. An unfold costs O(body), one level per elimination, and types never grow along a chain. If profiling finds the clones, interning Rec bodies makes the substituted copy O(1).

  8. Generic unification is structural. Rec unifies only with Rec, and inference never unfolds a type. That keeps equi-recursion from coming back through the unifier.

  9. Mutual recursion nests. An inner binder may refer to an outer one:

    rec E enum {
      term Lit int,
      term Block [rec S enum { term Let E, term Do E }],
    }
    

    This is the usual iso-recursive limitation. The S obtained by projecting out of E is not syntactically equal to a separately written rec S ... rec E ..., so which type is outermost matters. Wasm GC solves this with rec groups. Data files can likely live without them.

Every operation stays linear in the size of the type, and equality does not change. The new code is one conversion pass, one substitution, one helper, and an audit of the elimination sites.

Borrowing match

match consumes its input (botspec Section 8.8). A recursive struct can be read without moving it, because a field projection is a borrow: f(ref n.kids[0]?). A recursive enum cannot. Reading a Json or an AST means moving the value or cloning every level, so recursive enums can be built but not read in place. This plan therefore includes a borrowing match:

match ref v
case term Arr items        // items is a ref borrow of the payload
    call walk(ref items)
case default
end match

The form follows for item in ref xs, where the marker already means "the names this binds are borrows". match mut v would follow for ... in mut in the same way, binding payloads that may be written but not moved. The checker unrolls v's type before matching, as at every elimination. The ownership analysis treats each binding as a borrow rooted at v, the same rule that covers ref a[i]?.

Borrowing match does not depend on recursive types and is useful without them. Recursive enums are what make it necessary.

Runtime and backends

None of this has been checked against the code beyond reading TyDesc.

Against the AWFY benchmarks

AWFY benchmarks that could not be written because Datalove has no recursive types are listed in the AWFY survey. Checked on paper against the AWFY JavaScript sources:

Bench Recursive type Verdict
Storage rec T [?T] Ports directly
Json rec J enum { ..., term Arr [J], term Obj {...} } Ports directly
Towers rec D { size: u32, next: [D] } Ports, with swaps so moves do not allocate
List rec E { val: u32, next: [E] } The type ports; sharing makes the algorithm allocate
Richards rec P { link: [P], ... } for packets Packets port; tasks stay an arena
CD Red-black tree with parent links No: parent links are cycles
DeltaBlue Constraint graph No: a graph
Havlak Control-flow graph, loop tree with parents No, and not needed: blocks are numbered

Two things stand out. First, the guard rule never got in the way. Second, what blocks the remaining benchmarks is sharing, which no owned recursive type provides.

Prior art

Precedent What it does What it shows
OCaml as 'a [ `Nil | `Cons of int * 'a ] as 'a binds a name inside one type expression An inline binder that is not an alias
Zig @This() Names the innermost enclosing anonymous type An innermost-only binder suffices for most uses
Algol 68 Structural modes; recursion must be shielded by ref or proc The guard rule, and its well-formedness check
Rust Box<Self> is required, but Vec<Self> is not Collections already break the size cycle
Swift indirect Marks the indirection on the type only; construction is implicit The marker belongs in the type, not in values
Wasm GC Iso-recursive types; references inside a rec group become indices Canonical indices make structural equality syntactic
CUE #List: { head: _, tail: null | #List }; a cycle with no exit is an error Recursion in a structural configuration language
Elm A self-referencing structural alias is an error; use a custom type Datalove's current position, with no nominal escape
Dhall No recursive types; Boehm-Berarducci encodings instead What refusing costs a declarative language
JSON Schema {"$ref": "#"}; Relative JSON Pointer counts ancestors Self-reference in a serialization format

References:

What exists, what is missing

What exists:

What is missing:

Phases

  1. Front end. Parse rec in datalit and Datafun, convert hints to de Bruijn form, and check guards and unused binders. Add tests that reject each ill-formed example above.
  2. Checking. Roll in checking mode, add expose at every elimination site, and roll through @. Write datalit tests for the rose tree, JSON and list shapes, and Datafun tests that walk them.
  3. Borrowing match. match ref and match mut, with ownership rules for the bindings. This phase is independent of the others and can land first.
  4. Runtime. Knot-tied descriptors, and a check of the type walkers. Load recursive datalit values with the runtime datalit loader.
  5. Backends. Memoized glue in the interpreter, the JIT and C AOT. Run the engine tests on recursive values. Port Storage, Json and Towers to benchvs as the first real programs.
  6. Deep values. Measure the depth at which drop and clone overflow, then decide between explicit work stacks and a documented limit.

Open questions