Skip to main content

Control flow and narrowing

Reed's control flow is WebAssembly's structured control flow, with names bolted on. There is no CFG, no basic-block numbering, no relooper. Every construct you write is one nested block, loop, or if in the output, and the label you wrote is the label you get. That is the whole design trade: the WAT stays readable and predictable, and in exchange you do not get while, switch, goto, or an expression-form if.

What exists

ConstructKindNotes
if (c) { } else { }statementc must be exactly one i32; else if chains
for i in a..b { }statementsigned, exclusive, i32-only
switch (e) { case v: { } default: { } }statementi32 scrutinee, comptime case values, mandatory default, no fall-through
block label(p: T = e, ...) -> R { }expressionlabel targets the end
loop label(p: T = e, ...) -> R { }expressionlabel targets the start
br label(v, ...);statementexplicit label required
break label?(v, ...);statementleaves a loop with its result
continue label?(v, ...);statementre-enters a loop with its parameters
return e?;statement
unreachableexpressiontraps; diverges; any type, any arity (below)

Not in the language: while, do/while, goto, unlabelled br, expression-form if, expression-form switch, and labelled for. tag/throw are covered separately (see Exceptions).

if

The condition is a single i32, which may be spelled bool because bool is a transparent alias. Comparisons produce a canonical 0/1 value, and that is what if consumes; there is no conversion from any other WebAssembly value type.

func clamp(value: i32, low: i32, high: i32) -> i32 {
  if (value < low) {
    return low;
  }
  if (value > high) {
    return high;
  }
  return value;
}

func check() -> i32 {
  if (clamp(5, 0, 10) != 5) { return 1; }
  if (clamp(-3, 0, 10) != 0) { return 2; }
  if (clamp(42, 0, 10) != 10) { return 3; }
  return 0;
}

export func check as "check";

A non-i32 condition is a type error, not a coercion:

if (some_f64) { }
// type error: value of type f64 is not assignable to i32

if itself is still not an expression — there is no if/else that produces a value. For a plain two-way choice between two values, use the ternary condition ? then : else (spec section 7); see wasm-operations.md's "Ternary, assignment expressions, and general is" section for the full rules and a compiling example — notably, unlike if/else, it is not short-circuiting: both branches always evaluate. When an arm needs more than one statement, or the result needs more than one value, reach for a block instead (see Blocks are expressions).

An if/else where every arm diverges (return, br, break, continue) is a valid way to end a non-unit body:

func pick(x: i32) -> i32 {
  if (x == 0) {
    return 10;
  } else {
    return 20;
  }
}

func check() -> i32 {
  if (pick(0) == 10 && pick(7) == 20) {
    return 0;
  }
  return 1;
}

export func check as "check";

WebAssembly does not consider the point after an if unreachable just because both arms returned — it validates each arm independently against the block type — so the compiler appends an explicit terminator:

;; every branch of 'if (x == 0)' diverges
(unreachable)

That instruction is compiler-generated structure, not something you wrote. Emitting it was previously missing, which produced modules that passed reedc check and then failed WebAssembly validation; see audit C7.

loop does not loop

A Reed loop is a WebAssembly loop: the label is a back-edge target, not a repeat annotation. Reaching the closing brace falls out of the loop. Iteration happens only because a continue (or a br to the loop label) jumps back to the top.

global Ticks: mut i32 = 0;

func run_once(limit: i32) -> i32 {
  var count: i32 = 0;
  loop pump() {
    if (count == limit) {
      break;
    }
    count = count + 1;
    // No `continue`: control falls out of the loop here, after one pass.
  }
  return count;
}

func drain(limit: i32) -> i32 {
  var count: i32 = 0;
  loop pump() {
    if (count == limit) {
      break;
    }
    count = count + 1;
    Ticks = Ticks + 1;
    continue pump();
  }
  return count;
}

func plain_block() -> i32 {
  var n: i32 = 1;
  block scope {
    n = n + 1;
  }
  return n;
}

func check() -> i32 {
  if (run_once(4) != 1) { return 1; }
  if (drain(4) != 4) { return 2; }
  if (Ticks != 4) { return 3; }
  if (plain_block() != 2) { return 4; }
  return 0;
}

export func check as "check";

