Skip to main content

Raw WebAssembly operations

WAST'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 WAST'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, wastc-core-gc-0, 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:

  • There is no - in the immediate grammar. wasm.i32.const<-1>() is a syntax error, not a type error. Write the unsigned bit pattern instead.
  • 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 /, %, <, >nowasm.i32.div_u, wasm.i32.lt_u, …
Int/float conversion, sign extensionnowasm.i64.extend_i32_s, wasm.f64.convert_i32_u, …
Bit reinterpretationnowasm.i64.reinterpret_f64, …
clz / ctz / popcnt / rotl / sqrt / min / maxnowasm.i32.popcnt, wasm.f64.min, …
Linear memory load/storenowasm.i32.load, wasm.i32.store8, …
Struct field read/write->wasm.struct.get / .set (needed for packed fields)
Array element, lengtha[i], #awasm.array.get_s/_u, array.fill, array.copy
Table slot, sizeT[i], #Twasm.table.grow, wasm.call_indirect
Type test / narrowingiswasm.ref.test / wasm.ref.cast

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

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; WAST 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. Drop to raw operations for the things they don't spell: struct.new by position, array.new/new_default sized at runtime, array.fill, array.copy, and every packed access.

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. It is the one opcode that explicitly rejects immediates — wasm.select<i32>(...) is a syntax 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.

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.

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.

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 WAST 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 (wastc-core-gc-0)

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.

Numeric opcodes silently ignore their immediates. Nothing inspects them, so this compiles and the <Nonsense, 42> is discarded:

func check() -> i32 {
  // No `-` in the immediate grammar — write the bit pattern.
  let minus_one: i32 = wasm.i32.const<4294967295>();
  if (minus_one != 0 - 1) { return 1; }

  // Compiles. The immediate list is never looked at.
  let sum: i32 = wasm.i32.add<Nonsense, 42>(20, 22);
  if (sum != 42) { return 2; }

  return 0;
}

export func check as "check";

That is a gap in the checking, not a feature. Don't rely on it. select is the only opcode that rejects a stray immediate outright.

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, incomplete table. It knows every i32.*/i64.*/f32.*/ f64.* opcode plus a hand-listed set (array.get, struct.get, array.len, table.size, table.grow, table.get, local.get, local.tee, global.get, call, ref.i31, ref.func, the i31.get_* and ref.* predicates). It does not know memory.size, memory.grow, array.get_s/_u, struct.get_s/_u, or call_indirect:

if (wasm.memory.size<Scratch>() != 1) { ... }
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 pages: i32 = wasm.memory.size<Scratch>();
if (pages != 1) { return 6; }

Harmless, but surprising the first time, and the reason several examples above use an extra let.

What is deliberately absent

The catalog is core MVP plus reference types, sign extension, and the GC proposal. 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 WAST'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.
memory.copy, memory.fill, memory.init, data.drop, array.new_data, array.new_elem, table.copy, table.fill, table.init, elem.dropSegment and bulk operations, out of scope for this catalog.
*_sat_* truncationNot in the target feature set. i32.trunc_f64_s traps on out-of-range input.
SIMD, threads/atomics, exception handling, stringref, memory64Proposals 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