Skip to main content

GC structs and arrays

Reed exposes WebAssembly GC types as nominal declarations. There is no runtime, no allocator to configure, and no destructor — a declaration becomes a wasm type, new becomes struct.new/array.new_fixed, and the host engine's collector owns everything after that.

Allocation is always explicit and always produces a non-null reference.

Structs

A struct declaration is a list of typed fields. mut marks a field writable after construction; the default is read-only.

struct Shape {
  id: i32,
  area: mut f64,
}

struct Circle : Shape {
  radius: mut f64,
}

func scale(circle: &Circle, factor: f64) -> f64 {
  circle->radius = circle->radius * factor;
  circle->area = 3.141592653589793 * circle->radius * circle->radius;
  return circle->area;
}

func check() -> i32 {
  let unit: &Circle = new Circle {
    id: 7,
    area: 3.141592653589793,
    radius: 1.0,
  };

  let grown: f64 = scale(unit, 2.0);

  if (unit->id == 7 && unit->radius == 2.0 && grown > 12.5 && grown < 12.6) {
    return 0;
  }
  return 1;
}

export func check as "check";

That module emits:

(type $Shape (sub (struct (field $id i32) (field $area (mut f64)))))
(type $Circle
(sub final $Shape
(struct
(field $id i32)
(field $area (mut f64))
(field $radius (mut f64)))))

Two things to read off this. Inherited fields are flattened into the child, not linked to it — Circle physically repeats id and area. And WAT addresses fields by index, so circle->radius becomes struct.get $Circle 2. Field names survive only as WAT identifiers for readability; adding a field to Shape renumbers every field of every descendant. That never breaks source code (the whole module is compiled together) but it does mean diffing emitted WAT after a parent change is noisy.

Inheritance

struct Child : Parent gives Child all of Parent's fields, in parent-first order, followed by its own.

RuleConsequence
Source MUST NOT repeat inherited fieldsWriting f in both parent and child is struct 'Child' redeclares field 'f'
A child may not shadow an inherited nameSame diagnostic; there is no shadowing mechanism at all
Parent links MUST be acycliccyclic struct inheritance involving 'A'
Only structs can be parentsstruct B : SomeArray is parent 'SomeArray' is not a struct
Field type and mutability are inherited unchangedA child cannot re-declare area as non-mut

Finality is a whole-module property, not something you write. The compiler looks at which types have declared children:

struct Base { a: i32 }         // has children  -> (sub (struct ...))
struct Mid : Base { b: i32 }   // parent + kids -> (sub $Base (struct ...))
struct Leaf : Mid { c: i32 }   // parent, no kids -> (sub final $Mid (struct ...))
struct Lone { d: i32 }         // neither       -> (type $Lone (struct ...))
array Samples { i32 }          // always        -> (type $Samples (array i32))

Adding one child declaration anywhere in the file silently flips its parent from final to non-final, which changes the parent's WebAssembly type identity. Membership in a recursive group does not affect finality.

Nominality survives compilation, but only because of grouping

Reed's type checker is nominal: two structs with identical fields are different types.

struct A { v: i32 }
struct B { v: i32 }

func take(b: &B) -> i32 { return b->v; }

func check() -> i32 {
  let a: &A = new A { v: 1 };
  return take(a);   // value of type &A is not assignable to &B
}

WebAssembly, left alone, is not nominal. Names carry no identity, and the engine canonicalizes recursive type groups structurally — so two identical declarations emitted as separate groups would fold into one runtime type, and a is &B would match an A.

The compiler avoids that by emitting declarations that would canonicalize together in a single recursive group, which makes their identity positional:

(rec
(type $A (struct (field $v i32)))
(type $B (struct (field $v i32))))

Types that cannot collide keep their own top-level definition, so this only shows up where it is needed. The rule is normative — see section 6.

This mechanism exists because of a real, now-fixed bug: sibling structs with matching field types briefly shared a runtime type, and an is test silently matched the wrong one. See the limitations page for the history (audit C8).