run_once(4) returns 1. If you are transcribing a while loop, the missing continue is the mistake you will make, and for a unit loop nothing warns you — falling off the end of a () body is legal. A loop with a result catches it, because falling off the end then has no value to produce:

loop l(n: i32 = 0) -> i32 {
  let x: i32 = n;
}
// type error: reaching the end of this control body is only valid when its result type is '()'

Note also that the signature is optional in its entirety: block scope { ... } and loop pump() { ... } are both () -> ().

for

for is the one construct with an implicit back-edge, and it is deliberately narrow: signed i32, exclusive end, immutable induction variable.

global Log: mut i32 = 0;

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

func check() -> i32 {
  // Both endpoints run exactly once, start first: Log becomes 14, not 1444.
  var total: i32 = 0;
  for i in note(1)..note(4) {
    total = total + i;
  }
  if (Log != 14) { return 1; }
  if (total != 6) { return 2; }

  // start >= end runs the body zero times.
  var ran: i32 = 0;
  for j in 5..5 { ran = ran + 1; }
  for k in 3..0 { ran = ran + 1; }
  if (ran != 0) { return 3; }

  // The comparison is signed.
  var negative: i32 = 0;
  for m in -2..2 { negative = negative + m; }
  if (negative != -2) { return 4; }

  // `continue` skips the rest of the body but still increments.
  var kept: i32 = 0;
  for n in 0..5 {
    if (n == 2) { continue; }
    kept = kept + 1;
  }
  if (kept != 4) { return 5; }

  return 0;
}

export func check as "check";

The lowering is a block around a loop whose body is guarded by i32.lt_s, with the end value hoisted into a hidden local before the first test:

(block $for_exit
(local.set $i (i32.const 0))
(local.set $for_to (call $upper))
(loop $for_head
(if (i32.lt_s (local.get $i) (local.get $for_to))
(then
(block $for_continue ...body...)
(local.set $i (i32.add (local.get $i) (i32.const 1)))
(br $for_head)))))

Consequences worth internalizing:

  • Mutating the bound of a for inside its body does nothing; the bound was already read.
  • The induction variable is a let, not a var. i = i + 1; is type error: local 'i' is not a mutable 'var' binding.
  • There is no unsigned for; the comparison is always signed.
  • A for carries no values: break (i); is type error: 'break' on a for-loop takes no values, and the same for continue.
  • A for cannot be labelled. To exit two nested fors at once, wrap them in a block and br to it — see below.

Counting by something other than one

A third operand sets the step. It may be negative, which is how you count down.

const Back: i32 = -2;

func check() -> i32 {
  var up: i32 = 0;
  for i in 0..10..3 {
    up = up + i;
  }
  // 0 + 3 + 6 + 9
  if (up != 18) { return 1; }

  var down: i32 = 0;
  for j in 10..0..Back {
    down = down + j;
  }
  // 10 + 8 + 6 + 4 + 2
  if (down != 30) { return 2; }

  return 0;
}

Unlike the two endpoints, the step must be compile-time-known — a literal, a param, a const, an enum member, or an expression over those. That is not a restriction for its own sake: the step's sign is what selects the comparison that ends the loop, so a runtime step would mean emitting both and choosing between them at every iteration. It also makes a step of 0 a compile error rather than a program that never finishes.

A step whose magnitude could carry the induction variable past the end of i32 ends the loop instead of wrapping around it, so for i in 0..2147483647..2000000000 runs twice and stops. The compiler emits that check only when the step's magnitude exceeds one, so an ordinary for compiles to exactly what it did before this existed.

switch

switch can dispatch either on an i32 value or on a reference's runtime type. The two forms cannot be mixed in one statement.

struct Animal {}
struct Dog : Animal { age: i32 }
struct Cat : Animal { lives: i32 }

func describe(value: ?Animal) -> i32 {
  switch (value) {
    case &Dog: { return value->age; }
    case &Cat: { return value->lives; }
    default: { return -1; }
  }
}

Type cases run in source order and lower to repeated br_on_cast. When the scrutinee is a bare immutable local or parameter, it is narrowed to the case type inside that arm. Broader patterns therefore belong after narrower ones. For example, case ?Animal before case &Dog is rejected because the latter could never be selected. The scrutinee is evaluated once even when it is a call or another side-effecting expression.

