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.
Related rules
- Raw WebAssembly operations — the full form, including memory, tables, casts, and constant initializers
- Opcode catalog — all 199 opcodes in
wastc-core-gc-0 - Limitations — what the escape hatch does not reach
- Spec section 12