Reading and writing fields

ref->field. The receiver must be a non-null declared-struct reference; ?T is rejected.

let radius: f64 = circle->radius;
circle->radius = 2.0;   // only because `radius` was declared mut
AttemptDiagnostic
nullable->ffield access requires a non-null declared-struct reference, found ?Shape
shape->radius where radius is only on Circlestruct 'Shape' has no field 'radius'
write to a non-mut fieldfield 'f' is not declared 'mut'

Narrowing is the way past ?T

There is no cast expression. To get from ?Shape to &Circle you use is, which narrows only a bare immutable local read directly — a let binding or a parameter. On a var, a field read, or a call result the test still runs correctly and still produces the right boolean, but the static type is unchanged, and you find out at the use site, not the test site.

struct Shape { id: i32 }
struct Circle : Shape { radius: f64 }

struct Slot { held: mut ?Shape }

func radius_of(shape: ?Shape) -> f64 {
  // Narrows: `shape` is an immutable parameter read directly.
  if (shape is &Circle) {
    return shape->radius;
  }
  return 0.0;
}

func radius_in(slot: &Slot) -> f64 {
  // `slot->held is &Circle` would test correctly but narrow nothing,
  // and `slot->held->radius` would then not compile. Re-bind first.
  let held: ?Shape = slot->held;
  if (held is &Circle) {
    return held->radius;
  }
  return 0.0;
}

func check() -> i32 {
  let circle: &Circle = new Circle { id: 1, radius: 2.5 };
  let slot: &Slot = new Slot { held: circle };

  if (radius_of(circle) == 2.5 && radius_in(slot) == 2.5 && radius_of(null) == 0.0) {
    return 0;
  }
  return 1;
}

export func check as "check";