The narrowing restriction is the same one is has, for the same reason (narrowing only applies to bare immutable locals): on a var, a global, a field read, or a call result the arm is still selected correctly, but the scrutinee keeps its declared type inside the arm. That case warns at the scrutinee (non-narrowing-is) rather than leaving you to discover it as a field-access error further in. Bind to a let first.

Multi-way dispatch on an i32. Unlike a chain of ifs, a dense switch lowers to a single br_table — one indexed jump instead of one comparison per arm.

func classify(x: i32) -> i32 {
  switch (x) {
    case 0: { return 100; }
    case 1, 2: { return 200; }
    case 3: { return 300; }
    default: { return -1; }
  }
}

func check() -> i32 {
  if (classify(0) != 100) { return 1; }
  if (classify(1) != 200) { return 2; }
  if (classify(2) != 200) { return 3; }
  if (classify(3) != 300) { return 4; }
  if (classify(9) != -1) { return 5; }
  return 0;
}
export func check as "check";

case 1, 2: is one arm with two values, not two arms. check() returns 0.

Five rules, each with a reason:

  • The scrutinee must be exactly one i32. br_table takes an i32 index, and the compiler never inserts a conversion to get one.
  • Every case value must be compile-time-known and fit i32 — a literal, a param, or a comptime for loop variable. It becomes a table index, so it cannot depend on a runtime value. The same value may not appear twice.
  • A default arm is required, and must come last. br_table needs some default target; requiring one in the source keeps that visible instead of having the compiler invent fall-through semantics for an unnamed value.
  • There is no fall-through. Exactly one arm runs, then control continues after the switch. No break is needed to separate arms, and none is implied.
  • break and continue still target the enclosing loop. A switch is not a branch target and cannot be named. This matters: if a switch captured break, wrapping an existing loop body in one would silently change what the break did.
func first_gap(limit: i32) -> i32 {
  var found: i32 = -1;
  for i in 0..limit {
    switch (i) {
      // Reaches the `for`, not the `switch`.
      case 4: { break; }
      case 0, 1: { continue; }
      default: { found = found + 1; }
    }
  }
  return found;
}

func check() -> i32 {
  // i = 0, 1 are skipped; 2 and 3 each increment; 4 breaks out. -1 + 2 = 1.
  return first_gap(10) - 1;
}
export func check as "check";

Generating arms with comptime for

Because case values are compile-time-known, an comptime for inside the body can generate the arms — which is how a compile-time-sized dispatch becomes one br_table rather than N comparisons:

switch (index) {
    comptime for i in 0..N {
        case $i: { return [<Handler $i>]; }
    }
    default: { unreachable; }
}

See compile-time parameters for a complete, runnable version, and compile-time generated dispatch for the emitted br_table alongside the same source resized with --define.

When it is not a br_table

Widely-spaced values (case 0, case 1000, case 1000000) would need an absurd table, so the compiler falls back to an i32.eq comparison chain — the same shape nested if/else already produces. The threshold is an output-size choice and not part of the language; the dispatch behaves identically either way. The scrutinee is still evaluated exactly once, even though the sparse form compares it repeatedly.

Blocks and loops are expressions

Both take a signature and both produce values. The difference is only where the label points.

block name(...) -> Rloop name(...) -> R
Label points atthe end of the bodythe start of the body
br name(v) suppliesthe block's resultthe loop's parameters
break name(v)rejectedthe loop's result
continue name(v)rejectedthe loop's parameters
Falling off the endallowed only if R is ()allowed only if R is ()

So inside a loop, br l(...) and continue l(...) are the same instruction; break l(...) is the only way out with a value. Getting this backwards is a type error, not silent misbehaviour:

block b() -> i32 { continue b(1); }
// type error: 'continue' can only target a loop label, not a block
block b() -> i32 { break b(1); }
// type error: 'break' can only target a loop label, not a block

Parameter initializers are evaluated left to right in the enclosing scope, before any parameter name is bound. A later initializer therefore cannot see an earlier parameter (resolution error: unknown local 'a'), and an initializer mentioning the parameter's own name reads the outer binding of that name.

