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
| Construct | Kind | Notes |
|---|---|---|
if (c) { } else { } | statement | c must be exactly one i32; else if chains |
for i in a..b { } | statement | signed, exclusive, i32-only |
block label(p: T = e, ...) -> R { } | expression | label targets the end |
loop label(p: T = e, ...) -> R { } | expression | label targets the start |
br label(v, ...); | statement | explicit label required |
break label?(v, ...); | statement | leaves a loop with its result |
continue label?(v, ...); | statement | re-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
forinside its body does nothing; the bound was already read. - The induction variable is a
let, not avar.i = i + 1;istype error: local 'i' is not a mutable 'var' binding. - There is no unsigned or descending
for. Count up and subtract, or write aloop. - A
forcarries no values:break (i);istype error: 'break' on a for-loop takes no values, and the same forcontinue. - A
forcannot be labelled. To exit two nestedfors at once, wrap them in ablockandbrto 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(...) -> R | loop name(...) -> R | |
|---|---|---|
| Label points at | the end of the body | the start of the body |
br name(v) supplies | the block's result | the loop's parameters |
break name(v) | rejected | the loop's result |
continue name(v) | rejected | the loop's parameters |
| Falling off the end | allowed 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 ().
| Body | Result () | Result non-() |
|---|---|---|
block / loop | falls through | reaching the end of this control body is only valid when its result type is '()' |
| function | returns nothing | function 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.
| Test | Matches | True-arm type of v |
|---|---|---|
v is &T | non-null values whose runtime type is a subtype of T | &T |
v is ?T | the same, plus null | ?T |
v is null | only null | nullable 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:
| Source | Instruction |
|---|---|
v is &T, v is ?T | br_on_cast |
v is null | br_on_null |
| statically-false test | i32.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.
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";
| Build | Emitted 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
- Syntax cheat sheet
- GC structs and arrays for the type hierarchy
istests against - Types for
&Tvs?T - Diagnostics
- Normative rules: statements and structured control flow, type tests and flow narrowing