is is not confined to an if condition — value is null/value is &T/value is ?T are ordinary i32-valued expressions, usable anywhere an expression is valid: negated, stored in a let, passed as a call argument, and so on. Only narrowing — the type change the true arm gets, described above — stays confined to the if-condition position, and only for a bare immutable local (a let or a parameter) named directly. See General is as a boolean expression for the one real syntax restriction (an unparenthesized is can't be the operand of any other operator) and the full grammar and examples.

Construction

new Name { field: expr, ... } initializes every field, including inherited ones, exactly once. Result type is a non-null &Name.

MistakeDiagnosticPhase
omit a fieldfield 'g' was not initializedtype
repeat a fieldfield 'f' initialized more than oncetype
name a field that does not existstruct 'A' has no field 'z'resolution

A failed new still declares its enclosing let's binding, using the annotated type, even though the initializer itself is rejected — so a later use of the local does not cascade into an unrelated diagnostic. The single diagnostic you get points straight at the real problem:

struct A { f: i32, g: i32 }

func check() -> i32 {
  let a: &A = new A { f: 1 };   // field 'g' was not initialized
  return a->f;                  // fine: `a` is still bound, as type &A
}

(This used to not be true: a failed initializer skipped declaring the binding entirely, so a->f cascaded into an unrelated resolution-phase unknown local 'a' that hid the real problem. Fixed; see Recently fixed compiler bugs.)

Field initializers run in source order

This is the rule most likely to bite you, and the one most worth understanding. The emitted struct.new needs its operands in parent-first field order. Your source can write them in any order. The compiler does not get to reorder your expressions to match — it evaluates them in lexical order into temporary locals, then reads the locals back in field order.

global Log: mut i32 = 0;

// Appends `tag` as the next base-10 digit of Log, then returns it.
func note(tag: i32) -> i32 {
  Log = Log * 10 + tag;
  return tag;
}

struct Base {
  first: i32,
  second: i32,
}

struct Derived : Base {
  third: i32,
}

func check() -> i32 {
  // Written third, second, first -- the reverse of the field layout.
  let value: &Derived = new Derived {
    third: note(3),
    second: note(2),
    first: note(1),
  };

  // Log == 321 proves the calls ran in written order, not layout order.
  if (Log == 321 && value->first == 1 && value->second == 2 && value->third == 3) {
    return 0;
  }
  return 1;
}

export func check as "check";

Running it returns 0, so Log is 321, not 123. The emitted body shows the mechanism:

(block $new_3
(result (ref $Derived))
(local.set $third_0 (call $note (i32.const 3)))
(local.set $second_1 (call $note (i32.const 2)))
(local.set $first_2 (call $note (i32.const 1)))
(struct.new $Derived
(local.get $first_2)
(local.get $second_1)
(local.get $third_0)))

The spill is unconditional. Even new P { a: 1, b: 2 } with fields already in layout order and constant initializers emits two local.sets and two local.gets. The compiler does no escape analysis and no "is this reorder observable" check; it always takes the safe path. Expect one extra local per field per allocation site in the output, and let the engine's optimizer clean it up.

Default-initialized allocation

new Name {} and new Name[count]{} skip individual field/element expressions and default-initialize instead, lowering to struct.new_default and array.new_default — the dedicated, non-escape-hatch spelling of the raw ops shown below in Raw GC operations. Every field or element type must be defaultable: a numeric type, or a nullable ?T; a non-null &T has no zero value, so it is a compile error naming the offending field.

struct Counter {
  count: mut i32,
  label: ?Counter,
}

array Samples { mut i32 }

func check() -> i32 {
  // Struct: every field defaulted (i32 -> 0, ?Counter -> null).
  let c: &Counter = new Counter {};
  // Array: a count-based form, since an empty brace list already means
  // "zero elements" for `new Samples { ... }`.
  let s: &Samples = new Samples[5]{};

  if (c->count == 0 && (c->label is null) && #s == 5 && s[0] == 0 && s[4] == 0) {
    return 0;
  }
  return 1;
}

export func check as "check";

See Default-initialized allocation for the full grammar (including why the two array forms are mutually exclusive) and spec section 6/6.1.

Filled allocation

new Name[count]{value} builds a count-length array with every element set to one value, lowering to array.new. It is the same [count] form as above with the braces non-empty, and exactly one element may appear there.

The difference that matters is that the element type does not have to be defaultable, because you supplied a value. This is the only construction that fills a non-null-reference array without listing every element:

struct Cell {
  value: mut i32,
}

array Nums { mut i32 }
array Cells { mut &Cell }

func check() -> i32 {
  let n: &Nums = new Nums[4]{7};
  // `&Cell` has no default, so `new Cells[2]{}` would be a compile error.
  let c: &Cells = new Cells[2]{new Cell { value: 6 }};

  // The value is evaluated once and shared, so both slots are the same object.
  c[0]->value = 11;

  if (#n == 4 && n[3] == 7 && #c == 2 && c[1]->value == 11) {
    return 0;
  }
  return 1;
}

export func check as "check";

That sharing is the one thing to watch: count copies of a reference are count references to a single object, not count separate objects. For per-element objects, write the list out or fill a loop.

count evaluates before value, matching source order, even though array.new takes them the other way round on the stack.

Arrays

An array declaration has exactly one element type, optionally mut. It has no parent, cannot be inherited from, and is always emitted final.

array Samples { mut i32 }

func sum(samples: &Samples) -> i32 {
  var total: i32 = 0;
  for index in 0..#samples {
    total = total + samples[index];
  }
  return total;
}

func check() -> i32 {
  let samples: &Samples = new Samples { 8, 13, 21 };
  let empty: &Samples = new Samples { };   // valid: array.new_fixed Samples 0

  samples[1] = samples[1] + 21;

  if (#samples == 3 && #empty == 0 && sum(samples) == 63 && sum(empty) == 0) {
    return 0;
  }
  return 1;
}

