Raw WebAssembly operations
Reed's operators and declarations cover the shape of a normal module. They do not cover all of WebAssembly. When you need a specific instruction — an unsigned comparison, a bit reinterpretation, a packed array read, an indirect call — you write it directly:
wasm.opcode(arguments...)
wasm.opcode<immediates...>(arguments...)
This is an escape hatch from Reed's surface syntax, not from its type system. Names are resolved, operand and result types are checked, and the result still has to pass WebAssembly validation. You cannot use it to emit something the module wouldn't otherwise allow.
The set of reachable opcodes is fixed by the target's instruction catalog. This compiler
ships exactly one, reedc-core-gc-eh-simd-threads-1, with 199 opcodes, listed in full on the
opcode catalog page. This page is about how the form
works and when it's the right tool.
The immediate list
Immediates go in <...> and are compile-time only — they name a memory, a field, a type,
or an alignment. Runtime values go in (...).
| Immediate kind | Examples |
|---|---|
| Identifier | <Scratch>, <Packet, len>, <double> |
| Integer literal | <4294967295> |
| Float literal | <1.5> |
| Type | <&Square>, <?any>, <i32> |
key=value | <Scratch, offset=16, align=4> |
| List | <[...]> |
A trailing comma inside <> is allowed. The grammar is normative in
section 12.
Two consequences that bite immediately:
- Only
i32.const/i64.constaccept a leading-. The parser accepts one narrow negative form — a-immediately followed by digits — and only these two opcodes give it meaning:wasm.i32.const<-1>()compiles to(i32.const -1). Every other opcode's integer immediate, includingf32.const/f64.const, still rejects a leading-outright (wasm.f32.const<-1.5>()is a syntax error, "expected an immediate, found Minus") — write the unsigned bit pattern instead. See the limitations page for why the exception is this narrow. - A bare heap keyword is not a type. Write
<&struct>or<?struct>, never<struct>.
When you actually need it
Most of what you'd reach for already has an operator. The escape hatch is for the gaps.
| You want | Operator? | Escape hatch |
|---|---|---|
Signed /, %, <, >> | yes | — |
Unsigned /, %, <, <=, >, >= | no (method syntax, see below) | wasm.i32.div_u, wasm.i32.lt_u, … |
| Int/float conversion, sign extension, any/extern bridge | as (see below) | wasm.i64.extend_i32_s, wasm.f64.convert_i32_u, wasm.any.convert_extern, … |
| Bit reinterpretation | no | wasm.i64.reinterpret_f64, … |
clz / ctz / popcnt / rotl / rotr / sqrt / abs / ceil / floor / trunc / nearest / min / max / copysign | no (method syntax, see below) | wasm.i32.popcnt, wasm.f64.min, … |
| Linear memory load/store | no | wasm.i32.load, wasm.i32.store8, … |
| Default-initialized struct/array | new Name {}, new Name[count]{} (see below) | wasm.struct.new_default, wasm.array.new_default |
| Memory size, growth | #Mem, Mem.grow(delta) (see below) | wasm.memory.size, wasm.memory.grow |
| Memory bulk fill, copy | Mem.fill(...), Dest.copy<Src>(...) (see below) | none — no wasm.* form |
| Struct field read/write | -> | wasm.struct.get / .set (needed for packed fields) |
| Array element, length | a[i], #a | wasm.array.get_s/_u (needed for packed elements) |
| Array bulk copy/fill | no (method syntax, see below) | wasm.array.copy, wasm.array.fill |
| Table slot, size, growth | T[i], #T, Table.grow(init, delta) (see below) | wasm.call_indirect, wasm.table.grow |
| Table bulk fill, copy | Table.fill(...), Dest.copy<Src>(...) (see below) | none — no wasm.* form |
| Type test / narrowing | is | wasm.ref.test / wasm.ref.cast |
Every Reed arithmetic and comparison operator is signed. That is the single most common reason to drop down:
func check() -> i32 {
let all_ones: i32 = wasm.i32.const<4294967295>();
// Signed: -1 / 2 == 0. Unsigned: 4294967295 / 2 == 2147483647.
if (all_ones / 2 != 0) { return 1; }
if (wasm.i32.div_u(all_ones, 2) != 2147483647) { return 2; }
// Signed: -1 < 1. Unsigned: 4294967295 > 1.
if ((all_ones < 1) != true) { return 3; }
if (wasm.i32.lt_u(all_ones, 1) != 0) { return 4; }
// Sign-extend vs zero-extend into i64 — same bits, different answers.
if (wasm.i64.extend_i32_s(all_ones) != 0 - 1) { return 5; }
if (wasm.i64.extend_i32_u(all_ones) != 4294967295) { return 6; }
return 0;
}
export func check as "check";
The other unavoidable case is treating a float's bits as an integer. There is no operator spelling for that, and there shouldn't be:
func exponent_of(x: f64) -> i32 {
let bits: i64 = wasm.i64.reinterpret_f64(x);
let shifted: i64 = wasm.i64.shr_u(bits, 52);
return wasm.i32.and(wasm.i32.wrap_i64(shifted), 2047);
}
func check() -> i32 {
// IEEE 754 binary64 stores the exponent biased by 1023.
if (exponent_of(1.0) != 1023) { return 1; }
if (exponent_of(2.0) != 1024) { return 2; }
if (exponent_of(0.5) != 1022) { return 3; }
let bits: i32 = wasm.i32.reinterpret_f32(1.5);
if (bits != 1069547520) { return 4; }
if (wasm.f32.reinterpret_i32(bits) != 1.5) { return 5; }
return 0;
}
export func check as "check";
Method-postfix numeric operations
The rows marked "method syntax" above no longer need the escape hatch at all: Reed's default
core library declares numeric methods such as i32.clz and f64.sqrt, so they are reachable as
ordinary receiver.method(args) calls (full list) (grammar in spec section
7). The core method
wraps the identical instruction as the wasm.* spelling — x.clz() and wasm.i32.clz(x) emit
the same i32.clz — so pick whichever reads better; the opcode
catalog marks every opcode that has both forms.
- No arguments, same-type result:
.clz(),.ctz(),.popcnt()oni32/i64;.abs(),.sqrt(),.ceil(),.floor(),.trunc(),.nearest()onf32/f64. - One same-type argument, same-type result:
.rotl(n),.rotr(n),.div_u(n),.rem_u(n)oni32/i64;.min(y),.max(y),.copysign(y)onf32/f64only — core WebAssembly has no integermin/maxinstruction, so those two names don't exist oni32/i64at all. - One same-type argument,
i32result:.lt_u(n),.le_u(n),.gt_u(n),.ge_u(n)oni32/i64. - One
i64argument, twoi64results:.mul_wide_s(y),.mul_wide_u(y)oni64only — see wide arithmetic below.
Core only declares these exact qualified numeric methods — a reference receiver, an unknown
name, or a method that exists but not for that specific type (.min() on an i32,
.mul_wide_u() on an i32) is a type error unless you declare a matching method yourself.
Method syntax itself is open: you can declare your own methods on any type, including a
reference or v128 receiver, with a qualified function name. What you cannot do
is redeclare a core method's exact qualified name, such as func i32.clz(...) or
func f64.sqrt(...), because the default core library already owns it.
func check() -> i32 {
let a: i32 = 6;
if (a.rotl(1) != 12) { return 1; }
if (a.clz() != 29) { return 2; }
let neg_one: i32 = 0 - 1;
// Same unsigned/signed split as the escape hatch's div_u/lt_u above, spelled as methods.
if (neg_one.div_u(2) != 2147483647) { return 3; }
if (neg_one.lt_u(0) != 0) { return 4; }
let x: f64 = -2.5;
if (x.abs() != 2.5) { return 5; }
if (x.floor() != -3.0) { return 6; }
return 0;
}
export func check as "check";
A literal receiver needs parentheses: 8.clz() lexes as the float literal 8. followed
by clz, not as an integer literal followed by a method call, because a bare decimal
point after digits always starts a float literal's fractional part (section 3's
maximal-munch tokenization). Write (8).clz().
as conversions
The row marked "as (see below)" above also no longer needs the escape hatch: expr as type and expr as type.s/expr as type.u are ordinary expression syntax (grammar in
spec section 7),
and lower to the identical instruction as the wasm.* spelling — the opcode
catalog marks every opcode that has both forms.
Which form applies depends on whether the (source type, target type) pair is signedness-ambiguous:
- Plain
as, no suffix:wrap(i64 as i32),promote(f32 as f64),demote(f64 as f32). Adding a.s/.usuffix here is a type error — there is nothing ambiguous to disambiguate. as type.s/as type.u: everywhere the target's signedness changes the result —i32 as i64.s/.u(extend),i32/i64 as f32/f64.s/.u(convert),f32/f64 as i32/i64.s/.u(trunc, which traps on an out-of-range or NaN value, same as the escape hatch). Omitting the suffix on one of these is a type error naming the suffix you need.as ?any/as &any/as ?extern/as &extern: the any/extern host reference bridge. The operand must already be a reference in the opposite family, and the result is always nullable no matter which nullability marker you wrote on the target.as &i31: boxes ani32into a non-null&i31, lowering toref.i31. No suffix is permitted;as ?i31is a type error sinceref.i31never produces a nullable result.as i32.s/as i32.uon an&i31/?i31operand: unboxes back toi32, lowering toi31.get_s/i31.get_u. The suffix is required. See the i31 nullability asymmetry for the nullable-operand lint.
func check() -> i32 {
// Unambiguous: the (source, target) pair alone selects the instruction.
let big: i64 = 4294967296;
if ((big as i32) != 0) { return 1; }
// Ambiguous: extend, convert, and trunc each need a suffix naming the
// signedness of the integer side.
let neg_one: i32 = 0 - 1;
if ((neg_one as i64.s) != 0 - 1) { return 2; }
if ((neg_one as i64.u) != 4294967295) { return 3; }
if ((neg_one as f64.u) != 4294967295.0) { return 4; }
let almost_four: f64 = 3.9;
if ((almost_four as i32.s) != 3) { return 5; }
// The any/extern host bridge, also reachable through `as`.
let e: ?extern = null;
let a: ?any = e as ?any;
if (a is null) { return 0; }
return 6;
}
export func check as "check";
Same-width bit reinterpretation stays escape-hatch-only. i32/f32 and i64/f64
are the one case as deliberately does not cover: reinterpreting an operand's bits and
converting its numeric value are two different, equally valid operations, and the
(source, target) type pair alone can't tell as which one you mean the way it can for
wrap/promote/demote/extend/convert/trunc above. i32 as f32 always means
"convert this integer's value to the nearest float" — reach for
wasm.f32.reinterpret_i32 (see above) when you actually want the bits.
as sits just above unary in precedence and does not chain specially: a as i32 as i64 parses as (a as i32) as i64, each cast fully resolved before the next applies.
Default-initialized allocation
struct.new_default and array.new_default also no longer need the escape hatch:
new Name {} and new Name[count]{} are ordinary construction syntax (grammar in
spec section 6/6.1),
and lower to the identical instructions as the wasm.* spellings — the opcode
catalog marks both with New.
new Name {}, empty braces, is a reinterpretation of the already-legal zero-field-initializer case, not new grammar. WhenNamehas no fields it means what it always has — an empty struct construction. WhenNamehas one or more fields, it instead default-initializes every one of them and lowers tostruct.new_default.new Name[count]{}is a genuinely new array-construction alternative, since a count has to come from somewhere and an empty brace list already means "zero elements" for the existingnew Name { ... }form. It default-initializescountelements and lowers toarray.new_default. The two array forms are mutually exclusive at the grammar level — a bracketed count is never combined with a non-empty brace list — so there is nothing to reject after the fact.
Both require every field or element type to be defaultable: a numeric type, or a
nullable ?T reference. A non-null &T field or element has no zero value, so it makes
the whole construction a compile error naming the offending field or element type — the
same defaultability rule the escape hatch's own struct.new_default/array.new_default
arms already enforce.
struct Counter {
count: mut i32,
label: ?Counter,
}
array Samples { mut i32 }
func check() -> i32 {
// Every field defaulted: i32 -> 0, the nullable ?Counter -> null.
let c: &Counter = new Counter {};
if (c->count != 0) { return 1; }
if (!(c->label is null)) { return 2; }
// Runtime-sized array, every element defaulted to 0.
let s: &Samples = new Samples[5]{};
if (#s != 5) { return 3; }
if (s[0] != 0) { return 4; }
if (s[4] != 0) { return 5; }
return 0;
}
export func check as "check";
A struct or array with only non-null reference fields/elements can't be
default-initialized at all — write the full new Name { field: expression, ... } form
for those instead.
Array bulk operations
array.copy and array.fill have dedicated .method(args) postfix syntax too, the same
mechanism the Method-postfix numeric
operations section above uses (grammar in spec
section 7) —
except these two are reserved for a non-null declared-array receiver instead of a
numeric one, are statements rather than value-producing expressions, and are dispatched
separately from the numeric method catalog rather than being one more entry in it. The
opcode catalog marks both Method.
dest.copy(dest_offset, src, src_offset, count)lowers toarray.copy, withdest's declared array type as the first instruction immediate andsrc's as the second —srcis a second non-null declared-array reference, and its element type must be assignable todest's with matching packedness.arr.fill(offset, value, count)lowers toarray.fill.
Both require the receiver's element type to be declared mut — for .copy, the receiver
is dest, so this is one requirement on dest, not two; src's element type need not
be mut. Both evaluate the receiver then each argument left to right exactly once,
matching the existing indexed-write evaluation-order rule.
array Samples { mut i32 }
func check() -> i32 {
let dest: &Samples = new Samples { 0, 0, 0, 0 };
let src: &Samples = new Samples { 10, 20, 30, 40 };
// dest.copy(dest_offset, src, src_offset, count) -> array.copy
dest.copy(1, src, 0, 2);
if (dest[0] != 0) { return 1; }
if (dest[1] != 10) { return 2; }
if (dest[2] != 20) { return 3; }
if (dest[3] != 0) { return 4; }
// arr.fill(offset, value, count) -> array.fill
dest.fill(2, 99, 2);
if (dest[2] != 99) { return 5; }
if (dest[3] != 99) { return 6; }
return 0;
}
export func check as "check";
Both are statements only, arity zero — neither can be used as an expression or a
return operand, the same rule that applies to any other zero-result call.
It is checked, not spliced
Nothing about wasm.* is textual. Each of these is rejected before any WAT is produced:
wasm.i32.add(1) 'i32.add' expects 2 arguments, found 1
wasm.i32.store<Nope>(0, 1) unknown memory 'Nope'
wasm.struct.get<S, b>(s) struct 'S' has no field 'b'
wasm.struct.set<S, a>(s, 2) 'struct.set' target field is not declared 'mut'
wasm.local.set<x>(2) local 'x' is not a mutable 'var' binding
wasm.i32.store<M, align=3>(0, 1) 'align' must be a power of two
wasm.i32.store8<M, align=4>(0, 1) 'align=4' exceeds the natural alignment for 'i32.store8'
wasm.ref.cast<&Shape>(square) 'ref.cast' target heap type must be a subtype
of the operand heap type
global G: i32 = wasm.i32.add(1, 2) 'i32.add' is not marked as a constant expression
in a global initializer
Identifier immediates are resolved against the real module, not passed through. A struct
field immediate is a name in source and becomes a flattened index in the output —
wasm.struct.get<Packet, len>(packet) emits (struct.get $Packet 1 ...). If you
reorder the fields, the source stays correct and the index changes.
Immediates that WebAssembly genuinely wants verbatim, like memory arguments, do pass
through: wasm.i32.store<Scratch, offset=16, align=4>(0, v) emits
(i32.store $Scratch offset=16 align=4 ...).
Linear memory
Every memory operation must name its memory; Reed has no default memory. After the name
come optional offset=N and align=N, in either order, each at most once. align must
be nonzero, a power of two, and no larger than the opcode's natural alignment — it is a
promise to the engine, not a request, and lying about it is a validation error rather
than a slowdown. Addresses are always i32; there is no memory64.
Loads take one argument (the address); stores take two (address, value). The offset
immediate is folded into the address, so <offset=16>(3) and <offset=0>(19) are the
same byte.
memory Scratch(1);
func check() -> i32 {
// 0xFEEDBA98 written as one aligned i32 at byte 16.
wasm.i32.store<Scratch, offset=16, align=4>(0, 4275878552);
if (wasm.i32.load<Scratch, offset=16>(0) != 4275878552) { return 1; }
// WebAssembly is little-endian: byte 16 is the low byte, 0x98 = 152.
if (wasm.i32.load8_u<Scratch, offset=16>(0) != 152) { return 2; }
if (wasm.i32.load8_u<Scratch, offset=19>(0) != 254) { return 3; }
// Same byte through the signed load: 0x98 -> -104.
if (wasm.i32.load8_s<Scratch, offset=16>(0) != 0 - 104) { return 4; }
// offset is added to the address operand, so this is byte 19 again.
if (wasm.i32.load8_u<Scratch, offset=16>(3) != 254) { return 5; }
let pages: i32 = wasm.memory.size<Scratch>();
if (pages != 1) { return 6; }
return 0;
}
export func check as "check";
The load/store family is where the _s/_u split matters most: i32.load8_u and
i32.load8_s read the same byte and disagree about its sign. Nothing in the type system
will catch picking the wrong one.
Structs and arrays
-> and a[i] cover ordinary field and element access. new Name {}/new Name[count]{} (default-init, when the field/element type is defaultable) and
.copy(...)/.fill(...) (above) now cover most of what used to require the escape
hatch here. Drop to raw operations for what's left: struct.new by position, array.new
sized at runtime with an explicit fill value, and every packed access. The example below
still reaches for wasm.array.new_default/wasm.array.copy/wasm.array.fill in their
raw wasm.* spelling — the dedicated syntax would work just as well here too, since
Bytes's i8 element is numeric and therefore defaultable; this is simply demonstrating
that the raw spelling stays available underneath.
struct.new takes one argument per flattened field, parent fields first.
array.copy takes the destination type first.
struct Header {
tag: i32,
}
struct Packet : Header {
len: mut i32,
}
array Bytes { mut i8 }
array Words { mut i32 }
func check() -> i32 {
// One argument per flattened field: Header.tag, then Packet.len.
let packet: &Packet = wasm.struct.new<Packet>(7, 3);
if (wasm.struct.get<Packet, tag>(packet) != 7) { return 1; }
wasm.struct.set<Packet, len>(packet, 4);
if (wasm.struct.get<Packet, len>(packet) != 4) { return 2; }
// Packed i8 element: _s / _u are mandatory here.
let bytes: &Bytes = wasm.array.new_default<Bytes>(4);
wasm.array.set<Bytes>(bytes, 0, 200);
let raw: i32 = wasm.array.get_u<Bytes>(bytes, 0);
let signed: i32 = wasm.array.get_s<Bytes>(bytes, 0);
if (raw != 200) { return 3; }
if (signed != 0 - 56) { return 4; }
// Non-packed i32 element: plain array.get is mandatory here.
let words: &Words = wasm.array.new<Words>(9, 3);
if (wasm.array.get<Words>(words, 2) != 9) { return 5; }
if (wasm.array.len(words) != 3) { return 6; }
// Destination type first, then source.
let dest: &Bytes = wasm.array.new_default<Bytes>(4);
wasm.array.copy<Bytes, Bytes>(dest, 1, bytes, 0, 2);
let copied: i32 = wasm.array.get_u<Bytes>(dest, 1);
if (copied != 200) { return 7; }
wasm.array.fill<Bytes>(dest, 0, 5, 4);
let filled: i32 = wasm.array.get_u<Bytes>(dest, 3);
if (filled != 5) { return 8; }
return 0;
}
export func check as "check";
Packedness is enforced in both directions
A packed (i8/i16) field or element requires _s/_u. A non-packed one
requires the plain opcode. Both mismatches are errors:
wasm.array.get<Bytes>(b, 0) packed field/element requires 'array.get_s'/'array.get_u',
not 'array.get'
wasm.array.get_u<Words>(w, 0) non-packed field/element requires 'array.get',
not 'array.get_s'/'array.get_u'
This is deliberate. A packed read has no single correct sign, so the compiler refuses to
pick one for you; a non-packed read has no sign question at all, so accepting _u there
would suggest a choice that doesn't exist.
Casts, tests, and the odds and ends
ref.test and ref.cast only narrow. The immediate's heap type must be a subtype of
the operand's — an upcast is an error, because an upcast is never a question worth asking
at runtime. In most code the is operator and its flow narrowing are the better tool;
reach for ref.cast when you need the cast as an expression rather than a branch.
select takes three arguments in the order (a, b, cond) and evaluates both a and b.
Its own immediate check runs separately from the general one below and fires at the
syntax phase — wasm.select<i32>(...) is a syntax error, not a type error.
struct Shape {
sides: i32,
}
struct Square : Shape {
edge: i32,
}
func classify(shape: &Shape) -> i32 {
// <&Shape> here would be rejected: it is not a subtype of &Shape's own type.
if (wasm.ref.test<&Square>(shape) == 1) {
let square: &Square = wasm.ref.cast<&Square>(shape);
return square->edge;
}
return 0;
}
func check() -> i32 {
let square: &Square = new Square { sides: 4, edge: 3 };
let plain: &Shape = new Shape { sides: 5 };
if (classify(square) != 3) { return 1; }
if (classify(plain) != 0) { return 2; }
// ref.eq is identity, and both operands must sit below `eq`.
if (wasm.ref.eq(square, square) != 1) { return 3; }
if (wasm.ref.eq(square, plain) != 0) { return 4; }
// (a, b, cond) — the condition is last, and both arms are evaluated.
let taken: i32 = wasm.select(10, 20, 1);
let skipped: i32 = wasm.select(10, 20, 0);
if (taken != 10) { return 5; }
if (skipped != 20) { return 6; }
wasm.drop(classify(square));
wasm.nop();
return 0;
}
export func check as "check";
wasm.unreachable() is polymorphic: it adopts whatever type the context expects and
marks the path as diverging. It is the honest way to close out a branch you have proven
can't be taken — for example after narrowing a ?Callback you know is populated.
Prefer the unreachable keyword for this one. It
emits the identical instruction, is not shaped like a call to something that is not a
function, and reads as a statement where it almost always appears: unreachable;. The
escape-hatch spelling still works and is not deprecated, but it has surface syntax now, so
it belongs in the same category as ref.test and select — listed here for completeness,
written the other way in practice.
Ternary, assignment expressions, and general is
Three of the opcodes just shown — select, local.tee, and ref.test/ref.is_null —
also have dedicated surface syntax (spec section 7
and section 9) that
covers the common case without reaching for wasm.* at all:
condition ? then : elselowers toselect. Likeselectitself, and unlikeif/elseor&&/||, it is not short-circuiting — boththenandelseare always evaluated, in source order, before the choice is made.(name = value), parenthesized and used as an expression, lowers tolocal.tee— but only whennameis a mutable localvar. Assigning to a global, a struct field, or an array element this way is a compile error: WebAssembly has onlylocal.tee, noglobal.tee/struct.tee/array.tee. The unparenthesized statement formname = value;is untouched and still lowers to plainlocal.set.value is null/value is &Heap/value is ?Heapnow work as a plaini32-valued expression anywhere, not just insideif (...). Only theif-condition form narrows the tested local's type in its true arm; the identical test used elsewhere still evaluates to the samei32, but changes no type.
struct Cell {
value: i32,
}
func check() -> i32 {
// Ternary: both branches run, non-short-circuiting -- select underneath.
let a: i32 = 3;
let b: i32 = 5;
let bigger: i32 = a < b ? b : a;
if (bigger != 5) { return 1; }
// Assignment as an expression: local var only, lowers to local.tee.
var counter: i32 = 0;
let previous: i32 = (counter = counter + 1);
if (previous != 1) { return 2; }
if (counter != 1) { return 3; }
// General 'is', outside any if: plain ref.is_null, no narrowing.
let maybe: ?Cell = null;
let absent: i32 = maybe is null;
if (absent != 1) { return 4; }
// General 'is' on a heap type, also outside an if: plain ref.test.
let present: &Cell = new Cell { value: 7 };
let matches: i32 = present is &Cell;
if (matches != 1) { return 5; }
return 0;
}
export func check as "check";
is binds looser than the ternary and is checked only once, at the very end of a full
expression — a ternary's own branches don't get it for free. cond ? a is null : b is a
syntax error; write cond ? (a is null) : b. This is one instance of a general rule —
an unparenthesized is can't be the operand of any operator, not just the ternary — see
control-flow.md's "General is as a boolean
expression" for the full statement.
The i31 nullability asymmetry
ref.i31 produces a non-null &i31. i31.get_s and i31.get_u accept a
nullable ?i31. The two do not mirror each other, and the looser side is the reader:
func check() -> i32 {
// Produces &i31 — non-null.
let boxed: &i31 = wasm.ref.i31(4294967295);
// Accepts ?i31, so &i31 passes by subtyping. The payload is 31 bits,
// so the same value sign-extends one way and zero-extends the other.
if (wasm.i31.get_s(boxed) != 0 - 1) { return 1; }
if (wasm.i31.get_u(boxed) != 2147483647) { return 2; }
// A genuinely null ?i31 also satisfies i31.get_*'s operand type.
// Nothing rejects it at compile time; it traps at runtime.
let missing: ?i31 = null;
if (wasm.ref.is_null(missing) != 1) { return 3; }
return 0;
}
export func check as "check";
Assigning missing to a &i31 is a type error, as you'd expect. Passing it to
wasm.i31.get_u is not. That asymmetry is real WebAssembly semantics, faithfully
reproduced — narrow first if the value can be null.
Boxing also has dedicated as-cast sugar (spec section 7): value as &i31 lowers to
ref.i31, identical to the escape-hatch call above. Unboxing has the same sugar,
value as i32.s/value as i32.u, lowering to i31.get_s/i31.get_u. The nullability
asymmetry above still applies — the operand may be &i31 or ?i31 — but unboxing a
statically nullable ?i31 this way also triggers the nullable-i31-unbox lint,
pointing at an is-narrowing null check or an explicit wasm.ref.cast<&i31>(...) as
the fix. See diagnostics.
func check() -> i32 {
// Same two operations as above, spelled with `as` instead of `wasm.*`.
let boxed: &i31 = 4294967295 as &i31;
if ((boxed as i32.s) != 0 - 1) { return 1; }
if ((boxed as i32.u) != 2147483647) { return 2; }
return 0;
}
export func check as "check";
Tables and indirect calls
A table's declared type must be ?FunctionType, where FunctionType is a named
function type. ?func is rejected: an untyped funcref table gives call_indirect
nothing to check against. Each initializer entry is null or &function_name — a bare
name is a syntax error — and each referenced function must match the table's type
exactly, with no subtyping.
call_indirect takes <TableName, FuncTypeName> and puts the table index last,
after the call arguments. table.grow takes the fill value first, then the delta,
and returns the previous size.
type Callback = func(i32) -> i32;
func double(x: i32) -> i32 { return x * 2; }
func negate(x: i32) -> i32 { return 0 - x; }
table Callbacks(4 ?Callback) = { &double, &negate, null };
// Raw: index last, and the null check is the engine's, at runtime.
func dispatch_raw(slot: i32, x: i32) -> i32 {
return wasm.call_indirect<Callbacks, Callback>(x, slot);
}
// Language-level: read the slot, narrow away the null, then call.
func dispatch_checked(slot: i32, x: i32) -> i32 {
let entry: ?Callback = Callbacks[slot];
if (entry is &Callback) {
return entry(x);
}
return 0;
}
func check() -> i32 {
if (dispatch_raw(0, 21) != 42) { return 1; }
if (dispatch_raw(1, 21) != 0 - 21) { return 2; }
if (dispatch_checked(0, 21) != 42) { return 3; }
if (dispatch_checked(2, 21) != 0) { return 4; }
// Value first, then delta; returns the size before growing.
let previous: i32 = wasm.table.grow<Callbacks>(wasm.ref.func<double>(), 2);
if (previous != 4) { return 5; }
if (wasm.table.size<Callbacks>() != 6) { return 6; }
if (dispatch_raw(5, 21) != 42) { return 7; }
wasm.table.set<Callbacks>(2, wasm.ref.func<negate>());
if (dispatch_checked(2, 21) != 0 - 21) { return 8; }
return 0;
}
export func check as "check";
dispatch_raw and dispatch_checked are not equivalent. The raw form traps on a null
slot; the narrowed form handles it. Prefer indexing plus is unless you specifically
want the trap. Note also that wasm.ref.func<f>() requires an exact signature match
against a named function type in context — &double in the table initializer and
wasm.ref.func<double>() are subject to the same rule.
Memory and table growth, fill, and copy
memory.size, memory.grow, and table.grow — all three shown above through the raw
escape hatch — also have dedicated surface syntax (spec section
10), marked
Growth on the opcode catalog, that covers the
common case without wasm.* at all:
#Memreachesmemory.size, the same#-for-length operator already used for an array's length and a table's size (#arr,#Table).Mem.grow(delta)reachesmemory.grow;Table.grow(init, delta)reachestable.grow. Both return the previous size — pages for a memory, elements for a table — and both take their growth amount (delta) as an ordinaryi32argument, no immediate involved. A table'sinitfills every new slot and follows the same value grammar as its declaration's own initializer:nullor&function_name.
memory.fill, memory.copy, table.fill, and table.copy are different: they have
no wasm.<opcode>(...) spelling at all (the opcode
catalog lists all four for reference, with their
Immediates column saying so directly). Dedicated syntax is the only way to reach them:
-
Mem.fill(offset, value, len)/Table.fill(offset, value, len)fill a region with a repeated value — ani32byte value for a memory, an element value matching the table's declared type for a table. -
DestMem.copy(SrcMem, dest_offset, src_offset, len)/DestTable.copy(SrcTable, dest_offset, src_offset, len)copylenelements from a second declared memory or table into the receiver. The first argument is a declaration argument: it names a declaration rather than producing a value, is resolved at compile time, and is never evaluated..init(...)(below, in Segment init and drop) takes one too.The older spelling put that name in an immediate —
DestMem.copy<SrcMem>(...)— and is still accepted, but deprecated. The emitted WAT comment mirrors whichever spelling the source used, since the point of those comments (spec 15) is to read back as the program that produced them.
All seven are statements or expressions with arity zero or one, matching their
underlying instruction exactly: .grow(...) and #Mem produce an i32; .fill(...)
and .copy(...) produce nothing and may only appear as expression statements.
type Callback = func() -> i32;
func answer() -> i32 {
return 42;
}
memory Src(1);
memory Dest(1);
table Handlers(1 ?Callback) = { &answer };
table CopySrc(1 ?Callback) = { &answer };
table CopyDest(1 ?Callback) = { null };
func check() -> i32 {
// `#Mem` extends the existing `#`-for-length operator to a declared memory:
// current size in pages.
if (#Src != 1) { return 1; }
// `.grow(delta)` returns the memory's PREVIOUS size, in pages.
if (Src.grow(1) != 1) { return 2; }
if (#Src != 2) { return 3; }
// Same idea for a table, except each new slot is initialized to `init`
// (`null` or `&function_name`) and growth counts elements, not pages.
if (#Handlers != 1) { return 4; }
if (Handlers.grow(null, 2) != 1) { return 5; }
if (#Handlers != 3) { return 6; }
// `.fill(offset, value, len)`: a memory's `value` is an `i32` byte value;
// a table's `value` matches its declared element type.
Src.fill(0, 65, 4);
if (wasm.i32.load8_u<Src>(0) != 65) { return 7; }
if (wasm.i32.load8_u<Src>(3) != 65) { return 8; }
// Slot 1 was left null by `.grow` above; `.fill` overwrites all three slots.
Handlers.fill(0, &answer, 3);
let slot_one_filled: i32 = Handlers[1] is null;
if (slot_one_filled == 1) { return 9; }
// `.copy(Source, dest_offset, src_offset, len)`: the receiver is the
// destination, and the first argument NAMES a second declared memory/table --
// a declaration argument, resolved at compile time and never evaluated.
Dest.copy(Src, 0, 0, 4);
if (wasm.i32.load8_u<Dest>(0) != 65) { return 10; }
CopyDest.copy(CopySrc, 0, 0, 1);
let copy_dest_empty: i32 = CopyDest[0] is null;
if (copy_dest_empty == 1) { return 11; }
return 0;
}
export func check as "check";
Segment init and drop
elem Name: Type = { ... }; (grammar in spec section
5) declares a passive WebAssembly
element segment — parallel to a data declaration, but always passive; there is no
at offset alternative. Type MUST be a nullable reference to a named function type
(?FunctionType), the same requirement a table declaration's own type carries, and each
entry follows the identical table-entry-list grammar a table's own inline initializer
uses: null or &function_name, where every &function_name MUST exactly match the
declared function type's signature. The elem name lives in its own distinct elem
namespace, parallel to the data namespace data declarations use.
memory.init/table.init/data.drop/elem.drop have no wasm.<opcode>(...)
spelling at all (the opcode catalog lists all four for
reference, with their Immediates column saying so directly) — the same "dedicated syntax
is the only way to reach them" situation as memory.fill/memory.copy/table.fill/
table.copy above:
Mem.init(SomeData, dest_offset, src_offset, len)/Table.init(Handlers, dest_offset, src_offset, len)copylenelements starting atsrc_offsetin the named passive data or elem segment into the receiver memory or table starting atdest_offset, and lower tomemory.init/table.init. The first argument is a declaration argument, exactly as in.copy(...)above: it names a declaration, is resolved at compile time, and is never evaluated. The older.init<SomeData>(...)immediate spelling is still accepted but deprecated.SomeData.drop()/Handlers.drop()take no arguments and permanently mark the named passive segment dropped, lowering todata.drop/elem.drop. A segment MAY be dropped whether or not it was ever consumed by.init(...)first — dropping only prevents further use, it does not require the segment to still hold data.
All four are statements of arity zero, matching their underlying instruction: none of
them produce a value, so none may appear as an expression or a return operand.
type Callback = func() -> i32;
func answer() -> i32 {
return 42;
}
data Greeting = "hi!!";
memory Scratch(1);
elem Handlers: ?Callback = { &answer, null };
table Dest(2 ?Callback) = { null, null };
func check() -> i32 {
// `Mem.init(Data, dest_offset, src_offset, len)` copies from a passive
// data segment into linear memory and lowers to `memory.init`.
Scratch.init(Greeting, 0, 0, 4);
if (wasm.i32.load8_u<Scratch>(0) != 104) { return 1; }
if (wasm.i32.load8_u<Scratch>(3) != 33) { return 2; }
// `Table.init(Elem, dest_offset, src_offset, len)` copies from a passive
// elem segment into a table and lowers to `table.init`.
Dest.init(Handlers, 0, 0, 2);
let dest0_null: i32 = Dest[0] is null;
if (dest0_null == 1) { return 3; }
let dest1_null: i32 = Dest[1] is null;
if (dest1_null == 0) { return 4; }
// `.drop()` permanently marks a passive segment dropped -- legal even
// after the segment has already been fully consumed above.
Greeting.drop();
Handlers.drop();
return 0;
}
export func check as "check";
Segment-sourced array construction
array.new_data/array.new_elem also have no wasm.<opcode>(...) spelling — two
more array-construction alternatives (grammar in spec section
6/6.1) reach them
instead, distinguished by the keyword right after [count]:
new Name[count] data<D>(offset)requiresName's element type to be a value type (numeric or packedi8/i16) andDto name a declared data segment; it lowers toarray.new_data. A reference-typed element is a compile error —array.new_datacannot produce references.new Name[count] elem<E>(offset)requiresName's element type to be a reference type assignable fromE's declared elem type (always a nullable reference toE's function type) andEto name a declared elem segment; it lowers toarray.new_elem.
D/E are immediates — declaration references resolved at compile time, not values to
evaluate, exactly like .copy<...>(...)'s <Name>. count and offset are each i32
and evaluated exactly once, in that left-to-right source order — the fact that
array.new_data/array.new_elem expect their stack operands in the opposite order is a
lowering detail with no observable effect on evaluation order. Both forms produce the
non-null type &Name, the same as every other array-construction form.
data Bytes = "\u{01}\u{02}\u{03}\u{04}";
array I32Values { mut i8 }
type Callback = func() -> i32;
func answer() -> i32 {
return 42;
}
elem Handlers: ?Callback = { &answer, null };
array Callbacks { ?Callback }
func check() -> i32 {
// new Name[count] data<D>(offset) -> array.new_data
let v: &I32Values = new I32Values[4] data<Bytes>(0);
if (#v != 4) { return 1; }
if (v[0].u != 1) { return 2; }
if (v[3].u != 4) { return 3; }
// new Name[count] elem<E>(offset) -> array.new_elem
let cbs: &Callbacks = new Callbacks[2] elem<Handlers>(0);
if (#cbs != 2) { return 4; }
let cbs1_null: i32 = cbs[1] is null;
if (cbs1_null == 0) { return 5; }
return 0;
}
export func check as "check";
Locals, globals, and constant initializers
local.get/set/tee and global.get/set exist mainly so generated or
mechanically-translated code can name a binding explicitly. Hand-written Reed should just
use the identifier. local.set/tee require a var; global.set requires a mut
global.
Only eight opcodes are constant-expression eligible, and therefore usable in a global
initializer: i32.const, i64.const, f32.const, f64.const, ref.null, ref.func,
ref.i31, and global.get (imported immutable globals only). Everything else gets
'<op>' is not marked as a constant expression in a global initializer.
type Handler = func(i32) -> i32;
func identity(x: i32) -> i32 { return x; }
global Origin: &Handler = wasm.ref.func<identity>();
global Boxed: &i31 = wasm.ref.i31(11);
global Empty: ?any = wasm.ref.null<?any>();
global Mask: i32 = wasm.i32.const<4278255360>();
global Counter: mut i32 = 0;
func check() -> i32 {
wasm.global.set<Counter>(wasm.global.get<Counter>() + 5);
if (Counter != 5) { return 1; }
// local.tee writes and yields the value; the binding must be `var`.
var scratch: i32 = 0;
let teed: i32 = wasm.local.tee<scratch>(Mask);
if (teed != wasm.local.get<scratch>()) { return 2; }
if (scratch != 4278255360) { return 3; }
if (wasm.ref.is_null(Empty) != 1) { return 4; }
if (wasm.i31.get_u(Boxed) != 11) { return 5; }
// A &Handler value is called with the language's own call syntax.
if (Origin(41) != 41) { return 6; }
return 0;
}
export func check as "check";
ref.null insists on the ? spelling of its immediate — wasm.ref.null<any>() is a
syntax error, because the result is nullable by construction and the immediate should say
so.
Sharp edges
A typo is a feature error, not an unknown-name error. The catalog is a hand-written
dispatch with a catch-all final arm, so wasm.i32.addd(...) produces:
feature error: 'i32.addd' is not available in the selected target's
instruction catalog (reedc-core-gc-eh-simd-threads-1)
Read that as "not in the catalog", which covers both "you misspelled it" and "that proposal isn't in this target". It does not distinguish them.
A stray immediate on a zero-immediate opcode is rejected, not ignored. Every opcode
that structurally never takes one — every plain numeric op, plus drop, nop,
unreachable, ref.eq, ref.is_null, ref.i31, and the i31.get_s/_u pair among
others — checks its immediate list is empty before doing anything else:
wasm.i32.add<Nonsense, 42>(20, 22) 'i32.add' does not take any immediates
This used to be a real gap: the check didn't exist, so wasm.i32.add<Junk>(a, b)
type-checked and silently discarded <Junk> instead of being rejected. See the
limitations page for the historical
writeup. select's own equivalent check (above) is separate from this one and fires at
the syntax phase instead of the type phase.
Some opcodes can't drive operator type inference. When a binary operator's other side
is an untyped literal, the compiler asks the wasm expression what type it produces —
and that lookup is a separate, deliberately partial table. It knows every
i32.*/i64.*/f32.*/f64.* opcode plus a hand-listed set: array.get,
array.get_s/_u, struct.get, struct.get_s/_u, array.len, table.size,
table.grow, table.get, local.get, local.tee, global.get, call,
call_indirect, memory.size, memory.grow, ref.i31, ref.func, and the
i31.get_*/ref.* predicates. (memory.size, memory.grow, array.get_s/_u,
struct.get_s/_u, and call_indirect used to be missing from this list — a real gap,
closed since; see the limitations page for
the historical writeup.) It does not know select:
if (wasm.select(10, 20, 1) != 10) { ... }
type error: cannot determine the operand type for this operator; add a typed binding
The fix is what the message says — bind it first:
let taken: i32 = wasm.select(10, 20, 1);
if (taken != 10) { return 5; }
Saturating conversions
wasm.i32.trunc_sat_f64_s(x) and its seven siblings clamp an out-of-range float instead of
trapping: too large saturates to the maximum, too small to the minimum, and a NaN becomes
zero.
export func check as "check";
func check() -> i32 {
let too_big: i32 = wasm.i32.trunc_sat_f64_s(1.0e300);
let too_small: i32 = wasm.i32.trunc_sat_f64_s(-1.0e300);
let not_a_number: i32 = wasm.i32.trunc_sat_f64_s(0.0 / 0.0);
if (too_big != 2147483647) { return 1; }
if (too_small != -2147483648) { return 2; }
if (not_a_number != 0) { return 3; }
return 0;
}
The as cast does not do this, on purpose. x as i32.s lowers to the trapping
i32.trunc_f64_s, because silently clamping an out-of-range value hides the bug that produced
it. Choosing saturation is therefore explicit, and it is the one conversion pair where the
escape hatch is not merely a longer spelling of dedicated syntax — which is also why the
escape-hatch-alternative lint says nothing about these eight
opcodes.
Wide arithmetic
Four operations work on 128-bit values as (low, high) pairs of i64. They are the only
ones with two results, destructured with the ordinary multi-value binding form.
The two multiplies have method syntax, since they take one operand and a receiver reads
naturally — the same reasoning as .div_u(n), which also exists because no operator spells
it (* is the single-result i64.mul):
export func check as "check";
func check() -> i32 {
let base: i64 = 4294967296;
// 2^32 * 2^32 = 2^64 exactly: the low half is 0, the high half is 1.
let (low, high): (i64, i64) = base.mul_wide_u(base);
// Adding 1 to an all-ones low half carries into the high half -- the reason add128 exists
// rather than two independent adds. Four operands, so no method form; see below.
let (sum_low, sum_high): (i64, i64) = wasm.i64.add128(-1, 0, 1, 0);
if (low != 0) { return 1; }
if (high != 1) { return 2; }
if (sum_low != 0) { return 3; }
if (sum_high != 1) { return 4; }
return 0;
}
.mul_wide_s(y)/.mul_wide_u(y) are i64-only: a 128-bit product needs 64-bit operands,
and the diagnostic says so rather than just reporting an unknown method.
add128/sub128 deliberately have no method form. They take four operands, because a
128-bit value is an i64 pair on each side, so a receiver would be one arbitrary quarter
of the arithmetic — lhs_low.add128(lhs_high, rhs_low, rhs_high) reads as though lhs_low
were special. The escape-hatch spelling keeps all four visibly peers.
A literal receiver needs an explicit type here, unlike (8).clz():
let (low, high): (i64, i64) = (7).mul_wide_u(3);
type error: integer literal requires a contextual 'i32' or 'i64' type
A type-preserving method's result is its receiver's type, so the expected type can flow backwards into an untyped literal. A wide multiply's expected type is a pair, which says nothing about the receiver — so bind it first.
The checks confirm that low is 0 and high is 1 from the multiply, while sum_low is 0 and
sum_high is 1 from the carry. A low/high mix-up in either would change the result.
i64.add128/i64.sub128 take their operands as lhs_low, lhs_high, rhs_low, rhs_high. Wide
arithmetic is recent enough that an engine may need it enabled explicitly (wasmtime run -W wide-arithmetic=y).
What is deliberately absent
The catalog is core MVP plus reference types, sign extension, GC, exceptions, SIMD (fixed-width and relaxed), atomics, memory64, and wide arithmetic. Some omissions are scope; others are design decisions:
| Absent | Why |
|---|---|
br, br_if, br_table, block, loop, if, return, br_on_null, br_on_cast | The escape hatch's grammar is an expression producing a value. Branch targets don't fit that shape, and Reed's own structured control flow already covers them. |
return_call, return_call_ref | Tail calls are an output mode (--tail-calls) applied to your own return, not source syntax. |
call_ref | Call a &FuncType value with ordinary call syntax. |
throw, throw_ref | Diverge and produce no value, so they don't fit the escape hatch's expression-producing shape -- same reason as the branch instructions above. Use source-level throw Name(...)/throw e; instead. |
try_table, catch* | Branch-carrying, like the instructions above. Use source-level try/catch. |
Half-precision (f16x8.*, f32.load_f16) | Still an unstandardized proposal; the toolchain this compiler emits text for cannot parse it, so emitting it would trade a clear feature error for an opaque downstream failure. |
| Stringref, stack switching, custom descriptors, gc-draft-2022 | Proposals outside the target. |
Attempting any of them gives the same feature error as a typo. If a future catalog adds them, that is a different language target with a different catalog ID.
See also
- Opcode catalog — all 211, with immediate shapes and argument counts.
- Linear memory and function tables — a worked module.
- Spec section 5 — the normative grammar for
data/elemdeclarations and their distinct namespaces. - Spec section 6/6.1 — the normative grammar for
new Name[count] data<D>(...)/elem<E>(...). - Spec section 12 — the normative grammar and catalog contract.
- Spec section 7 — the normative grammar for method-postfix syntax (including the
.copy<Other>(...)/.init<Other>(...)immediate forms), the ternary, and assignment-as-expression. - Spec section 9 — the normative grammar for
is, and which position actually narrows. - Spec section 10 — the normative grammar and lowering for
#Mem,.grow(...),.fill(...),.copy<Other>(...),.init<Other>(...), and.drop().