A block as a value

func combine(a: i32, b: i32) -> i32 {
  return a * 100 + b;
}

func divmod(numerator: i32, denominator: i32) -> (i32, i32) {
  return block split() -> (i32, i32) {
    if (denominator == 0) {
      br split(0, 0);
    }
    br split(numerator / denominator, numerator % denominator);
  };
}

func pick(flag: i32) -> i32 {
  return combine(block chosen() -> i32 {
    if (flag != 0) {
      br chosen(1);
    }
    br chosen(2);
  }, 7);
}

func check() -> i32 {
  let (q, r): (i32, i32) = divmod(17, 5);
  if (q != 3) { return 1; }
  if (r != 2) { return 2; }
  if (pick(1) != 107) { return 3; }
  if (pick(0) != 207) { return 4; }
  return 0;
}

export func check as "check";

A multi-result control expression works as a local initializer or a return, but a single-result one is the only kind usable as a call argument — a (i32, i32) block does not spread into two parameters (function 'combine' expects 2 argument(s), found 1).

The spec says a non-empty control expression must be consumed. The compiler is laxer: a bare block b() -> i32 { br b(1); } statement compiles and emits a (drop ...). Do not rely on that.

Multi-parameter loops

Loop parameters are how you carry state across iterations without a var. Order is positional and matches the declaration:

func fib(n: i32) -> i32 {
  return loop step(i: i32 = 0, a: i32 = 0, b: i32 = 1) -> i32 {
    if (i == n) {
      break step(a);
    }
    continue step(i + 1, b, a + b);
  };
}

func check() -> i32 {
  if (fib(0) != 0) { return 1; }
  if (fib(1) != 1) { return 2; }
  if (fib(10) != 55) { return 3; }
  return 0;
}

export func check as "check";

This lowers to a result block wrapping a parameterized loop. The initializers are pushed onto the stack in source order, and the loop body pops them in reverse into locals — which is what makes the source order come out right:

(block $exit_2 (result i32)
(i32.const 0)
(i32.const 0)
(i32.const 1)
(loop $step_1 (param i32 i32 i32) (result i32)
(local.set $b_5)
(local.set $a_4)
(local.set $i_3)
...
(br $step_1 ...)))

break targets $exit_2; continue and br target $step_1. Arity is checked: continue; inside a loop with one parameter is type error: expected 1 value(s), found 0.

Labels

Labels live in their own namespace (they never collide with locals, globals, or functions), they are lexical, and an inner label may shadow an outer one. A duplicate at the same lexical depth is resolution error: duplicate label 'a' at the same lexical depth.

br always needs an explicit label. break and continue may omit one, in which case they bind to the nearest enclosing loop or for — blocks are skipped entirely, which is the trap in break_exits_the_for below.

func first_pair(target: i32) -> i32 {
  return block found() -> i32 {
    for i in 1..10 {
      for j in 1..10 {
        if (i * j == target) {
          br found(i * 10 + j);
        }
      }
    }
    br found(-1);
  };
}

func break_exits_the_for() -> i32 {
  var seen: i32 = 0;
  for i in 0..5 {
    block guard {
      if (i == 2) {
        break;
      }
      seen = seen + 1;
    }
  }
  return seen;
}

func br_exits_the_block() -> i32 {
  var seen: i32 = 0;
  for i in 0..5 {
    block guard {
      if (i == 2) {
        br guard();
      }
      seen = seen + 1;
    }
  }
  return seen;
}

func shadowed() -> i32 {
  return block same() -> i32 {
    block same {
      br same();
    }
    br same(7);
  };
}

func check() -> i32 {
  if (first_pair(12) != 26) { return 1; }
  if (first_pair(97) != -1) { return 2; }
  if (break_exits_the_for() != 2) { return 3; }
  if (br_exits_the_block() != 4) { return 4; }
  if (shadowed() != 7) { return 5; }
  return 0;
}

export func check as "check";

break_exits_the_for() is 2: the unlabelled break inside block guard leaves the whole for, not the block. br_exits_the_block() is 4: br guard() leaves only the block, so the for keeps going. shadowed() is 7: the inner same (a () block) shadows the outer i32 one, so br same() inside it is unit-typed and legal.

