ADR-246: A Type That Holds Itself Holds Itself Through a Box the Compiler Writes

Status: Accepted Date: September 27, 2026 Answers: open-decisions.md, how a type holds itself - the owner chose option 1, the compiler puts the indirection in Related: Part I 4.4 (enums), ADR-094 D4 (the & is the compiler’s to write), ADR-291 (patterns), ADR-291 (a match over a place lends), ADR-290 D11 (the toolchain is written in the language)

1. Context

enum Expr { Add(Expr, Expr) } and struct Node { next: Node? } passed the check and lowered as written, and the language below said recursive type has infinite size about a file nobody wrote: a value that holds a whole value of its own type inline would be larger than itself. Refusing them in this language’s words left the question of how a type should hold itself.

It is the question that stood between Nikaia and a parser written in Nikaia: the compiler’s parser is already written in the grammar language Nikaia’s grammar blocks lower to, and its actions build a syntax tree, which is a type that holds itself. The three options were the compiler writing the indirection, a written indirection type in std, and no new form. The owner chose the first, which is the rule the language already follows for references - the reference a call needs is the compiler’s to write - applied to the one other thing Rust makes a program spell for the machine’s sake.

2. Decisions

D1 - A field on a ring of types that hold each other inline is boxed below, and the program does not say so.

A field is on a ring where what it holds inline reaches back to the type that declares it: the type itself, or one that holds it, through any number of types between. Inline is the type itself, nullable or not, a tuple’s parts and an array’s element; a view, a function, a list, a map or anything this compiler cannot see through is an indirection already and breaks no ring. Such a field is Box<T> below, where T is the field’s own lowered type - so next: Node? is Box<Option<Node>> and Add(Expr, Expr) is Add(Box<Expr>, Box<Expr>).

Both edges of a ring through two types are boxed (Expr holds a Call that holds an Expr). That is one allocation more than the least that would do, and it keeps the rule a property of a field rather than of a choice among a ring’s edges that a reader could not predict.

Such a type is not refused (NK1192 is withdrawn): the walk that finds it is the walk that finds the fields to box.

D2 - A boxed field reads as the field it declares.

A read of a boxed field is (*base.field) below: a place of the field’s declared type, which is read, lent, moved out of an owned value and assigned to exactly as the unboxed field would be. A read through ?. reaches the same place inside the closure it lowers to.

A name a match binds out of a boxed part is taken out of the box at the head of the arm, through std’s boxed::open: a Box<T> taken by value is a T, and a &Box<T> lent is a &T, so the arm reads the name as the pattern’s type says whether the match took the value or lent it.

D3 - A boxed field is built in its box.

A struct literal, a with and a variant’s constructor put the value for a boxed field in Box::new(…), around whatever else the value is written with - Box::new(Some(tail)) for a node handed to next: Node?, and Box::new(None) for a null.

D4 - A pattern the lowering does not rewrite at a boxed part is refused by name.

The language below matches no pattern through a box. Where D5’s rewrite does not reach - a pattern that looks more than one level into a boxed part, or one inside an | - NK1193 refuses it and says what works: bind the part to a name and match on the name in the arm.

D5 - A nested pattern at a boxed part is rewritten, a guard reads the part, and a nullable field is boxed whole.

  1. A nested pattern at a boxed part is rewritten into a match in the arm rather than refused. The part is bound to a fresh name, the arm’s guard asks whether it has the shape (matches! over nikaia_std::boxed::peek), so the next arm is tried where it does not, and the arm takes it apart with a let … else before anything else. Such an arm covers no variant on its own for NK1151, as a guarded one does not. One level per arm and outside an |. A guard that reads a name bound in there is answered by a match through the box that binds the names - a number copied out, anything else a view of the part - which answers the guard where it fits and false where it does not. Over a value the match was lent, a number taken out of the box at the head of the arm is copied out too.
  2. A guard reads a boxed binding as what the box holds. The names are opened at the head of the arm, and a guard runs before it, so each boxed name the guard reads is bound again, for the guard, to a view of what its box holds (nikaia_std::boxed::peek), and the checker reads it as a view there: Expr::Add(a, b) if a == Expr::Num(1) compares the part.
  3. A nullable field is boxed whole, so a null costs an allocation. Option<Box<T>> would make a null free, and needs the reads D2 makes into places to become reads of an option of a view, which is a larger change to the same three sites. It is open (§4).

3. Consequences

4. What this does not decide

This site is open source. Improve this page.