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
| 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 |
switch (e) { case v: { } default: { } } | statement | i32 scrutinee, comptime case values, mandatory default, no fall-through |
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 | |
unreachable | expression | traps; 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
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
for; the comparison is always signed. - 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.
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_tabletakes ani32index, and the compiler never inserts a conversion to get one. - Every
casevalue must be compile-time-known and fiti32— a literal, aparam, or acomptime forloop variable. It becomes a table index, so it cannot depend on a runtime value. The same value may not appear twice. - A
defaultarm is required, and must come last.br_tableneeds 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. Nobreakis needed to separate arms, and none is implied. breakandcontinuestill target the enclosing loop. Aswitchis not a branch target and cannot be named. This matters: if aswitchcapturedbreak, wrapping an existing loop body in one would silently change what thebreakdid.
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(...) -> 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.
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
iforswitchdiverges, the compiler still appends its ownunreachableafter the construct, because WebAssembly treats the point after it as reachable regardless. Writingunreachablein 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.
| 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";
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:
| 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 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";
| Build | Emitted 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
- 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