Reaching the end of a body

One rule, applied uniformly: falling off the end of a control body is valid only when its result type is ().

BodyResult ()Result non-()
block / loopfalls throughreaching the end of this control body is only valid when its result type is '()'
functionreturns nothingfunction must return a value of type i32 on every reachable path

Every reachable path out of a non-unit body must return, branch to an enclosing target, or trap. There is no exhaustiveness analysis beyond that — the compiler tracks divergence, not value ranges, so a chain of ifs that a human can see is total still needs a trailing statement.

unreachable

unreachable is that trailing statement. It traps unconditionally, so control never continues past it, and the compiler counts it as a diverging path — which is what lets a non-unit body end with it instead of a return.

func handler_for(index: i32) -> i32 {
  switch (index) {
    case 0: { return 100; }
    case 1: { return 200; }
    default: { unreachable; }
  }
}

func check() -> i32 {
  if (handler_for(0) == 100 && handler_for(1) == 200) {
    return 0;
  }
  return 1;
}

export func check as "check";

It is an expression, not a statement, which is why unreachable; above needs no special grammar: it is an ordinary expression statement. The same word therefore works anywhere a value is expected, and — since nothing after it runs — it adopts whatever type and arity the context wants, with no annotation of its own:

let x: i32 = unreachable;              // any type
let (a, b): (i32, i64) = unreachable;  // any arity
return unreachable;                    // the function's whole result sequence
identity(unreachable);                 // an argument
let n: i64 = unreachable as i64;       // an `as` operand: no conversion is emitted

That polymorphism is what makes it usable as the tail of a block with a result type, where a return would exit the whole function rather than produce the block's value:

func decode(tag: i32, payload: i32) -> i32 {
  let value: i32 = block decoded() -> i32 {
    if (tag == 0) {
      br decoded(payload);
    }
    if (tag == 1) {
      br decoded(payload * 2);
    }
    unreachable;
  };
  return value;
}

func check() -> i32 {
  if (decode(0, 7) == 7 && decode(1, 7) == 14) {
    return 0;
  }
  return 1;
}

export func check as "check";

Three things it is not:

  • Not a function. unreachable() is rejected, as is any postfix on it (unreachable->field). The call-shaped spelling belongs to the escape hatch.
  • Not a constant. A global initializer rejects it — a trap is not a value, and global Bad: i32 = unreachable; is a compile error.
  • Not free of the trailing-terminator rule. Where every arm of an if or switch diverges, the compiler still appends its own unreachable after the construct, because WebAssembly treats the point after it as reachable regardless. Writing unreachable in an arm makes that arm diverge; it does not remove the terminator the lowering needs.

wasm.unreachable() still works and emits the identical instruction. Prefer the keyword; the escape hatch is there for the operations that have no surface syntax.

Since anything after unreachable can never run, a following statement gets the unreachable-code warning, exactly as it would after a return.

Type tests and narrowing

is refines a reference's static type inside the true arm of an if.

TestMatchesTrue-arm type of v
v is &Tnon-null values whose runtime type is a subtype of T&T
v is ?Tthe same, plus null?T
v is nullonly nullnullable bottom reference
struct Shape { area: f64, }
struct Circle : Shape { radius: f64, }
struct Square : Shape { side: i32, }

func classify(v: ?Shape) -> i32 {
  if (v is null) { return 0; }
  if (v is &Circle) { return 1; }
  if (v is &Square) { return 2; }
  return 3;
}

func maybe_circle(v: ?Shape) -> i32 {
  if (v is ?Circle) {
    // v is ?Circle here, still nullable.
    if (v is null) { return 10; }
    return 11;
  }
  return 12;
}

func as_circle(v: ?Shape) -> ?Circle {
  // In the true arm of `is null`, v has the bottom reference type, so it is
  // assignable to ?Circle even though it was declared ?Shape.
  if (v is null) { return v; }
  if (v is &Circle) { return v; }
  return null;
}

func radius_or(v: ?Shape, fallback: f64) -> f64 {
  let c: ?Circle = as_circle(v);
  if (c is &Circle) { return c->radius; }
  return fallback;
}

