Skip to main content

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.