Skip to main content

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 kindExamples
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.const accept 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, including f32.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 wantOperator?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 bridgeas (see below)wasm.i64.extend_i32_s, wasm.f64.convert_i32_u, wasm.any.convert_extern, …
Bit reinterpretationnowasm.i64.reinterpret_f64, …
clz / ctz / popcnt / rotl / rotr / sqrt / abs / ceil / floor / trunc / nearest / min / max / copysignno (method syntax, see below)wasm.i32.popcnt, wasm.f64.min, …
Linear memory load/storenowasm.i32.load, wasm.i32.store8, …
Default-initialized struct/arraynew 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, copyMem.fill(...), Dest.copy<Src>(...) (see below)none — no wasm.* form
Struct field read/write->wasm.struct.get / .set (needed for packed fields)
Array element, lengtha[i], #awasm.array.get_s/_u (needed for packed elements)
Array bulk copy/fillno (method syntax, see below)wasm.array.copy, wasm.array.fill
Table slot, size, growthT[i], #T, Table.grow(init, delta) (see below)wasm.call_indirect, wasm.table.grow
Table bulk fill, copyTable.fill(...), Dest.copy<Src>(...) (see below)none — no wasm.* form
Type test / narrowingiswasm.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() on i32/i64; .abs(), .sqrt(), .ceil(), .floor(), .trunc(), .nearest() on f32/f64.
  • One same-type argument, same-type result: .rotl(n), .rotr(n), .div_u(n), .rem_u(n) on i32/i64; .min(y), .max(y), .copysign(y) on f32/f64 only — core WebAssembly has no integer min/max instruction, so those two names don't exist on i32/i64 at all.
  • One same-type argument, i32 result: .lt_u(n), .le_u(n), .gt_u(n), .ge_u(n) on i32/i64.
  • One i64 argument, two i64 results: .mul_wide_s(y), .mul_wide_u(y) on i64 only — 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/.u suffix 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 an i32 into a non-null &i31, lowering to ref.i31. No suffix is permitted; as ?i31 is a type error since ref.i31 never produces a nullable result.
  • as i32.s / as i32.u on an &i31/?i31 operand: unboxes back to i32, lowering to i31.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. When Name has no fields it means what it always has — an empty struct construction. When Name has one or more fields, it instead default-initializes every one of them and lowers to struct.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 existing new Name { ... } form. It default-initializes count elements and lowers to array.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 to array.copy, with dest's declared array type as the first instruction immediate and src's as the second — src is a second non-null declared-array reference, and its element type must be assignable to dest's with matching packedness.
  • arr.fill(offset, value, count) lowers to array.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 : else lowers to select. Like select itself, and unlike if/else or &&/||, it is not short-circuiting — both then and else are always evaluated, in source order, before the choice is made.
  • (name = value), parenthesized and used as an expression, lowers to local.tee — but only when name is a mutable local var. Assigning to a global, a struct field, or an array element this way is a compile error: WebAssembly has only local.tee, no global.tee/struct.tee/array.tee. The unparenthesized statement form name = value; is untouched and still lowers to plain local.set.
  • value is null / value is &Heap / value is ?Heap now work as a plain i32-valued expression anywhere, not just inside if (...). Only the if-condition form narrows the tested local's type in its true arm; the identical test used elsewhere still evaluates to the same i32, 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:

  • #Mem reaches memory.size, the same #-for-length operator already used for an array's length and a table's size (#arr, #Table).
  • Mem.grow(delta) reaches memory.grow; Table.grow(init, delta) reaches table.grow. Both return the previous size — pages for a memory, elements for a table — and both take their growth amount (delta) as an ordinary i32 argument, no immediate involved. A table's init fills every new slot and follows the same value grammar as its declaration's own initializer: null or &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 — an i32 byte 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) copy len elements 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) copy len elements starting at src_offset in the named passive data or elem segment into the receiver memory or table starting at dest_offset, and lower to memory.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 to data.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) requires Name's element type to be a value type (numeric or packed i8/i16) and D to name a declared data segment; it lowers to array.new_data. A reference-typed element is a compile error — array.new_data cannot produce references.
  • new Name[count] elem<E>(offset) requires Name's element type to be a reference type assignable from E's declared elem type (always a nullable reference to E's function type) and E to name a declared elem segment; it lowers to array.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:

AbsentWhy
br, br_if, br_table, block, loop, if, return, br_on_null, br_on_castThe 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_refTail calls are an output mode (--tail-calls) applied to your own return, not source syntax.
call_refCall a &FuncType value with ordinary call syntax.
throw, throw_refDiverge 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-2022Proposals 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/elem declarations 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().