func check() -> i32 {
  let circle: &Circle = new Circle { area: 3.0, radius: 1.5 };
  let square: &Square = new Square { area: 4.0, side: 2 };
  let base: &Shape = new Shape { area: 5.0 };
  let nothing: ?Shape = null;

  if (classify(nothing) != 0) { return 1; }
  if (classify(circle) != 1) { return 2; }
  if (classify(square) != 2) { return 3; }
  if (classify(base) != 3) { return 4; }

  if (maybe_circle(circle) != 11) { return 5; }
  if (maybe_circle(nothing) != 10) { return 6; }
  if (maybe_circle(square) != 12) { return 7; }

  if (radius_or(circle, -1.0) != 1.5) { return 8; }
  if (radius_or(square, -1.0) != -1.0) { return 9; }
  return 0;
}

export func check as "check";

General is as a boolean expression

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:

struct Shape { area: f64, }
struct Circle : Shape { radius: f64, }

func check() -> i32 {
  let v: ?Shape = null;
  if (!(v is null)) { return 1; }

  let t: i32 = v is &Circle;
  if (t != 0) { return 2; }

  return 0;
}

export func check as "check";

Only narrowing — the type change v gets inside the true arm — stays confined to the if-condition position, and only for a bare immutable local (a let or a parameter) named directly (see Narrowing only applies to bare immutable locals below). Used anywhere else, the identical test still evaluates to the same i32; it just narrows no source binding's type.

One real syntax restriction survives: is is recognized only at the outermost level of an expression, so x is T must be the entire expression on its own — it is checked once, at the very top of the expression grammar, and binds looser than every other operator. That means an unparenthesized is cannot be the operand of any operator — not &&/||, not a comparison or arithmetic operator, and not either branch of the ternary:

if (v is &Circle && k == 1) { }
// syntax error: expected ')', found AndAnd

let x: i32 = v is null ? 1 : k;
// syntax error: expected ';', found Question

Parenthesize the is sub-expression to use it as an operand of anything else:

if ((v is &Circle) && k == 1) { }

let x: i32 = (v is null) ? 1 : k;

The operand must still be a reference (type error: 'is' requires a reference-typed left operand, or 'is null' requires a reference-typed left operand).

See wasm-operations.md's "Ternary, assignment expressions, and general is" section for the full grammar and lowering, and spec section 9 for the normative rules.

The false arm keeps the original type

There are no complement types. The else side of v is null is still ?Shape, so the usual "guard against null and continue" shape does not work:

func area_of(v: ?Shape) -> f64 {
  if (v is null) {
    return 0.0;
  }
  return v->area;
  // type error: field access requires a non-null declared-struct reference, found ?Shape
}

Write the positive test instead — if (v is &Shape) { return v->area; } followed by the fallback. This inverts the habit from most languages and is the single most common narrowing complaint.

Narrowing only applies to bare immutable locals

This is the sharpest edge on the page. The test always runs; only the type change is conditional on the operand being a bare immutable local (a let or a parameter), named directly. On a var, a global, a field read, a call result, or any compound expression, the check is still emitted, the narrowing is silently skipped, and no diagnostic is produced at the test. You find out at the use site, with an error that points at the wrong thing.

var probe: ?Shape = incoming;
if (probe is &Circle) {
  return probe->radius;
  // type error: field access requires a non-null declared-struct reference, found ?Shape
}

if (box->item is &Circle) {
  return box->item->radius;    // same error, same reason
}

if (Current is &Circle) {      // a global: never narrows
  return Current->radius;      // same error
}

The fix is always the same: bind to a let first.

struct Shape { area: f64, }
struct Circle : Shape { radius: f64, }

global Current: mut ?Shape = null;

func current_radius() -> f64 {
  // `Current is &Circle` would run but narrow nothing, because Current is a
  // global. Snapshot it into an immutable local and test that.
  let snapshot: ?Shape = Current;
  if (snapshot is &Circle) {
    return snapshot->radius;
  }
  return 0.0;
}

func check() -> i32 {
  Current = new Circle { area: 3.0, radius: 2.5 };
  if (current_radius() != 2.5) { return 1; }
  Current = new Shape { area: 1.0 };
  if (current_radius() != 0.0) { return 2; }
  return 0;
}

export func check as "check";

Impossible tests fold silently