export func check as "check";
SyntaxLowers toRequires
new A { e1, e2 }array.new_fixed A 2each e assignable to the element type
new A[n]{}array.new_defaulta defaultable element type
new A[n]{v}array.newv assignable to the element type; no defaultability requirement
arr[i]array.getnon-null &A, i32 index
arr[i] = varray.setthe element declared mut
#arrarray.lennon-null &A

Only simple = assignment exists. samples[0] += 1 is a syntax error; write it out.

Bounds are not checked by the language

An out-of-range index traps, exactly as in native WebAssembly. #arr is the only bound check you get, and you have to write it:

array Samples { mut i32 }

func at_or(samples: &Samples, index: i32, fallback: i32) -> i32 {
  if (index < 0 || index >= #samples) {
    return fallback;
  }
  return samples[index];
}

func check() -> i32 {
  let samples: &Samples = new Samples { 8, 13, 21 };

  if (at_or(samples, 1, -1) == 13 && at_or(samples, 3, -1) == -1) {
    return 0;
  }
  return 1;
}

func boom() -> i32 {
  let samples: &Samples = new Samples { 8, 13, 21 };
  return samples[3];   // wasm trap: out of bounds array access
}

export func check as "check";
export func boom as "boom";

Invoking boom aborts with wasm trap: out of bounds array access. There is no exception to catch it with; a trap unwinds to the host.

Array evaluation order

new A { ... } evaluates left to right. A read evaluates receiver then index. An assignment evaluates receiver, then index, then the right-hand side. Each part runs exactly once.

global Log: mut i32 = 0;

array Cells { mut i32 }

func note(tag: i32, value: i32) -> i32 {
  Log = Log * 10 + tag;
  return value;
}

func pick(cells: &Cells) -> &Cells {
  Log = Log * 10 + 1;
  return cells;
}

func check() -> i32 {
  let cells: &Cells = new Cells { 0, 0, 0 };

  // Receiver, then index, then right-hand side.
  pick(cells)[note(2, 1)] = note(3, 42);

  if (Log == 123 && cells[1] == 42) {
    return 0;
  }
  return 1;
}

export func check as "check";

Packed storage

i8 and i16 are storage types for struct fields and array elements. They are not value types — nothing has type i8 at the expression level.

A packed read must carry .s or .u. It yields i32 with sign or zero extension. A write takes an i32 and keeps the low 8 or 16 bits, silently.

struct Pixel {
  red: mut i8,
  green: mut i8,
}

array Deltas { mut i8 }

func check() -> i32 {
  let pixel: &Pixel = new Pixel { red: 255, green: 0 };

  // One stored byte, two readings.
  let red_u: i32 = pixel->red.u;   // 255
  let red_s: i32 = pixel->red.s;   // -1

  // A write takes an i32 and silently keeps its low 8 bits.
  pixel->green = 0x1_FF;
  let green: i32 = pixel->green.u; // 255

  let deltas: &Deltas = new Deltas { -1, 127, 200 };
  let d0: i32 = deltas[0].s;       // -1
  let d2u: i32 = deltas[2].u;      // 200
  let d2s: i32 = deltas[2].s;      // -56

  if (red_u == 255 && red_s == -1 && green == 255
      && d0 == -1 && d2u == 200 && d2s == -56) {
    return 0;
  }
  return 1;
}

export func check as "check";

The suffix requirement is deliberate: pixel->red on a byte holding 0xFF is either 255 or -1, and the language refuses to pick for you. The rule is symmetric — a suffix on a non-packed field is also an error.

SituationDiagnostic
pixel->red (packed, no suffix)a packed field or array element cannot be read without an explicit '.s' or '.u' suffix
count->total.u (non-packed, suffix)'.s'/'.u' suffixes are only valid on a packed 'i8'/'i16' field or array element

Truncation on write is not diagnosed. struct S { v: mut i16 } with new S { v: 70000 } then s->v.u returns 4464. If you need the range check, write it.

Recursive type groups

You never write a recursive-group annotation. The compiler builds a directed graph over declared types — edges from struct fields, array elements, function parameter/result types, and parent links — runs Tarjan's algorithm, and emits each strongly connected component as one rec group. A singleton with no self-edge is emitted outside any group.

