Skip to main content

GC narrowing

A derived GC struct stored in a nullable parent reference, then narrowed before the derived field is read. Narrowing is the one place in WAST where a binding's type changes based on control flow, and it has tighter restrictions than the equivalent feature in most languages.

struct Value {
  kind: i32,
}

// Nominal inheritance, not structural. The emitted child type flattens the
// parent's fields first, so Integer is (struct (field kind i32)
// (field payload i32)) and `payload` is struct.get index 1, not 0.
// Being a parent also changes how Value itself is emitted: it becomes a
// non-final `(sub ...)` type, while Integer, which nothing extends, is
// `(sub final $Value ...)`.
struct Integer : Value {
  payload: i32,
}

func read_integer(value: ?Value) -> i32 {
  // `value` is an immutable parameter, which is the ONLY shape that narrows.
  // Had it been a `var`, a field read, or a call result, this test would
  // still evaluate correctly but would not change any binding's type -- and
  // nothing warns you that narrowing was skipped.
  if (value is &Integer) {
    // Narrowed to &Integer here: non-null AND derived, so no null check and
    // no explicit cast. Lowers to br_on_cast, which delivers the already-cast
    // reference to its branch target -- not ref.test followed by ref.cast.
    return value->payload;
  }

  // The arm above did not narrow `value` to "some Value that is not an
  // Integer". There are no complement types; the false arm always keeps the
  // original ?Value, which is why this second test is still necessary.
  if (value is null) {
    return 0;
  }

  // `is` is a condition form, not an expression. All three of these are
  // syntax errors, not type errors:
  //   !(value is null)
  //   value is &Integer && other is &Integer
  //   let matched: i32 = value is &Integer;
  // An `is` test must be the entire condition of an `if`.
  return -1;
}

func check() -> i32 {
  let integer: &Integer = new Integer { kind: 1, payload: 42 };
  // Widening to the parent is implicit and free -- no cast, no runtime cost.
  // Only the narrowing direction needs a test.
  let value: ?Value = integer;

  if (read_integer(value) == 42) {
    return 0;
  }
  return 1;
}

export func check as "check";

Why narrowing only works on immutable locals

WebAssembly locals have fixed types. A local declared (ref null $Value) is that type for the entire function; there is no instruction that changes it. So source-level narrowing cannot be implemented by "retyping the variable" — the compiler has to produce a separate value of the narrower type and use that inside the true arm. In the emitted WAT it appears as an extra local:

(local $narrow_4 (ref $Integer))

br_on_cast pushes the successfully-cast reference onto the branch target's stack, the compiler stores it into that local, and every mention of value inside the true arm compiles to a read of $narrow_4 instead.

That trick is only sound if value cannot change between the test and the use. For a let or a parameter, it cannot. For a var, an assignment inside the true arm would leave $narrow_4 holding a stale reference while the source says the variable was updated — the narrowed copy and the original would silently disagree. Rather than track that, the language refuses to narrow anything but an immutable local.

The restriction extends to non-bindings for a different reason: some_call() or obj->field do not name a location at all, so there is nothing to narrow. The test still runs exactly once, and the result is still correct as a boolean — you just do not get a retyped name out of it. The workaround is mechanical: bind first, then test.

struct Value { kind: i32 }
struct Integer : Value { payload: i32 }

func does_not_narrow(input: ?Value) -> i32 {
  var value: ?Value = input;
  if (value is &Integer) {
    // `value` is still ?Value in here. Writing `value->payload` on this line
    // is a type error, even though the test just succeeded.
    return 1;
  }
  return 0;
}

func narrows(input: ?Value) -> i32 {
  // Same test, but against an immutable binding.
  let pinned: ?Value = input;
  if (pinned is &Integer) {
    return pinned->payload;
  }
  return 0;
}

The genuine complaint here is the silence. Testing a var is not a diagnostic; you find out later, at the use site, with an error that blames the field access rather than the failed narrowing:

type error: field access requires a non-null declared-struct reference, found ?V

If a narrowing that you expected to happen did not, check whether the left operand is a bare let or parameter before looking anywhere else.

Why no complement types

After if (value is &Integer) fails, a language with complement types would give the false arm a type meaning "Value but definitely not Integer". WAST does not model that, so the false arm keeps ?Value unchanged.

The cost is visible in the example: read_integer needs a second is null test that a smarter type system could have discharged from the first test's failure. The benefit is that the type system stays close to WebAssembly's own subtyping lattice, which has no negation either. Adding complements would mean carrying a type form with no lowering target, purely to satisfy the checker.

Why br_on_cast and not ref.test plus ref.cast

The obvious lowering — test the type, branch, then cast — evaluates the operand twice and performs two runtime type checks for one source-level question. br_on_cast does the check and the branch in one instruction and hands the narrowed reference to the target block as a block result, so the cast is free once the test has already passed. Spec section 9.1 requires this: a compiler must not emit ref.test followed by ref.cast solely to implement an is test. is null gets the analogous treatment via br_on_null.

Note the failure path in the generated code. On a failed cast, br_on_cast falls through with the original reference still on the stack, so the compiler must drop it before branching to the else target. Getting the stack discipline around this instruction wrong does not announce itself — is-lowering bugs in this compiler's history validated as well-formed WebAssembly and were only caught by running the module and checking which arm executed.

Expected result

check() returns 0; read_integer takes the narrowed arm and returns 42.