Skip to main content

GC structs and arrays

WAST 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

WAST'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 is worth knowing about because it was wrong until recently (audit C8): sibling structs with matching field types shared a runtime type, and an is test silently matched the wrong one. If you are reading older generated WAT and see two identical (type ...) definitions outside a rec, that output has the bug.

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 also grammatically restricted: it must be the entire condition of an if. if (x is &T && ...), if (!(x is &T)), and let b: i32 = x is &T; are all syntax errors. Invert by putting the work in the else, or bind an early return. See control flow and narrowing.

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

Sharp edge worth knowing before you burn ten minutes on it: because a failed new makes the enclosing let produce no binding, and because only the earliest non-empty diagnostic phase is reported, a missing field inside a let surfaces as a resolution error about the local:

struct A { f: i32, g: i32 }

func check() -> i32 {
  let a: &A = new A { f: 1 };   // real problem: `g` was not initialized
  return a->f;                  // reported instead: unknown local 'a'
}

Delete the use, or move the new into a return, and the real message appears.

Field initializers run in source order

This is the rule most likely to bite a compiler, 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.

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
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 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