Raw WebAssembly operations
wasm.<opcode>(...) is an escape from Reed'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 {
// `wasm.i32.const<-1>()` would also work here -- `i32.const`/`i64.const`
// are the one pair of opcodes whose immediate grammar accepts a leading
// `-`. Every OTHER opcode's integer immediate rejects one outright, so the
// unsigned bit-pattern spelling below is the form you actually need
// everywhere else; it is shown here for that reason.
let all_ones: i32 = wasm.i32.const<4294967295>();
// Every Reed 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, reedc-core-gc-eh-simd-threads-1, 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 (reedc-core-gc-eh-simd-threads-1)
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 as raw wasm.<opcode>(...) spellings, for a different reason than the branch
instructions above: memory.init, data.drop, memory.copy/fill, table.copy/fill/
init, and array.new_data/_elem have no wasm.* form at all, but each is reachable
through dedicated method-postfix syntax instead — Mem.init<...>(...)/SomeData.drop(),
Mem.fill(...)/DestMem.copy<Src>(...),
Table.fill(...)/DestTable.copy<Src>(...)/Table.init<...>(...), and
new Name[count] data<D>(...)/elem<E>(...), respectively (see segment init and
drop). call_ref is
absent as a raw opcode the same way, but its reachable path is more direct than a
method-postfix call: calling a &FunctionType-typed value with ordinary call syntax (spec
section 7) lowers straight to call_ref — no wasm.* spelling and no dedicated
method-postfix form either, just an ordinary call on a typed function reference. throw is
absent as a raw opcode for the same reason the branch instructions above are: it diverges
and produces no value, so it doesn't fit the escape hatch's expression-producing grammar —
but it does have a source-level path, throw Name(...) (spec section 10). Saturating
truncation, tail-call opcodes, and everything from SIMD, threads, stringref, and memory64
remain genuinely and fully absent, in every form — those have no source-level path at all,
unlike call_ref and throw. 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
A stray immediate on a zero-immediate opcode is rejected, not ignored. Every opcode
that structurally never takes one — every plain numeric op among others — checks its
immediate list is empty before doing anything else: wasm.i32.add<Nonsense, 42>(20, 22)
is a type error, 'i32.add' does not take any immediates. This used to be a real gap (the
check didn't exist, so the immediate type-checked and was silently discarded instead of
being rejected — see the limitations page
for the historical writeup). select's own equivalent check is separate from this one and
fires at the syntax phase instead of the type phase — wasm.select<i32>(...) is a syntax
error, not a type error.
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
reedc-core-gc-eh-simd-threads-1 - Limitations — what the escape hatch does not reach
- Spec section 12