A self-referential node is the smallest case that needs one:

struct Node {
  value: i32,
  next: mut ?Node,
}

// Not in any cycle: nothing refers back to Counter.
struct Counter {
  hits: mut i32,
}

func sum(head: ?Node) -> i32 {
  var total: i32 = 0;
  var cursor: ?Node = head;

  loop walk() {
    // `cursor` is a var, so `cursor is &Node` would not narrow it.
    // Re-bind to an immutable local first.
    let node: ?Node = cursor;
    if (node is &Node) {
      total = total + node->value;
      cursor = node->next;
      continue walk();
    }
  }

  return total;
}

func check() -> i32 {
  let third: &Node = new Node { value: 3, next: null };
  let second: &Node = new Node { value: 2, next: third };
  let first: &Node = new Node { value: 1, next: second };

  // `next` is mut, so the graph can be rewired after construction.
  second->next = null;

  if (sum(first) == 3 && sum(third) == 3 && sum(null) == 0) {
    return 0;
  }
  return 1;
}

export func check as "check";

Emitted types:

(rec
(type $Node
(struct (field $value i32) (field $next (mut (ref null $Node))))))
(type $Counter (struct (field $hits (mut i32))))

Cycles across kinds work the same way. A struct holding an array of itself puts both in one group, ordered by the SCC, not by declaration order:

struct Tree {
  label: i32,
  children: ?Branches,
}

array Branches { ?Tree }

struct Detached {
  note: i32,
}

func check() -> i32 {
  let leaf: &Tree = new Tree { label: 2, children: null };
  let root: &Tree = new Tree { label: 1, children: new Branches { leaf } };
  return root->label - 1;
}

export func check as "check";
(rec
(type $Branches (array (ref null $Tree)))
(type $Tree
(struct (field $label i32) (field $children (ref null $Branches)))))
(type $Detached (struct (field $note i32)))

Branches is emitted first even though Tree is declared first. Group membership matters for type identity across module boundaries: a rec group is canonicalized as a unit, so an importer must present the same group shape, not just the same field list. Adding one type into a cycle changes the identity of everything else in that cycle.

Subtyping

Every declared struct is a subtype of struct, eq, and any. Every declared array is a subtype of array, eq, and any. Widening is implicit; there is no cast syntax, so narrowing back always goes through is.

FromImplicitly assignable to
&Circle&Shape, &struct, &eq, &any, and any nullable form of those
&Samples&array, &eq, &any, ?Samples, ...
&T?T (adding nullability always works)
?Tnot &T — narrow with is
&Shapenot &Circlevalue of type &Shape is not assignable to &Circle
struct Shape { id: i32 }
struct Circle : Shape { radius: f64 }
array Samples { i32 }

func describe(value: ?any) -> i32 {
  if (value is &Circle) { return 1; }
  if (value is &Shape) { return 2; }
  if (value is &Samples) { return 3; }
  if (value is null) { return 4; }
  return 0;
}

func check() -> i32 {
  let circle: &Circle = new Circle { id: 1, radius: 1.0 };
  let samples: &Samples = new Samples { 1, 2 };

  // Every widening below is implicit; no cast syntax exists.
  let as_shape: &Shape = circle;
  let as_struct: &struct = circle;
  let as_eq: &eq = circle;
  let as_any: &any = circle;
  let nullable: ?Shape = circle;

  let arr_as_array: &array = samples;
  let arr_as_any: &any = samples;

  if (describe(as_any) == 1 && describe(arr_as_any) == 3 && describe(null) == 4) {
    return as_shape->id - 1;
  }
  return 1;
}

export func check as "check";

Test order matters in describe: &Circle must be checked before &Shape, because a Circle satisfies both.

GC values in globals

new is not a constant expression, so a global cannot be initialized with one:

struct Registry { count: mut i32 }

