Skip to main content

Control flow and narrowing

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

Not in the language: while, do/while, switch, goto, unlabelled br, ternaries, expression-form if, and labelled for. tag/throw parse but are rejected by the target feature set (see Exceptions).

if

The condition is a single i32. There is no implicit truthiness and no boolean type — comparisons produce a canonical 0/1 i32, and that is what if consumes.

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

Since if is not an expression, "pick one of two values" is spelled with a block (see Blocks are expressions), not a ternary.

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 wastc check and then failed WebAssembly validation; see audit C7.

loop does not loop

A WAST 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 or descending for. Count up and subtract, or write a loop.
  • 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.

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.

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

is is not an expression

is is grammar attached to an if condition, nothing more. It cannot be negated, combined, or stored:

if (!(v is null)) { }          // syntax error: expected ')', found keyword 'is'
if (v is &Circle && k == 1) { } // syntax error: expected ')', found AndAnd
let t: i32 = v is &Circle;      // syntax error: expected ';', found keyword 'is'

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

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 plain let local 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, structural in the output

The spec calls struct declarations nominal, but the emitted WebAssembly types are canonicalized structurally. Two sibling structs with the same parent and the same field types lower to the same WebAssembly type — field names are annotations and do not participate — so is cannot tell them apart.

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 twig: &Twig = new Twig { tag: 0, count: 5 };
  // Leaf and Twig both lower to `(sub final $Node (struct (field i32) (field i32)))`,
  // so this is 1, not 2.
  if (classify(twig) != 1) { return 1; }
  return 0;
}

export func check as "check";

Until this is resolved, give sibling structs distinguishable field types (a discriminant tag field read with an ordinary if is the reliable option) rather than relying on is. The classify example above is what a correct-looking program that silently does the wrong thing looks like.

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);
wastc build(return (call $step (local.get $value_0)))
wastc 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 rejected, not missing

tag and throw are in the grammar so the parser can give a real message, but the target feature set (wastc-core-gc-0) excludes exception handling:

tag Oops(i32);
// feature error: tag 'Oops' requires exception handling, which is not enabled
func f() {
  throw Oops(1);
  // feature error: exception handling ('throw') is not enabled in the selected target feature set
}

These are Feature-phase diagnostics, which means they suppress any later type errors in the same module — see diagnostic phases.

See also