A test that can never succeed — a non-null reference against null, or two reference families with no common subtype — becomes a constant i32.const 0 condition. The operand is still evaluated (and dropped, so side effects survive), but the true arm is dead code and you get no warning:

(func $never_null (param $value_0 (ref $Circle)) (result i32)
(drop (local.get $value_0))
(if (i32.const 0) ;; `value is null` folded away
(then (return (i32.const 1))))
(return (i32.const 0)))

Lowering

Narrowing does not change the type of the emitted WebAssembly local — wasm locals are fixed. Instead the compiler branches on a cast and materializes the refined value in a fresh typed local:

SourceInstruction
v is &T, v is ?Tbr_on_cast
v is nullbr_on_null
statically-false testi32.const 0

Never ref.test + ref.cast: br_on_cast hands the narrowed reference to its branch target directly, so there is no second dynamic check.

Nominal in the source, positional in the output

The spec calls struct declarations nominal, but raw WebAssembly canonicalizes recursive type groups structurally, with no help from names. Left alone, two sibling structs with the same parent and the same field types would fold into a single WebAssembly type — field names are annotations and do not participate — and is would not be able to tell them apart. The compiler avoids this by emitting declarations that would collide into a single recursive group, which makes their identity positional instead of purely structural, so is distinguishes them correctly:

struct Node { tag: i32, }
struct Leaf : Node { value: i32, }
struct Twig : Node { count: i32, }

func classify(n: ?Node) -> i32 {
  if (n is &Leaf) { return 1; }
  if (n is &Twig) { return 2; }
  return 0;
}

func check() -> i32 {
  let leaf: &Leaf = new Leaf { tag: 0, value: 9 };
  let twig: &Twig = new Twig { tag: 0, count: 5 };
  // Leaf and Twig have identical field-type lists under the same parent, but
  // the same-recursive-group emission keeps them distinct.
  if (classify(leaf) != 1) { return 1; }
  if (classify(twig) != 2) { return 2; }
  return 0;
}

export func check as "check";

See GC structs and arrays for how the emission strategy achieves this, and the limitations page for the history of the bug it fixes (sibling structs briefly shared a runtime type before this was corrected).

Tail calls

There is no tail-call syntax. Write an ordinary return of a call; whether it becomes return_call is a compiler output mode, selected with --tail-calls, and never something the source can request or observe.

func step(value: i32) -> i32 {
  return value + 1;
}

func forward(value: i32) -> i32 {
  return step(value);
}

func check() -> i32 {
  if (forward(41) != 42) { return 1; }
  return 0;
}

export func check as "check";
BuildEmitted for return step(value);
reedc build(return (call $step (local.get $value_0)))
reedc build --tail-calls(return_call $step (local.get $value_0))

Because it is an output mode, nothing in the source guarantees constant stack usage. If you need unbounded iteration, write a loop, not recursion.

Exceptions are checked

tag Name(types...); declares an exception tag. A function that can let that tag escape must list it in throws (...); a caller must either catch it or write ? immediately after the call to propagate an effect its own signature declares:

tag Overflow(i32);

func checked_add(a: i32, b: i32) -> i32 throws (Overflow) {
  let sum: i32 = a + b;
  if (b > 0 && sum < a) {
    throw Overflow(sum);
  }
  return sum;
}

func check() -> i32 {
  try {
    return checked_add(1, 2) == 3 ? 0 : 1;
  } catch Overflow(value) {
    return value;
  }
}

export func check as "check";

A locally caught call needs no ?. Outside that try, a wrapper would write checked_add(a, b)? and declare throws (Overflow). The marker never bypasses a matching lexical handler, and it is an error when the call has no unhandled checked effect.

An uncaught declared exception still unwinds to the embedding host. throw Overflow(999) also diverges: code after it is unreachable, and a function whose only reachable ending is a throw satisfies the return-path rule without a trailing return.

An unknown tag name or a wrong argument count/type is a real error, not silently accepted:

tag Oops(i32);
func f() throws (Oops) {
  throw Nope(1);
  // resolution error: unknown tag 'Nope'
}

See Exceptions for typed payload bindings, catch-all references, rethrowing, indirect calls, methods, imports, and exact function-type effect matching.

See also