// error: this expression is not a valid constant expression
// for a global initializer
global Shared: &Registry = new Registry { count: 0 };

Declare it nullable and fill it in from start:

struct Registry {
  count: mut i32,
}

// `new` is not a constant expression, so the global starts null...
global Shared: mut ?Registry = null;

// ...and is populated by the start function.
func init() {
  Shared = new Registry { count: 0 };
}

start init;

func bump() -> i32 {
  let shared: ?Registry = Shared;
  if (shared is &Registry) {
    shared->count = shared->count + 1;
    return shared->count;
  }
  return -1;
}

func check() -> i32 {
  if (bump() == 1 && bump() == 2) {
    return 0;
  }
  return 1;
}

export func check as "check";

The cost is that every read of Shared needs a re-bind plus an is test, since a global is never narrowed. If that gets noisy, hoist the narrow once at the top of the function and pass &Registry down.

Raw GC operations

The surface syntax does not cover every GC instruction. The escape hatch does — see the opcode catalog. Field immediates are by name, not index, so raw ops survive a parent gaining fields.

struct.new_default/array.new_default shown below also have dedicated, non-escape-hatch surface syntax — new Point {} / new Buffer[4]{} — see Default-initialized allocation above; the raw spellings here exist for symmetry with the other raw ops on this page, not because they are the only way to reach those two instructions.

struct Point { x: mut i32, y: mut i32 }
array Buffer { mut i32 }

func check() -> i32 {
  // Zero-initialized; rejected if any field is a non-nullable reference.
  let origin: &Point = wasm.struct.new_default<Point>();
  // Positional, in flattened field order -- no field names, no reordering.
  let corner: &Point = wasm.struct.new<Point>(3, 4);

  // 8 copies of the value 7; then 4 defaults.
  let filled: &Buffer = wasm.array.new<Buffer>(7, 8);
  let blank: &Buffer = wasm.array.new_default<Buffer>(4);

  // fill(array, offset, value, count)
  wasm.array.fill<Buffer>(filled, 2, 9, 3);
  // copy(dest, dest_off, src, src_off, count) -- destination type FIRST
  wasm.array.copy<Buffer, Buffer>(blank, 0, filled, 2, 4);

  if (origin->x == 0 && corner->x == 3 && corner->y == 4
      && filled[1] == 7 && filled[2] == 9 && filled[4] == 9 && filled[5] == 7
      && blank[0] == 9 && blank[2] == 9 && blank[3] == 7
      && wasm.array.len(blank) == 4) {
    return 0;
  }
  return 1;
}

export func check as "check";

Two traps for the unwary. wasm.struct.new<T>(...) is positional in flattened field order — it takes no field names and applies none of the source-order protection described above, so on an inherited type you must supply parent fields first yourself. And array.copy names the destination type first: wasm.array.copy<Dest, Src>(dest, dest_off, src, src_off, count).

struct.new_default refuses any type with a non-nullable reference field — 'struct.new_default' requires every field to have a defaultable type — because there is no null to default it to. Packed fields keep their suffix requirement under raw ops too, via wasm.struct.get_s/get_u and wasm.array.get_s/get_u.

Diagnostics summary

MessagePhase
type name 'my_thing' must use PascalCaseresolution
struct 'B' redeclares field 'f'resolution
cyclic struct inheritance involving 'A'resolution
parent 'A' is not a structresolution
struct 'A' has no field 'z'resolution
field 'g' was not initializedtype
field 'f' initialized more than oncetype
field access requires a non-null declared-struct reference, found ?Atype
field 'f' is not declared 'mut'type
array 'A' element is not mutabletype
expected a non-null declared-array reference, found ?Atype
a packed field or array element cannot be read without an explicit '.s' or '.u' suffixtype
'.s'/'.u' suffixes are only valid on a packed 'i8'/'i16' field or array elementtype
'struct.new_default' requires every field to have a defaultable typetype
this expression is not a valid constant expression for a global initializertype

See also