Skip to main content

Raw WebAssembly operations

wasm.<opcode>(...) is an escape from WAST's surface syntax, not from its type system. Nothing here is textual WAT injection: names in the immediate list are resolved against the real module, operand and result types are checked, and the output still has to pass WebAssembly validation. You cannot use it to emit something the module would not otherwise allow — which also means you cannot use it to work around a compiler bug.

array Values { mut i32 }

func tagged_value() -> i32 {
  // ref.i31 yields a NON-null &i31, but i31.get_s/_u accept a NULLABLE ?i31.
  // That asymmetry is real WebAssembly, faithfully reproduced: the reader is
  // the looser side, so a null slips past the type checker and traps.
  let value: &i31 = wasm.ref.i31(7);
  return wasm.i31.get_u(value);
}

func array_value() -> i32 {
  let values: &Values = new Values { 0, 0 };

  // <Values> is a resolved module reference, not a pasted string. The
  // compiler checks the receiver type, the index, and the element type
  // before it emits anything.
  wasm.array.set<Values>(values, 1, 42);

  // The element is a non-packed i32, so the plain opcode is mandatory here;
  // wasm.array.get_u<Values> is rejected. On an i8/i16 element the rule
  // inverts and the plain opcode is the one that's rejected.
  return wasm.array.get<Values>(values, 1);
}

func halve_unsigned() -> i32 {
  // There is no `-` in the immediate grammar. `<-1>` is a *syntax* error,
  // so write the bit pattern.
  let all_ones: i32 = wasm.i32.const<4294967295>();

  // Every WAST operator is signed: `all_ones / 2` is 0. This is 0x7FFFFFFF.
  // Signedness is the single most common reason to drop to a raw opcode.
  return wasm.i32.div_u(all_ones, 2);
}

func check() -> i32 {
  if (tagged_value() != 7) { return 1; }
  if (array_value() != 42) { return 2; }
  if (halve_unsigned() != 2147483647) { return 3; }
  return 0;
}

export func check as "check";

Why a catalog instead of "any instruction"

The reachable opcode set is fixed by an instruction catalog. This compiler ships exactly one, wastc-core-gc-0, with 199 opcodes covering core MVP plus reference types, sign extension, and the GC proposal. It is a hand-written dispatch, not a table generated from the WebAssembly spec — an opcode exists because somebody wrote the arity, operand types, result type, and immediate shape for it.

That has a cost you will hit within about five minutes of use: a typo is reported as a missing feature. The dispatch ends in a catch-all arm, so it cannot tell "you misspelled i32.add" apart from "SIMD is out of scope":

feature error: 'i32.addd' is not available in the selected target's
instruction catalog (wastc-core-gc-0)

The upside is that every accepted opcode is genuinely checked. wasm.i32.add(1) is an arity error, wasm.i32.store<Nope>(0, 1) is an unknown-memory error, and wasm.struct.set<S, a>(s, 2) on a non-mut field is rejected before lowering. A schema-driven passthrough would accept all three and produce broken WAT.

What is deliberately absent

Branch instructions — br, br_if, br_table, block, loop, if, return, br_on_null, br_on_cast — are not in the catalog and are not coming. They do not fit the grammar's shape: wasm.<op>(...) is an expression that produces a value, and a branch produces a control-flow edge. The language's own structured control flow already covers them, and is-narrowing is what compiles down to br_on_cast/br_on_null.

Also absent, for scope rather than shape: memory.init/data.drop, memory.copy/fill, table.copy/fill/init, array.new_data/_elem, saturating truncation, a raw call_ref, tail-call opcodes, and everything from SIMD, threads, exception handling, stringref, and memory64. Tail calls exist, but as a compiler output mode (--tail-calls) rather than an opcode, because return_call only ever comes from lowering a return — never from source.

Sharp edges worth knowing before you rely on them

Numeric opcodes ignore their immediates entirely. wasm.i32.add<Nonsense, 42>(20, 22) compiles and returns 42; nothing inspects the list. That is a gap in the checking, not a feature. select is the one opcode that rejects a stray immediate outright.

Type inference is also incomplete: comparing a raw operation to a bare literal can fail with cannot determine the operand type for this operator; add a typed binding. Binding the result to an annotated let first — as halve_unsigned does with all_ones — is the workaround, and it is why several examples in these docs look more verbose than necessary.

Expected result

check() returns 0. The i31 round-trip yields 7, the array round-trip 42, and the unsigned halving 2147483647.