Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Introduction

Wax is a Rust-like syntax for WebAssembly that compiles to standard Wasm binary or text formats. It provides a more familiar programming experience while maintaining a direct correspondence to WebAssembly concepts. It is validated against the official WebAssembly spec test suite and a differential, round-trip fuzzing harness, so conversions preserve meaning across all three formats. See Feature Support for the proposals it covers.

Why Wax?

WebAssembly Text format (WAT) uses S-expressions and stack-based operations, which can be verbose and unfamiliar to most programmers:

(func $add (param $x i32) (param $y i32) (result i32)
  local.get $x
  local.get $y
  i32.add)

Wax provides an expression-oriented syntax that feels more natural:

fn add(x: i32, y: i32) -> i32 {
    x + y;
}

Both compile to identical WebAssembly bytecode.

Installation

Prebuilt binaries

Native wax executables for Linux, macOS (Apple silicon and Intel), and Windows (with SHA256SUMS) are attached to the edge prerelease, which is rebuilt on every push to main. Download the one for your platform, make it executable, and put it on your PATH:

curl -LO https://github.com/ocsigen/wax/releases/download/edge/wax-linux-x86_64
chmod +x wax-linux-x86_64 && mv wax-linux-x86_64 /usr/local/bin/wax

From source

Requirements: Opam (2.1+) and OCaml 4.14+ (5.0+ to run the full test suite).

# Install dependencies
opam install . --deps-only

# Build
dune build

# Install globally
opam install .

Editor support

A Wax extension for Visual Studio Code supports both .wax and WebAssembly text (.wat) files: syntax highlighting, formatting (with format on save), diagnostics as you type, a document outline, snippets, and commands to preview the compiled WAT or decompiled Wax side by side. It runs the toolchain compiled to WebAssembly in-process, so it works the same in desktop and web VS Code (including vscode.dev).

Quick Start

Create a file hello.wax:

#[export = "add"]
fn add(x: i32, y: i32) -> i32 {
    x + y;
}

#[export = "factorial"]
fn factorial(n: i32) -> i32 {
    if n <=s 1 => i32 {
        1;
    } else {
        n * factorial(n - 1);
    }
}

Compile to WebAssembly binary:

wax hello.wax -o hello.wasm

Or convert to WebAssembly text format to see the generated WAT:

wax hello.wax -f wat

Reformat files in place with the format command:

wax format -i hello.wax

Supported Conversions

Wax supports all 9 combinations of input and output formats:

InputOutputUse Case
.wax.wasmCompile to binary
.wax.watCompile to text
.wax.waxFormat / type-check
.wat.wasmAssemble to binary
.wat.waxDecompile to Wax
.wat.watFormat
.wasm.watDisassemble
.wasm.waxDecompile to Wax
.wasm.wasmRound-trip

Type Checking

Compiling Wax to .wat or .wasm type-checks it automatically; type errors are reported before any output is produced. For example, adding an i32 and an f64:

Error: This operator cannot be applied to operands of types i32 and f64.
 ──➤  hello.wax:3:7
1 │ #[export = "add"]
2 │ fn add(x: i32, y: f64) -> i32 {
3 │     x + y;
  ·       ^
4 │ }

The -v/--validate flag adds checks that are off by default: it validates a same-format conversion (.wax.wax) or a trusted .wasm input, and reports unused locals.

Next Steps

Feature Support

Wax works across all three formats (Wax, WAT, Wasm) and targets recent WebAssembly proposals, several of them on by default, that most toolchains still gate. This page summarises what the toolchain accepts and produces.

On by default

Wax fully supports the WebAssembly 3.0 standard: garbage collection (WasmGC), exception handling (try_table), tail calls, multiple memories, 64-bit memory (memory64), reference types, bulk memory, and SIMD (including relaxed SIMD).

On top of that, it enables these further proposals by default:

ProposalIn Wax
Stack switching (typed continuations)cont, suspend, resume, …
Threads / atomicsshared memory, atomic loads/stores/RMW, atomic::fence()
Wide arithmetic128-bit integer ops (e.g. i64::add128)
Branch hinting#[likely] / #[unlikely]
Custom page sizespagesize

Enabled with -X

Off by default; turn one on with -X NAME (see the CLI reference):

FeatureWhat it adds
custom-descriptorsexact reference types, descriptor structs, and the descriptor instructions (descriptor / describes)
compact-import-sectiongroups same-module imports in the binary, a binary-encoding option gated on output but always accepted on input

Not supported

FeatureNotes
Legacy delegate / rethrowrejected on input. The rest of legacy exception handling (try/catch/catch_all) is supported: it has dedicated Wax syntax (try { … } catch { … }) and round-trips through the legacy binary opcodes
Component Model

A deliberate relaxation

A tag may carry a result type: tag yield(i32) -> i32. The MVP requires tags to have empty results; the stack-switching proposal lifts that (a suspended tag resumes with a value), so Wax permits it on purpose. See Stack Switching.

Language Guide

This guide covers Wax syntax and semantics. For the detailed mapping to WebAssembly instructions, see Correspondence.

Module Name

A whole .wax file is a module. To give the module a name, place a #![module = "..."] inner attribute at the top of the file:

#![module = "my_module"]

fn f() -> i32 {
    1;
}

Unlike the #[...] outer attributes that decorate a following field (such as #[export = "name"]), #![...] is an inner attribute: it applies to the enclosing module. A module may carry at most one name.

(This maps to the WebAssembly module name stored in the name custom section, the $name in a WAT (module $name …); see Module Fields.)

Comments

Wax supports C-style comments:

// Single-line comment

/* Multi-line
   comment */

Trailing Commas

A trailing comma is allowed after the last element of any comma-separated list: function parameters, call arguments, struct fields, result types, and so on:

type point = { x: i32, y: i32, };

fn add(a: i32, b: i32,) -> i32 {
    a + b;
}

Literals

Integers

42          // Decimal
0x2A        // Hexadecimal
0o52        // Octal
0b101010    // Binary

A numeric literal is flexible: it has no fixed type of its own but takes a concrete one from its context: a type annotation, the operation it feeds, or a function’s result type.

let x: i64 = 42;        // takes i64 from the annotation

Which concrete types a literal can take depends on its magnitude:

LiteralCan becomeDefaults to
fits in 32 bits (≤ 0xFFFF_FFFF)i32, i64, f32, f64i32
larger, fits in 64 bitsi64, f32, f64 (never i32)i64
larger than 64 bitsf32, f64 onlyf64
a floating-point literalf32, f64f64

The defaults apply only when nothing constrains the literal:

let a = 42;             // unconstrained -> i32
let b = 5_000_000_000;  // too big for i32 -> i64
let c = 3.14;           // float literal -> f64

Wax performs no implicit conversions between numeric types. A numeric expression must be coherent: an operator’s operands must share one type, so mixing an i32 with an i64, or an integer with a float, does not type-check. A flexible literal is never converted; it simply takes the type that keeps its expression coherent. An operation that accepts only integers (a bitwise op, a shift, ctz, …) holds the flexible literals feeding it to the integer family, so they can only be i32 or i64, never a float.

Changing a value’s type takes an explicit as cast, which emits the conversion: an out-of-range constant as i32 wraps to its low 32 bits, and an integer as f64 converts.

Floating Point

3.14        // Decimal
1.0e10      // Scientific notation
0x1.5p10    // Hexadecimal float
inf         // Infinity
nan         // Not a number

A floating-point literal is f32 or f64, defaulting to f64 when unconstrained (see the table above).

Characters

A character literal is a single character in single quotes. It evaluates to that character’s Unicode code point as an i32: it is simply a readable spelling of an i32.const:

'A';            // 65
'\n';           // 10
'\x41';         // 65, a byte written as two hex digits
'\u{1F600}';    // 128512, a Unicode code point

The recognised escapes are \t, \n, \r, \', \", \\, \xNN (exactly two hex digits, one byte), and \u{...} (a Unicode code point in hex). For text, see Strings.

A character literal round-trips through WAT, where it is written with a (@char …) annotation. A Wasm binary keeps only the underlying i32.const, though, so decompiling from Wasm yields the plain integer code point.

Variables

Local Variables

Declare local variables with let. Each variable gets a type, either from an explicit annotation or from an initializer:

fn example() -> i32 {
    let x: i32;        // annotated, no initializer
    let y = 20;        // type inferred from the initializer (i32)
    x = 10;
    x + y;
}

A local declared without an initializer starts at its type’s zero value (0, 0.0, or null). When the type is omitted, it is taken from the initializer; an otherwise-unconstrained integer literal then defaults to i32 and a float to f64. An annotation and an initializer can be combined, and must agree:

let count: i64 = 0;

A let can bind several names at once from an initializer that produces multiple values, a call to a multi-result function, for instance. The names are written as a parenthesised list and each takes the corresponding value, left to right. As with a single binding, each name may be annotated or left to be inferred, and _ discards its value:

let (q, r) = divmod(17, 5);     // q and r inferred from the results
let (n: i64, _) = stats();      // n annotated, the second result dropped

Assignment

Use = for assignment (like local.set):

x = 42;

Use := for assignment that also returns the value (like local.tee):

y = (x := 42) + 1;  // x is set to 42, y is set to 43

:= works on local variables only: WebAssembly has no global.tee, so applying it to a global is an error.

A compound assignment combines a binary operator with =: x op= e is shorthand for x = x op e, where the left-hand side is a local or global variable.

counter += 1;       // counter = counter + 1
mask &= 0xff;        // mask = mask & 0xff
total /s= n;         // total = total /s n

Every value-producing arithmetic and bitwise operator has a compound form: +=, -=, *=, /=, /s=, /u=, %s=, %u=, &=, |=, ^=, <<=, >>s=, and >>u=. As for the corresponding binary operators, the signed and unsigned variants apply to integers, while the sign-agnostic /= is float division. Comparisons have no compound form.

Discarding a value

Assigning to _ evaluates an expression and throws its result away, the equivalent of WebAssembly’s drop. Nothing is bound, so no name comes into scope:

_ = f();            // call f for its side effects, ignore its result

An optional type annotation, _: t = e, pins the type of the discarded value. This is hardly ever needed, since the value is thrown away anyway:

_: i64 = 1 << 40;   // discard the result, but keep it i64

Global Variables

Globals are declared at module level. const is immutable; let is mutable:

const PI: f64 = 3.14159;        // immutable global
let counter: i32 = 0;           // mutable global

As with locals, an initialized global may omit its type and take it from the initializer (an unconstrained integer literal defaults to i32):

const answer = 42;              // i32

A global’s initializer must be a constant expression (a literal, another global, or a simple reference-building expression).

Names and Scope

Functions, globals, memories, and tables share one module-level namespace: a name must be distinct across all four: you cannot, say, declare both a memory and a global named buf. Types, tags, and data/element segments each have their own namespace, so those names may overlap with the above and with one another.

A local variable is scoped to its function and takes precedence over module-level names. A bare name resolves to a local before a global or function, and the name-based module forms (mem.load(..), tab[i], seg.drop()) likewise defer to a same-named local. So a local that reuses a memory, table, or segment name shadows it; rename the local if you still need to reach the module entity.

Expressions

Wax is a readable layer over WebAssembly, and it keeps WebAssembly’s execution model: the operand stack. A function or block body is a sequence of statements, each ended with ;. Evaluating a statement may push values onto the stack and pop values off it, and whatever remains on the stack when the body ends is its result, which must match the declared result type. A value-returning function therefore ends with a statement that leaves that value:

fn double(x: i32) -> i32 {
    x * 2;
}

Most expressions produce a value. This same stack model underlies blocks, holes, and functions or blocks with more than one result.

Arithmetic

x + y       // Add
x - y       // Subtract
x * y       // Multiply
x / y       // Divide (float)
x /s y      // Divide signed (integer)
x /u y      // Divide unsigned (integer)
x %s y      // Remainder signed
x %u y      // Remainder unsigned

Bitwise

x & y       // And
x | y       // Or
x ^ y       // Xor
x << y      // Shift left
x >>s y     // Shift right signed
x >>u y     // Shift right unsigned

Comparison

x == y      // Equal
x != y      // Not equal
x < y       // Less than (float)
x <s y      // Less than signed (integer)
x <u y      // Less than unsigned (integer)
x <= y      // Less or equal (float)
x <=s y     // Less or equal signed
x <=u y     // Less or equal unsigned
// Similarly: >, >=, >s, >u, >=s, >=u

On reference operands (any subtype of &eq), == and != are reference identity, not a numeric comparison.

Unary Operations

-x          // Negate
+x          // Positive (no-op for integers)
!x          // Logical not / is_null for references

Operator Precedence

Operators are listed below from highest precedence (binds tightest) to lowest. The ordering follows Rust’s for every operator the two share (Wax adds ? :, which Rust lacks, just above assignment; it has no short-circuit &&/||: &/| are bitwise). Two mixes are worth committing to memory:

  • &, ^, | bind tighter than the comparison operators, so a & b == c parses as (a & b) == c. Rust agrees; C is the opposite: there it means a & (b == c).
  • The shifts bind looser than +/-, so 1 << nbits - 1 parses as 1 << (nbits - 1), not (1 << nbits) - 1. (Same in Rust and C.)
PrecedenceOperatorsAssociativity
highest.field   f(…)   […] (field, call, index)left
-x   +x   !x (unary)n/a
as   is (cast, test)left
*   /   /s   /u   %s   %uleft
+   -left
<<   >>s   >>uleft
&left
^left
|left
==   !=   <   >   <=   >= (and s/u forms)none
? :right
lowest=   :=   += … (assignment)right

Because the two cross-class mixes above are easy to misread, the precedence lint (in the correctness group, on by default) flags a shift mixed with arithmetic, or a comparison mixed with a bitwise operator, when it is written without disambiguating parentheses.

Method-Style Operations

Some operations use method syntax. These are calls, so they take parentheses even when they have no argument:

x.abs()       // Absolute value
x.sqrt()      // Square root
x.floor()     // Floor
x.ceil()      // Ceiling
x.trunc()     // Truncate
x.nearest()   // Round to nearest
x.clz()       // Count leading zeros
x.ctz()       // Count trailing zeros
x.popcnt()    // Population count
x.extend8_s()   // Sign-extend the low 8 bits
x.extend16_s()  // Sign-extend the low 16 bits

Two-argument operations pass the second operand as the argument:

x.min(y)
x.max(y)
x.copysign(y)
x.rotl(y)      // Rotate left
x.rotr(y)      // Rotate right

Qualified Intrinsics

Most built-in operations are written as methods on a receiver (x.clz(), a.add_i32x4(b) above). The few that have no natural receiver are written instead as a qualified path, type::member(args), a built-in free function namespaced by the WebAssembly type it belongs to. These names are built in, not user functions, and because the :: form is distinct from an ordinary name it can never clash with your own functions, globals, or locals.

Two families use this syntax today.

v128::: SIMD free functions. The vector constants (one argument per lane) and bitselect, which have no receiver:

v128::const_i32x4(1, 2, 3, 4)         // a v128 constant (also a constant expression)
v128::const_f32x4(1.5, 2.5, 3.5, 4.5)
v128::bitselect(a, b, mask)           // per-bit select

i64::: wide arithmetic. 128-bit integer operations, taking and returning their operands as (low, high) pairs of i64, so each returns two results (bind them with a multi-value let):

i64::add128(a_lo, a_hi, b_lo, b_hi)   // 128-bit add, returns (lo, hi)
i64::sub128(a_lo, a_hi, b_lo, b_hi)   // 128-bit subtract
i64::mul_wide_s(a, b)                 // signed 64×64 → 128-bit product
i64::mul_wide_u(a, b)                 // unsigned 64×64 → 128-bit product

Type Conversions

Use as for type conversions:

x as i32        // Wrap i64 to i32
x as i64_s      // Sign-extend i32 to i64
x as i64_u      // Zero-extend i32 to i64
x as f32        // Demote f64 to f32
x as f64        // Promote f32 to f64
x as i32_s      // Truncate float to signed int
x as f32_s      // Convert signed int to float
x.to_bits()     // Reinterpret float as int
x.from_bits()   // Reinterpret int as float

Conditional Expression

cond ? val_true : val_false

This maps directly to Wasm’s select instruction.

⚠ Warning: both branches are always evaluated. Unlike the ?: (or if/else expression) of most languages, this operator is not lazy: it compiles to a select, which computes both val_true and val_false before choosing between them. So cond ? 1 /s x : 0 still divides by x even when cond is false (trapping if x is 0), and cond ? f() : g() calls both f and g. When a branch may trap or has a side effect, use an if expression instead: it evaluates only the chosen branch. The eager-select lint (in the correctness group, on by default) flags a trapping or effectful operation in a ?: branch.

Control Flow

Statements are terminated by ;, including the final one that produces the block’s or function’s value. The block-shaped statements below (do, if, while, loop, dispatch, match, and try) are the exception: their closing } ends the statement, so no ; is needed. A bare ; is an empty statement (it does nothing), so a redundant one is harmless anywhere and dropped on formatting, most usefully after a block, where the reflex of ending every line with ; would otherwise be an error. The same holds outside function bodies: a stray ; is an empty element wherever items are listed: among module fields, the declarations of an import block, and the arms of a dispatch, match, or catch.

Blocks

A block groups a sequence of statements, written with do.

do {
    let x: i32;
    x = compute();
    use(x);
}

By default a block is void: it produces no value, so, like a function with no result type, its body must leave the operand stack empty.

To make a block produce a value, give it a result type after do. The body must leave a value of that type on the stack, and that becomes the block’s value:

answer = do i32 {
    42;
};

This is a stack discipline, not a “last expression” rule: the body must leave exactly the values the block’s type describes: no more, no less. A void block that leaves a value, or a do i32 block that leaves two, is an error.

The result type may be omitted and inferred: from the value the body leaves (including a value branched to the block’s own label), or from the surrounding context such as a function’s result type, a typed binding, or a call argument. It is needed only where neither determines it, for instance when two branches produce values with no common supertype. So the block above can drop its i32:

answer = do {
    42;
};

loop, try, and try_table infer their result type the same way. Converting WebAssembly to Wax drops the annotation wherever it is redundant, and re-reading that Wax recovers the type by the same inference.

A block type may also take parameters off the enclosing stack, using the full (params) -> results form:

do (i32) -> i32 {
    // the i32 operand below the block is available here
}

Parameters only work when the block stands on its own as a statement, where those values come from the operand stack. A block used inside an expression has no enclosing stack to draw from, so it cannot take parameters. To combine a parameterized block’s result with other operands, keep the block at statement level and pick up its result with a hole:

x;                        // leaves an i32 on the stack
do (i32) -> i32 { … }     // a statement: consumes that i32, leaves an i32
_ + 1;                    // the hole plugs in the block's result

Blocks can be labelled and used as branch targets; see Labels and Branches.

If Expressions

if tests a condition (any i32, where non-zero means true) and runs the matching branch. Like a block it is void by default (the branches leave no value), and the else is optional:

if condition {
    then_branch;
}

To make if produce a value, give it a result type with => <type>. Both branches must then leave a value of that type, so the else becomes required:

if condition => i32 {
    1;
} else {
    0;
}

Like a block’s result type, the => <type> may be omitted and inferred: from the branches (here both produce i32) or from the surrounding context. The else stays required, since a value-producing if needs both branches:

x = if condition {
    1;
} else {
    0;
};

Loops

A loop runs its body once and then falls through to whatever follows: it does not repeat on its own. What makes it a loop is its label: branching to a loop’s label jumps back to the start of its body. So a loop iterates by branching to itself, and stops once control reaches the end of the body without branching back.

'next: loop {
    total = total + i;
    i = i + 1;
    br_if 'next i <s n;   // jump back to the start while i < n
}

This is the opposite of a do block, whose label branches past the block (see Labels and Branches). To leave a loop early, branch to an enclosing block’s label.

Loops nest, and a branch may target any enclosing label:

'outer: loop {
    'inner: loop {
        br 'outer;   // jump back to the start of the outer loop
    }
}

While Loops

A while is readable sugar over the loop-and-back-branch idiom above; it tests before each iteration:

while i <s n {
    total = total + i;
    i = i + 1;
}

which is exactly:

'next: loop {
    if i <s n {
        total = total + i;
        i = i + 1;
        br 'next;
    }
}

It is void, and the test must be an i32. It may carry a label ('l: while …), which names the loop, so a br 'l from inside the body is a continue: it jumps back to re-test. Decompiling recovers a while from this shape, so a loop written this way survives a round trip through WAT or Wasm.

A while may carry a continue-expression after the condition, while cond : (step) { … }, a statement run at the end of every iteration, including on a continue. It keeps the loop’s step next to its header instead of at the bottom of the body, and, unlike a step written as the last statement, it still runs when the body branches to the loop label:

'l: while i <s n : (i += 1) {
    if skip(i) { br 'l; }   // continue: runs `i += 1`, then re-tests
    total += i;
}

The step must be parenthesised (a bare statement would be ambiguous with the loop body). Without a continue this is equivalent to writing the step as the last statement of the body, and decompiling recovers it: a loop whose last statement updates a variable its condition reads (the induction variable) is rendered as while … : (step) { … }, so index-and-stride loops read like for loops.

A trailing-test loop, where the body always runs at least once, has no leading-while form. Write it directly as a loop with a back-br_if:

'next: loop {
    total = total - 1;
    br_if 'next total >s 0;
}

Labels and Branches

Labels start with ' and prefix a block, loop, if, or try. A branch targets a label; where it lands depends on the labelled construct: branching to a block, if, or try jumps past it (an exit), while branching to a loop jumps to its start (an iteration, see Loops).

'done: do {
    if condition {
        br 'done;   // jump past the block (exits it)
    }
    // ...
}

Branch instructions:

br 'label;                          // unconditional branch
br_if 'label cond;                  // branch if cond (an i32) is non-zero
br_table ['a 'b else 'default] i;   // branch to the i-th label, else 'default

The labels in a br_table are separated by spaces, and else gives the fallback for an out-of-range index. A branch also carries any values its target expects: a do i32 target receives an i32, and so on.

Four more branch instructions test a reference and branch on the result, passing the refined reference to the target. They appear mainly in Wax decompiled from WebAssembly; hand-written code usually reaches for match, a null check, or a cast or test instead.

br_on_null 'l val;          // branch if val is null; else continue with val non-null
br_on_non_null 'l val;      // branch (passing val) if val is non-null; else continue
br_on_cast 'l &t val;       // branch (passing val as &t) if val is a &t; else continue
br_on_cast_fail 'l &t val;  // branch if val is not a &t; else continue with val as &t

Branch Hints

A conditional branch can be annotated #[likely] or #[unlikely] to tell the engine which way it usually goes (the branch-hinting proposal). The attribute prefixes the branch:

#[likely] if cond {
    // the common case
} else {
    // the rare case
}

'next: loop {
    #[unlikely] br_if 'next done;   // rarely taken
}

Any conditional branch accepts a hint: if, br_if, br_on_null, br_on_non_null, br_on_cast, and br_on_cast_fail. The hint is purely advisory (it does not change behaviour) and is preserved across every conversion to and from WebAssembly, so no compiler flag is needed. See Instructions → Branch Hints for the WebAssembly encoding.

Dispatch

A dispatch is a multi-way branch, the readable form of a br_table jump table. A bracket maps an index to a case label (with an else default for an out-of-range index), and each arm gives that case’s body:

fn classify(x: i32) -> i32 {
    dispatch x ['zero 'one 'two else 'big] {
        'big:  { return 99; }
        'two:  { return 30; }
        'one:  { return 20; }
        'zero: { return 10; }
    }
}

The bracket is exactly the underlying br_table: index 0 jumps to the label named first, 1 to the second, and so on, and an out-of-range index jumps to the else label. A label may be listed more than once (several indices sharing a case), and every listed label (and else) must name an arm. The index must be an i32, and the labels are 0-ary branch targets. Case (arm) labels must be distinct.

dispatch lowers to one nested block per case, with the br_table in the innermost block and each case body just after its block. As in a C switch, cases fall through: reaching a case runs its body and then falls into the next arm listed, unless it branches away first. So the arms are written in fall-through order, which is the reverse of the bracket’s index order: the last arm is the one index 0 reaches. The example above gives each case its own result via return; to break out instead, branch to an enclosing label:

let r: i32;
'done: do {
    dispatch x ['zero 'one else 'two] {
        'two:  { r = 30; }
        'one:  { r = 20; br 'done; }
        'zero: { r = 10; br 'done; }
    }
}

This is the shape compilers emit for a dense switch, so decompiling WAT/Wasm to Wax recovers dispatch from it (and a Wax dispatch round-trips through the binary).

Match

A match is a multi-way type test, the readable form of the nested type-test ladder that hand-written GC code uses to take apart a value of some general reference type. Each arm tests the scrutinee against a reference type, optionally binding the narrowed value, and runs its body when the test succeeds:

fn classify(v: &?eq) -> i32 {
    match v {
        p: &point => { return p.x + p.y; }   // v is a &point, bound to p
        a: &bytes => { return a.length(); }  // else if v is a &bytes
        null      => { return -1; }           // else if v is null
        _         => { return 0; }            // otherwise (the default)
    }
}

A x: &T arm binds the narrowed value to x for its body; drop the x: to test without binding. A null arm matches a null reference, and the required _ arm (as with a dispatch’s else) is the default. The scrutinee must be a reference type; it is evaluated once.

match lowers to a nested ladder of blocks, one per arm plus an outer escape block. The scrutinee is threaded through a br_on_cast (or br_on_null for a null arm) chain in the innermost block; each test, on success, branches out to its arm’s block carrying the narrowed value, and the arm body follows that block. As with dispatch, an arm’s body must leave the match (here each returns); to continue past it, branch to an enclosing label. When no test matches, the chain falls through to a br past all the arm bodies, and the default follows the escape block as trailing code, so reaching it falls through to whatever comes after, including the rest of the enclosing block:

let r: i32;
'done: do {
    match v {
        p: &point => { r = p.x; br 'done; }
        a: &bytes => { r = a.length(); br 'done; }
        _ => { r = 0; }   // also reached by a non-point, non-bytes v
    }
}

This is the shape hand-written GC code uses, so decompiling WAT/Wasm to Wax recovers match from such a ladder, even a single type test that branches out and then falls through to a default (and a Wax match round-trips through the binary).

Return

return value;

Tail Calls

Use become for tail calls (guaranteed not to grow the stack):

fn factorial_helper(n: i32, acc: i32) -> i32 {
    if n <=s 1 => i32 {
        acc;
    } else {
        become factorial_helper(n - 1, n * acc);
    }
}

become also accepts an intrinsic operation (for example become mem.grow(n)); since there is no tail-call instruction for intrinsics, this is equivalent to return mem.grow(n): the intrinsic’s result must match the function’s result type.

Unreachable and Nop

unreachable marks code that should never run: it traps if reached. nop does nothing. Neither is an expression, so neither can be bound to a name or written as an operand. Because unreachable diverges, the code after it is dead and the stack polymorphic there, so unreachable can satisfy any block result type, fill any hole _, and supply any block parameter.

if x <s 0 {
    unreachable;            // this branch cannot happen
}
nop;                        // no operation

Functions

Definition

fn name(param1: type1, param2: type2) -> return_type {
    body;
}

Functions without a return type return nothing:

fn log_value(x: i32) {
    // side effects only
}

A function may return several values, written as a parenthesized list; the body leaves them all on the stack, in order:

fn divmod(a: i32, b: i32) -> (i32, i32) {
    a /s b;
    a %s b;
}

Start Function

A function marked with the #[start] attribute runs automatically when the module is instantiated. It must take no parameters and return nothing, and a module may have at most one:

#[start]
fn init() {
    // initialization code
}

Like #[export], #[start] can carry an if <condition> guard, so a function is the start only in some configurations (at most one start per configuration):

#[start, if debug]
fn init() {
    // only run under `-D debug=true`
}

(This maps to the WebAssembly start field; see Module Fields.)

Function Types

Function types use fn:

type binary_op = fn(i32, i32) -> i32;

An unnamed parameter is written as just its type:

type callback = fn(i32) -> i32;

Calls

result = my_function(arg1, arg2);

Indirect Calls

Writing a function’s name without calling it is a reference to that function (a &func value). This is how you get one: to store in a global or a table, or to pass as an argument.

const handler: &callback = inc;   // a reference to `inc`, not a call

Call through such a reference by casting it to the expected function type:

(func_ref as &?callback)(arg)

Imports and Exports

A module talks to its host through imports and exports.

Export a definition under a name with #[export = "name"]. It applies to functions, globals, memories, tables, and tags, and a field may carry several. The bare #[export] (no name) exports the field under its own Wax name:

#[export]
fn add(x: i32, y: i32) -> i32 { x + y; }

#[export = "PI"]
const PI: f64 = 3.14159;

Import fields from a host module with an import "module" { … } block. Each entry has no body: a function is declared with just its signature, a global with its type. It is imported under its own Wax name unless a name-only #[import = "name"] overrides that:

import "env" {
    fn log(x: i32);
    #[import = "base_value"]
    const base: i32;
}

#[export = "run"]
fn run() { log(base); }

A lone import can be written on one line, import "module" <declaration>;:

import "env" fn trace(x: i32);

An imported field is re-exported by adding #[export] to its declaration.

An #[export] can carry an if <condition> guard, making just that export conditional independently of the field’s own reachability. This is how a definition can be exported under an extra name only in some configurations:

#[export]
#[export = "add_alias", if not(bootstrap)]
fn add(x: i32, y: i32) -> i32 { x + y; }

Here add is always exported under its own name, and also as add_alias except when bootstrap holds. #[export] and #[start] are the only attributes that accept a guard.

References

Reference Types

A reference type is written &type (non-nullable) or &?type (nullable):

&type           // Non-nullable reference
&?type          // Nullable reference

type is either a declared type (a struct, array, function, or continuation) or one of WebAssembly’s abstract heap types. These form five disjoint subtype hierarchies; a reference to a type accepts any reference to a type below it in the same tree (a &any accepts an &eq, an &i31, a declared struct, and so on):

any     (internal / GC references)
└─ eq   (comparable with ==)
   ├─ i31
   ├─ struct
   │  └─ declared struct types
   └─ array
      └─ declared array types

func    (function references)
└─ declared function types

extern  (host references)

exn     (exceptions)

cont    (continuations)
└─ declared continuation types

Each hierarchy also has a bottom type, a subtype of everything in its tree and inhabited only by null: none under any, nofunc under func, noextern under extern, noexn under exn, and nocont under cont. So &any, &?extern, and &eq are all valid reference types; see Types → Heap Types for how each maps to WebAssembly.

Null

null            // Null reference (requires type context)

Null Check

!ref            // True if ref is null
ref!            // Assert non-null (trap if null)

Type Testing and Casting

val is &type    // Test if val is of type (returns i32)
val as &type    // Cast val to type (trap on failure)

Structs and arrays are WebAssembly GC heap types: a value of such a type is always held through a reference (&point, &bytes, …), not inline.

i31

An i31 reference packs a 31-bit signed integer directly into a reference, with no heap allocation. Box an i32 with as &i31, and read it back with a signed or unsigned cast:

x as &i31       // box an i32 (its low 31 bits) into a reference
r as i32_s      // read an &i31 back, sign-extended
r as i32_u      //   or zero-extended

&i31 is a subtype of &eq and &any, so it can stand in wherever one of those is expected.

Structs

Definition

A struct is a named record. Mark a field mut to allow assignment after creation; a plain field is set only at creation time. A value of type point is held as &point.

type point = { x: i32, y: i32 };
type mutable_point = { x: mut i32, y: mut i32 };

A field may also hold a packed storage type, i8 or i16, stored in one or two bytes rather than a full i32. Reading one back needs an explicit cast, described under Field Access.

Creation

{T| …} allocates a new struct of type T and yields a &T. Give every field a value, or use .. to default them all (each field type must have a zero/null default):

{point| x: 10, y: 20}       // all fields given
{point| ..}                 // every field defaulted

The type name may be omitted in two cases. The first is when an expected type supplies it: a let/const annotation, the surrounding struct or array type (when the literal is a field value or an element), the parameter type of a function being called (when it is an argument), or the result type of a function or block. So let p: &point = {x: 10, y: 20}; is equivalent to writing {point| …}. Such an expectation also reaches into both branches of a conditional ?:, so a branch may omit the name (or default it with {..}): let p: &point = cond ? {x: 0, y: 0} : {..};. The second is when the field set names the type unambiguously: if exactly one struct type in the module has that exact set of field names, it is inferred even with no expected type:

fn origin() -> &eq {
    {x: 0, y: 0};               // inferred as &point from its fields
}

Field inference takes precedence over the expected type, so when the fields name a subtype of the expected type, that subtype is used. The type must still be given when the field set is ambiguous (several types share it) or absent (a {..} default with no fields).

A field written as a bare name is punned to the like-named local or global: {x} is shorthand for {x: x}. Punned and explicit fields mix freely, in any order:

fn make(x: i32, y: i32) -> &point {
    {x, y: y + 1};              // same as {x: x, y: y + 1}
}

Field Access

p.x                         // read a field
p.x = 42;                   // write a field (only if it is `mut`)

A packed (i8/i16) field is read as its raw bits, so accessing one needs an explicit as i32_s (sign-extend) or as i32_u (zero-extend) cast; there is no implicit widening. Storing to a packed field needs no cast, as the value is truncated to the field width.

Arrays

Definition

type bytes = [i8];
type mutable_ints = [mut i32];

Creation

[bytes| 0; 100]             // New array: 100 elements, all 0
[bytes| ..; 100]            // New array: 100 elements, default value
[bytes| 1, 2, 3, 4]         // New array with specific values
[bytes| seg @ 0; 100]       // New array from a segment (offset, count)

The last form builds the array from a named passive segment, copying count elements starting at offset. For a numeric array, seg is a data segment and this is array.new_data; for a reference array it is an element segment and the same syntax is array.new_elem.

As with structs, the type name may be omitted when an expected type supplies it, e.g. let xs: &bytes = [0; 100];.

Element Access

arr[i]                      // Get element
arr[i] = val;               // Set element (if mutable)
arr.length()                // Array length

Bulk operations are method calls on the array:

arr.fill(idx, val, count)             // fill count elements from idx with val
arr.copy(idx, src, src_idx, count)    // copy from another array src
arr.init(seg, idx, src_idx, count)    // fill from a passive data/element segment

An element may be a packed storage type (i8 or i16), like bytes above. As with a packed struct field, reading one needs an explicit as i32_s/as i32_u cast, while storing needs none.

Recursive and Subtyped Types

Struct and array types can refer to one another, form subtype hierarchies, and be left open for extension: the WebAssembly GC type features.

Recursive Groups

Types that reference each other are declared together in a rec { … } group, so each name is in scope for the others:

rec {
    type expr = { op: i32, args: &?args };
    type args = [mut &expr];
}

Subtyping

type sub : super declares sub as a subtype of super: it repeats the supertype’s fields in order and may add more, and a &sub is accepted wherever a &super is expected. Types are final by default; a type must be declared open to be extended.

type shape = open { kind: i32 };
type circle : shape = { kind: i32, radius: f64 };

fn kind_of(s: &shape) -> i32 { s.kind; }   // also accepts a &circle

Since the inherited fields must be repeated verbatim, a leading .. stands in for them: it copies the supertype’s field names and types, and the fields after it are the subtype’s additions. So circle above can be written to highlight just its delta:

type shape = open { kind: i32 };
type circle : shape = { .., radius: f64 };

.. must come first and appear once, and only where there is a supertype to inherit from. An inherited field that is renamed or whose type is refined (a covariant subtype) is no longer a verbatim copy, so it must be written out explicitly rather than covered by ...

Exact References and Descriptors

The custom-descriptors proposal adds two related features, enabled together with -X custom-descriptors (off by default).

Exact references

An exact reference points to values of exactly one concrete type, with no subtypes. The exactness marker ! goes between the &/&? sigil and the type name, next to the nullability ?:

&!point         // a non-null reference to exactly point
&?!point        // nullable, exactly point

Only a concrete (declared) type can be exact; &!any and other abstract heap types are rejected. An exact reference is a subtype of the plain one, so an &!point is accepted where an &point is expected, but exactness is invariant across distinct concrete types. The as and is operators reach an exact type explicitly:

x as &!point    // cast to an exact reference
x is &!point    // test for an exact type

Because ! comes before the type name, it never clashes with the postfix non-null assertion !: x as &!point casts to an exact reference, whereas (x as &point)! asserts the cast result is non-null.

A struct or array construction already yields an exact reference, since the new object has exactly the allocated type, so a literal can go where an &!point is expected without a cast. A module-defined function is likewise always exact; only an imported function needs the marker to be treated as exact, written on the declaration as fn g: !ft; (named type) or fn h!(x: i32) -> i64; (inline signature). A plainly imported function is not exact, so a reference to it must be cast to reach an exact type.

Descriptors

A struct can carry a runtime descriptor: a second struct linked to it, handy for modelling a runtime type or a vtable. The described type names its descriptor with a descriptor clause, and the descriptor names what it describes with a describes clause. The two are declared together in a rec group:

rec {
    type obj = descriptor obj_desc { x: i32 };
    type obj_desc = describes obj { info: i32 };
}

#[export = "make"]
fn make(d: &!obj_desc) -> &obj {
    {descriptor(d)| x: 42};      // construct, supplying the descriptor
}

#[export = "describe"]
fn describe(o: &obj) -> &obj_desc {
    o.descriptor;                // read the descriptor back
}

Construction takes the descriptor as an exact reference (&!obj_desc). Reading .descriptor gives an &obj_desc, or the exact &!obj_desc when the value’s own type is exact. A descriptor-based cast checks that a reference’s descriptor is a given one: o as descriptor(d), or as ?descriptor(d) to allow null. The br_on_cast and br_on_cast_fail branches accept the same descriptor(d) operand in place of a type (br_on_cast 'l descriptor(d) val, with an optional ? for the nullable form), branching on that descriptor check. The reciprocity and rec-group rules the two types must satisfy are listed under Types → Custom Descriptors.

Strings

A string literal builds a new array and lowers to an array.new_fixed. The element type must be i8 or i16, and it decides the encoding:

  • an i8 array holds the raw bytes of the (UTF-8) text, one element per byte;
  • an i16 array holds the UTF-16 code units of the text, so it must be a valid Unicode string (a code point outside the basic plane becomes a surrogate pair, hence two elements).

By default its type is [mut i8]:

"hello";                    // a new [mut i8] holding the 5 bytes

As with array and struct literals, a different array type can be selected: either by prefixing the string with the type name and #, or by an expected type from the context:

type chars = [i8];
type wide = [i16];

chars # "hi";               // a &chars, one i8 per UTF-8 byte
wide # "café";              // a &wide, one i16 per UTF-16 code unit
let s: &chars = "yo";       // type taken from the annotation

String literals recognise the same escapes as character literals: \t, \n, \r, \', \", \\, \xNN (two hex digits, one byte), and \u{...} (a Unicode code point, encoded as its UTF-8 bytes):

"tab\tend";
"smile \u{1F600}";

A string also supplies the bytes of a data segment.

A string literal round-trips faithfully through WAT, where it is written with a (@string …) annotation. Through Wasm it is best-effort: the binary keeps only the array.new_fixed, which is recovered as a string literal only when its elements, decoded by the array’s element type (UTF-8 for i8, UTF-16 for i16), form a reasonable string: valid text with no control characters other than tab, newline, and carriage return. Anything else (arbitrary binary data) decompiles as an ordinary array literal instead.

SIMD (v128)

The 128-bit vector type is v128. Its operations are written as method intrinsics with the lane shape baked into the name: the Wax name is the WebAssembly mnemonic with the leading shape moved to the end, so i32x4.add becomes add_i32x4 and f32x4.splat becomes splat_f32x4. Signed/unsigned variants keep their _s/_u suffix, and constant lane immediates (lane and shuffle indices) come first in the argument list.

fn scale(v: v128, k: i32) -> v128 {
    v.add_i32x4(k.splat_i32x4());     // i32x4.add of v and (i32x4.splat k)
}

fn lane0(v: v128) -> i32 {
    v.extract_lane_i32x4(0);          // extract lane 0
}

Constants are v128:: free functions, one argument per lane:

const ones: v128 = v128::const_i32x4(1, 1, 1, 1);

See Instructions › SIMD for the full mnemonic mapping (extract_lane/replace_lane/shuffle, comparisons, shifts, and the whole-vector *_v128 operations).

Memories

Declaration

memory mem0: i32 [1, 1000];     // address type i32, min 1 page, max 1000
memory mem1: i64 [2];           // min 2 pages, no maximum
memory mem2: i32;               // size derived from data segments

A memory may declare a custom page size with a pagesize clause after its limits. The page size is a byte count and must be 1 or 65536 (the default); limits are then counted in pages of that size:

memory small: i32 [4096] pagesize 1;      // 4096 pages of 1 byte
memory mem3: i32 [1, 1000] pagesize 65536; // the default page size, explicit

A memory may be declared shared (the threads proposal), for use with atomic accesses across threads. A shared memory must specify a maximum size:

memory pool: i32 [1, 16] shared;

Atomics

Atomic memory operations are methods on a memory, named after the WebAssembly mnemonic with . rewritten as _ (the value type is part of the name):

mem.i32_atomic_load(p)              // atomic load
mem.i32_atomic_store(p, v);         // atomic store
mem.i32_atomic_rmw_add(p, v)        // read-modify-write, returns the old value
mem.i64_atomic_rmw16_add_u(p, v)    // narrow (16-bit) RMW on an i64
mem.i32_atomic_rmw_cmpxchg(p, expected, replacement)
mem.atomic_notify(p, count)         // wake waiters
mem.atomic_wait32(p, expected, timeout)

An atomic access must use its natural alignment. atomic.fence, which has no memory operand, is written as the atomic::fence() intrinsic.

Data Segments

memory mem1: i64 {
    data _ @ [0x1000] = "hello world";   // active, anonymous
    data greeting @ [0x2000] = "hi";     // active, named
}

data seg = "raw\x00bytes";               // top-level passive segment
data init @ mem0 [0] = "hello";          // top-level active segment

Load and Store

mem0.load32(p)              // i32 load
mem0.load64(p)              // i64 load
mem0.load8(p) as i32_u      // narrow load, zero-extended
mem0.load16(p) as i32_s     // narrow load, sign-extended
mem0.load32(p) as i64_s     // load then widen to i64

mem0.store32(p, v);         // store (width by method, type by value)
mem0.store16(p, v);
mem0.store32(p, v, 1, 16);  // align=1, offset=16

The two optional trailing arguments are the access align and offset (constant integers).

A load8 or load16 returns raw bits, so it needs an explicit as i32_s/as i32_u (or as i64_s/as i64_u) cast to extend to the value type, as the lines above show; load32/load64 and the stores need none.

Tables and Element Segments

A table holds references and is indexed like an array. Element segments declare lists of references, optionally initializing a table.

table funcs: &?func [1, 10];          // table of function references
elem init: &?func @ funcs[0] = [f];   // active: initialize funcs at offset 0
elem pool: &?func = [f, g, h];        // passive segment

funcs[i]                              // table.get
funcs[i] = g;                         // table.set
(funcs[i] as &cmp)(x, y)              // indirect call (call_indirect)

The table-management instructions are method calls on the table (and drop on an element segment):

funcs.size()                          // current size
funcs.grow(init, n)                   // grow by n, filling with init; returns the old size
funcs.fill(dst, val, n)               // set n slots from dst to val
funcs.copy(dst, src, n)               // copy n slots within the table
funcs.init(pool, dst, src, n)         // copy n refs from element segment pool
pool.drop();                          // drop a passive element segment

A passive element segment initializes a GC array of references with the same [t| seg @ off; count] form used for data segments; a reference element type selects the element segment:

[handlers| pool @ 0; 3]               // array.new_elem

Exceptions

Tags

A tag declares an exception, optionally carrying a payload. The parameter list is required; write () for no payload:

tag stop();                 // no payload
tag overflow(i32);          // carries an i32
tag pair(i32, f64);         // carries several values

A tag may also declare a result type, written like a function signature. This is used by stack switching: resuming a suspended continuation hands this value back to the suspend expression.

tag yield(i32) -> i32;      // carries an i32; resumes with an i32

Throw

throw raises a tag together with its payload:

throw overflow 42;

Try / Catch

A try runs its body and routes a matching thrown tag to a handler. Like a block it may carry a result type (try i32 { … }) that the body and every handler produce. A caught tag’s payload is left on the operand stack for the handler to pick up with a hole _:

fn lookup(k: i32) -> i32 {
    try i32 {
        find(k);             // may throw `overflow`
    } catch {
        overflow => { _; }   // `_` is the thrown i32 payload
        _ => { 0; }          // catch-all
    }
}

Each tag => { … } handler matches one tag; a bare _ => { … } matches any exception. Leave the catch-all out to let unmatched exceptions propagate.

Branching handlers (try_table)

A lower-level form branches to a label instead of running an inline handler, mapping directly to WebAssembly’s try_table:

try { might_throw(); } catch [overflow -> 'h]    // on `overflow`, branch to 'h
try { might_throw(); } catch [overflow & -> 'h]  // also deliver the exnref
try { might_throw(); } catch [_ -> 'h]           // catch any exception

The target label receives the payload (and, with &, the exception reference, of type &exn, or &?exn for the nullable form), so the labelled block’s type must match what it is handed.

Stack Switching

Stack switching (the WebAssembly typed-continuations proposal) lets a computation suspend itself, yield control to a scheduler, and later be resumed. A continuation type wraps a function type with cont:

type task = fn(i32) -> i32;
type k = cont task;

A tag with a result type is the suspend/resume channel: suspend passes the tag’s payload out and evaluates to its result once resumed.

tag yield(i32) -> i32;

fn worker(x: i32) -> i32 {
    suspend yield(x);          // yield `x`; the result is what resumes it
}

Continuations are created and driven with cont_new, cont_bind, resume, resume_throw, and resume_throw_ref. cont wraps a named function type, so each continuation shape is a type. resume takes a list of handlers mapping a suspended tag to a label (empty here):

type unit_task = fn() -> i32;
type k0 = cont unit_task;

fn spawn() -> &k { cont_new k (worker); }          // wrap `worker` (a task)
fn prime(c: &k) -> &k0 { cont_bind k k0 (7, c); }  // bind the argument
fn go(c: &k0) -> i32 { resume k0 [] (c); }         // run until it suspends

The abstract continuation heap types are written &cont and &nocont (with &?cont for the nullable form).

switch hands control straight to another continuation instead of suspending back to a handler. Written switch k tag (args, cont), it passes args and the current continuation to cont, tagged by tag; the resume that drives things enables it with a tag -> switch arm in its handler list.

Holes

A hole (_) is a placeholder for a value that an earlier statement in the same sequence has left on WebAssembly’s implicit operand stack. It is the surface syntax for that stack flow: rather than naming an intermediate value, you leave a hole where it should be plugged in.

fn example() -> i32 {
    1; 2; _ + _;    // Equivalent to: let a = 1; let b = 2; a + b
}

Here the statements 1 and 2 each push a value; the two holes in _ + _ consume them.

How many, and in what order. The number of holes in an expression is the number of stack values it pulls in. They are filled left-to-right with the stack values in the order those values were produced: the earliest value fills the leftmost hole. Order therefore matters for non-commutative operators:

fn diff() -> i32 {
    10; 20; _ - _;    // 10 - 20, not 20 - 10
}

A single hole is common when combining one stacked value with an explicit operand:

fn add_one(x: i32) -> i32 {
    x; _ + 1;
}

Holes must come first. Within an expression, every hole must precede (in evaluation order) any explicit value-producing operand. Once a non-hole operand appears, no further holes may follow it. Explicit operands after all the holes are fine:

fn ok() -> i32 {
    1; 2; _ + _ + 3;    // OK: the literal 3 comes after both holes
}

but an explicit operand wedged before an unfilled hole is rejected:

fn bad() -> i32 {
    2; 3; _ + 3 + _;    // Error: This expression occurs before a hole '_'.
}

This restriction keeps holes unambiguous: they always refer to values already on the stack, never to operands appearing later in the expression.

When decompiling Wasm or WAT back to Wax, the compiler introduces holes wherever an instruction takes an operand from the stack instead of from a nested sub-expression, so this same mechanism round-trips stack-style code.

Conditional Compilation

Top-level items can be guarded by conditions, using a Rust-like attribute syntax. #[if(<condition>)] { ... } keeps the braced items only when <condition> holds; an optional #[else] { ... } provides an alternative. The braces are required:

#[if(ocaml_version >= (5, 1, 0))]
{
    const caml_marshal_header_size: i32 = 16;
}
#[else]
{
    const caml_marshal_header_size: i32 = 20;
}

A condition is one of:

  • a boolean variable, e.g. debug;

  • a comparison variable op literal, where op is =, !=, <, <=, > or >=, and the literal is either a version tuple (major, minor, patch) or a string "...":

    feature = "gc"
    ocaml_version >= (5, 1, 0)
    
  • a combination built with all(...) (conjunction), any(...) (disjunction) or not(...) (negation), nested arbitrarily:

    all(debug, not(target = "wasm32"))
    

A branch groups any number of items, so several can be guarded at once:

#[if(debug)]
{
    const debug_enabled: i32 = 1;
    fn debug_log(msg: i32) {
        // ...
    }
}

The same #[if(...)] { ... } / #[else] { ... } form also guards statements inside a function body:

fn size() -> i32 {
    #[if(debug)]
    {
        return 16;
    }
    #[else]
    {
        return 20;
    }
}

These two are the only levels at which conditional compilation applies: whole module items and whole statements. A condition cannot guard part of an expression. The one finer-grained case is the if <condition> guard on an #[export] or #[start], which makes a single export (or the start) conditional without wrapping its definition in an #[if] block; its condition is simplified against any enclosing #[if] and resolved by -D just like a block condition.

A statement-level branch cannot introduce a local with let, since the two branches are mutually exclusive and a binding made in one would not be in scope after the conditional. Declare the local before the conditional and assign to it inside each branch instead:

fn size() -> i32 {
    let s: i32;
    #[if(debug)]
    {
        s = 16;
    }
    #[else]
    {
        s = 20;
    }
    return s;
}

The conditions are not evaluated by the compiler; they are preserved for a downstream preprocessor. The #[if] and #[else] branches are mutually exclusive (they never coexist), so, for instance, the same name may be defined in both.

Variables can be given values on the command line with -D/--define, which specializes the conditionals: a condition that becomes fully determined causes its conditional to be removed (the surviving branch is spliced in), and one that still mentions unset variables is kept with its condition simplified. For example, wax -D debug=true turns #[if(all(debug, target = "wasi"))] into #[if(target = "wasi")], and wax -D debug=false removes that conditional altogether.

When type checking is enabled (--validate), every reachable combination of conditions is checked independently. Because branches are mutually exclusive, a name defined in both the #[if] and #[else] branch is accepted. An error that occurs only under some conditions is reported together with the assumption that makes it reachable:

Error: This instruction has type float but is expected to have type i32.
 ──➤  example.wax:4:16
Hint: reachable when not debug

Cheat Sheet

A terse syntax reference. See the Language Guide for the full explanations, and Correspondence for the mapping to WebAssembly.

Types

i32  i64  f32  f64        // numbers
v128                      // SIMD vector
i8   i16                  // packed storage (struct fields / array elements only)
&t   &?t                  // reference: non-null / nullable
&!t  &?!t                 // exact reference (-X custom-descriptors)

Abstract heap types: any eq i31 struct array func extern exn cont, and the bottom types none nofunc noextern noexn nocont.

Literals

42  0x2A  0o52  0b101010  // integers (flexible: i32/i64/f32/f64 by context)
3.14  1.0e10  0x1.5p3  inf  nan   // floats
'A'  '\n'  '\u{1F600}'    // char, an i32 code point
"text"  bytes # "text"    // string, an i8/i16 array

Variables

let x: i32 = 0;           // local (type or initializer may be omitted)
let (q, r) = divmod(a);   // bind several results
const PI: f64 = 3.14;     // immutable global
let counter: i32 = 0;     // mutable global
x = 1;                    // assign
x := 1;                   // assign and yield the value (locals only)
x += 1;                   // compound: += -= *= /= /s= /u= %s= %u= &= |= ^= <<= >>s= >>u=
_ = e;                    // evaluate and discard

Operators

+  -  *  /  /s  /u  %s  %u       // arithmetic (/ is float division)
&  |  ^  <<  >>s  >>u            // bitwise
==  !=  <  <s  <u  <=  <=s  ...  // comparison (== / != on refs is identity)
-x   !x                         // negate / logical not (eqz)

Type operations

e as t          // cast or convert (as i32_s / as i32_u for signedness)
e is &t         // type test, yields i32
!ref            // true if null
ref!            // assert non-null (traps if null)
x as &i31       // box an i32; read back with as i32_s / as i32_u

Control flow

if c { } else { }                    // conditional statement
c ? a : b                            // conditional expression
do { }        'l: do t { }           // block (optionally labeled / typed)
loop { }                             // loop
while c { }   'l: while c : (step) { }   // while (optional continue-expression)
br 'l;   br_if 'l c;   br_table ['a 'b else 'd] i;
br_on_null 'l v;   br_on_cast 'l &t v;   // also _non_null / _cast_fail
dispatch i ['a 'b else 'd] { 'a: { } 'b: { } 'd: { } }   // jump table (falls through)
match v { p: &t => { }  null => { }  _ => { } }          // type match
return e;   become f(x);             // return / tail call
unreachable;   nop;                  // trap / no-op (statements, not expressions)

Functions

fn f(x: i32, y: i32) -> i32 { x + y; }   // definition
fn g(x: i32) -> i32;                     // declaration (for imports)
f(a, b)                                  // call
(fref as &?ft)(a, b)                     // indirect call through a reference

Structs and arrays

type point = { x: i32, y: mut i32 };     // struct (mut = assignable)
{point| x: 1, y: 2}   {x, y}   {..}      // create (name optional; punned; defaulted)
p.x        p.x = 1;                      // field access

type bytes = [mut i8];                   // array
[bytes| 0; n]   [1, 2, 3]   [t| seg @ off; n]   // create (last is new_data/new_elem)
a[i]   a[i] = v;   a.length()            // element access
a.fill(i, v, n)   a.copy(i, src, si, n)   a.init(seg, i, si, n)

Module fields

memory m: i32 [min, max];                // memory (optional: pagesize N, shared)
table t: &?func [min, max];              // table
data d = "bytes";                        // data segment
elem e: &?func = [f, g];                 // element segment
tag oops(i32);                           // exception tag
import "env" { fn log(x: i32); }         // import block
#[export = "name"] fn f() { }            // export
#[start] fn init() { }                   // start function
#[if(cond)] ... #[else] ...              // conditional compilation

Memory access

m.load32(p)   m.load8(p) as i32_u   m.store16(p, v);   // load / store (width in name)
m.size()   m.grow(n)   m.fill(d, v, n)   m.copy(d, s, n)   // management

Exceptions and stack switching

try t { } catch { oops => { _; }  _ => { } }     // try / catch (payload via hole _)
throw oops 42;                                   // throw
try { } catch [oops -> 'h]                       // branch-to-label form (try_table)
type k = cont ft;                                // continuation type
suspend yield(x);   resume k [tag -> 'l] (args, c);   // suspend / resume

Examples

Complete examples demonstrating Wax features and their WebAssembly equivalents.

Named Module

A #![module = "..."] inner attribute names the module.

Wax

#![module = "calculator"]

#[export = "square"]
fn square(x: i32) -> i32 {
    x * x;
}

Equivalent WAT

(module $calculator
  (func $square (export "square") (param $x i32) (result i32)
    local.get $x
    local.get $x
    i32.mul))

Arithmetic Functions

Wax

#[export = "add"]
fn add(x: i32, y: i32) -> i32 {
    x + y;
}

#[export = "multiply"]
fn multiply(x: i32, y: i32) -> i32 {
    x * y;
}

Equivalent WAT

(func $add (export "add") (param $x i32) (param $y i32) (result i32)
  local.get $x
  local.get $y
  i32.add)

(func $multiply (export "multiply") (param $x i32) (param $y i32) (result i32)
  local.get $x
  local.get $y
  i32.mul)

Factorial with Recursion

Wax

#[export = "factorial"]
fn factorial(n: i32) -> i32 {
    if n <=s 1 => i32 {
        1;
    } else {
        n * factorial(n - 1);
    }
}

Equivalent WAT

(func $factorial (export "factorial") (param $n i32) (result i32)
  local.get $n
  i32.const 1
  i32.le_s
  if (result i32)
    i32.const 1
  else
    local.get $n
    local.get $n
    i32.const 1
    i32.sub
    call $factorial
    i32.mul
  end)

Factorial with Tail Call

Wax

#[export = "factorial"]
fn factorial(n: i32) -> i32 {
    become factorial_helper(n, 1);
}

fn factorial_helper(n: i32, acc: i32) -> i32 {
    if n <=s 1 => i32 {
        acc;
    } else {
        become factorial_helper(n - 1, n * acc);
    }
}

Loop with Early Exit

Wax

type ints = [i32];

#[export = "find_first_zero"]
fn find_first_zero(arr: &ints) -> i32 {
    let len = arr.length();
    let i = 0;
    'search: loop {
        if i >=s len {
            return -1;
        }
        if arr[i] == 0 {
            return i;
        }
        i += 1;
        br 'search;
    }
    unreachable;
}

Equivalent WAT

(func $find_first_zero (export "find_first_zero")
      (param $arr (ref $array_i32)) (result i32)
  (local $len i32)
  (local $i i32)
  local.get $arr
  array.len
  local.set $len
  i32.const 0
  local.set $i
  (loop $search
    local.get $i
    local.get $len
    i32.ge_s
    if
      i32.const -1
      return
    end
    local.get $arr
    local.get $i
    array.get $array_i32
    i32.eqz
    if
      local.get $i
      return
    end
    local.get $i
    i32.const 1
    i32.add
    local.set $i
    br $search))

While Loops

Wax

#[export = "triangle"]
fn triangle(n: i32) -> i32 {
    let i: i32 = 0;
    let total: i32 = 0;
    while i <s n {
        i += 1;
        total += i;
    }
    total;
}

#[export = "countdown"]
fn countdown(n: i32) -> i32 {
    let steps: i32 = 0;
    'loop: loop {
        n -= 1;
        steps += 1;
        br_if 'loop n >s 0;
    }
    steps;
}

Equivalent WAT

(func $triangle (export "triangle") (param $n i32) (result i32)
  (local $i i32) (local $total i32)
  (local.set $i (i32.const 0))
  (local.set $total (i32.const 0))
  (loop $loop
    (if (i32.lt_s (local.get $i) (local.get $n))
      (then
        (local.set $i (i32.add (local.get $i) (i32.const 1)))
        (local.set $total (i32.add (local.get $total) (local.get $i)))
        (br $loop))))
  (local.get $total)
)

(func $countdown (export "countdown") (param $n i32) (result i32)
  (local $steps i32)
  (local.set $steps (i32.const 0))
  (loop $loop
    (local.set $n (i32.sub (local.get $n) (i32.const 1)))
    (local.set $steps (i32.add (local.get $steps) (i32.const 1)))
    (br_if $loop (i32.gt_s (local.get $n) (i32.const 0))))
  (local.get $steps)
)

Dispatch (Jump Table)

Wax

#[export = "rgb_channel"]
fn rgb_channel(color: i32, value: i32) -> i32 {
    dispatch color ['red 'green 'blue else 'bad] {
        'bad:   { return -1; }
        'blue:  { return value & 255; }
        'green: { return (value & 255) << 8; }
        'red:   { return (value & 255) << 16; }
    }
}

Equivalent WAT

(func $rgb_channel (export "rgb_channel")
  (param $color i32) (param $value i32) (result i32)
  (block $red
    (block $green
      (block $blue
        (block $bad (br_table $red $green $blue $bad (local.get $color)))
        (return (i32.const -1)))
      (return (i32.and (local.get $value) (i32.const 255))))
    (return
      (i32.shl (i32.and (local.get $value) (i32.const 255)) (i32.const 8))))
  (return
    (i32.shl (i32.and (local.get $value) (i32.const 255)) (i32.const 16))))

Type Match

Wax

type cell = { value: i32 };
type cons = { head: &eq, tail: &?eq };

#[export = "sum_list"]
fn sum_list(v: &?eq) -> i32 {
    match v {
        c: &cons => { return (c.head as &cell).value + sum_list(c.tail); }
        x: &cell => { return x.value; }
        _ => { return 0; }
    }
}

Equivalent WAT

The scrutinee is evaluated once and threaded through a br_on_cast chain in the innermost block; each test branches out to its arm’s block (named by a readable $arm/$default label, picked fresh so it cannot capture a user branch) carrying the narrowed value, and the default trails the outer escape block:

(func $sum_list (export "sum_list") (param $v eqref) (result i32)
  (local $x (ref $cell)) (local $c (ref $cons))
  (block $default
    (local.set $x
      (block $arm_1 (result (ref $cell))
        (local.set $c
          (block $arm (result (ref $cons))
            (drop
              (br_on_cast $arm_1 eqref (ref $cell)
                (br_on_cast $arm eqref (ref $cons) (local.get $v))))
            (br $default)))
        (return
          (i32.add
            (struct.get $cell $value
              (ref.cast (ref $cell) (struct.get $cons $head (local.get $c))))
            (call $sum_list (struct.get $cons $tail (local.get $c)))))))
    (return (struct.get $cell $value (local.get $x))))
  (return (i32.const 0)))

Structs and Methods

Wax

type point = { x: i32, y: i32 };

#[export = "make_point"]
fn make_point(x: i32, y: i32) -> &point {
    {point| x, y};             // field shorthand: {x, y} means {x: x, y: y}
}

#[export = "distance_squared"]
fn distance_squared(p1: &point, p2: &point) -> i32 {
    let dx = p1.x - p2.x;
    let dy = p1.y - p2.y;
    dx * dx + dy * dy;
}

Mutable Structs

Wax

type counter = { value: mut i32 };

#[export = "new_counter"]
fn new_counter() -> &counter {
    {counter| value: 0};
}

#[export = "increment"]
fn increment(c: &counter) {
    c.value = c.value + 1;
}

#[export = "get_value"]
fn get_value(c: &counter) -> i32 {
    c.value;
}

Inferring Struct and Array Types

Wax

type point = { x: i32, y: i32 };
type ints = [mut i32];

// Where an expected type fixes the result (here a `let` annotation) the
// struct or array type name may be omitted.
#[export = "origin"]
fn origin() -> &point {
    let p: &point = {x: 0, y: 0};
    p;
}

#[export = "zeros"]
fn zeros() -> &ints {
    let a: &ints = [0; 16];
    a;
}

// A struct type can also be inferred from its field set alone, with no
// expected type: only `point` has the fields {x, y}, so its name may be
// dropped here too.
#[export = "make_origin"]
fn make_origin() -> &eq {
    {x: 0, y: 0};
}

// The expected type also reaches into both branches of a conditional `?:`, so a
// branch may omit the name: here the `{..}` default is resolved from the
// `-> &point` result type flowing through the `?:`.
#[export = "pick"]
fn pick(c: i32) -> &point {
    c ? {..} : {x: 1, y: 2};
}

Inferring Block Result Types

Wax

A block’s result type, => T on an if, or the type after do (and likewise loop, try, and try_table), may be omitted where it is fixed by the values the block produces or by the surrounding context:

#[export = "max0"]
fn max0(n: i32) -> i32 {
    // `=> i32` omitted: inferred from the branches and the return type
    if n <s 0 {
        0;
    } else {
        n;
    }
}

#[export = "answer"]
fn answer() -> i32 {
    // the `do` block's result type is inferred from the value it leaves
    let x = do {
        40 + 2;
    };
    x;
}

Arrays

Wax

type bytes = [mut i8];

#[export = "sum_bytes"]
fn sum_bytes(arr: &bytes) -> i32 {
    let sum = 0;
    let i = 0;
    let len = arr.length();
    'loop: loop {
        if i >=s len {
            return sum;
        }
        sum += arr[i] as i32_u;
        i += 1;
        br 'loop;
    }
    unreachable;
}

#[export = "fill_bytes"]
fn fill_bytes(arr: &bytes, val: i32) {
    let i = 0;
    let len = arr.length();
    'loop: loop {
        if i >=s len {
            return;
        }
        arr[i] = val;
        i += 1;
        br 'loop;
    }
}

Exception Handling

Wax

tag divide_by_zero();
tag overflow();

#[export = "safe_divide"]
fn safe_divide(a: i32, b: i32) -> i32 {
    if b == 0 {
        throw divide_by_zero;
    }
    a /s b;
}

#[export = "try_divide"]
fn try_divide(a: i32, b: i32) -> i32 {
    'on_error: do {
        try {
            return safe_divide(a, b);
        } catch [divide_by_zero -> 'on_error]
    }
    0;   // Return 0 on division by zero
}

Recursive Types (Linked List)

Wax

type list = { value: i32, next: &?list };

#[export = "make_node"]
fn make_node(value: i32, next: &?list) -> &list {
    {list| value, next};
}

#[export = "sum_list"]
fn sum_list(head: &?list) -> i32 {
    if !head {
        return 0;
    }
    let n = head!;
    n.value + sum_list(n.next);
}

Imports and Exports

Wax

// Import a function and a global from the host environment
import "env" {
    fn log(value: i32);
    const base_value: i32;
}

// Export a function that uses the imports
#[export = "compute_and_log"]
fn compute_and_log(x: i32) -> i32 {
    let result = x + base_value;
    log(result);
    result;
}

Hash Function

A more complex example showing bitwise operations:

Wax

#[export = "hash_mix_int"]
fn hash_mix_int(h: i32, d: i32) -> i32 {
    ((d * 0xcc9e2d51).rotl(15) * 0x1b873593 ^ h).rotl(13) * 5 + 0xe6546b64;
}

#[export = "hash_finalize"]
fn hash_finalize(h: i32) -> i32 {
    h ^= h >>u 16;
    h *= 0x85ebca6b;
    h ^= h >>u 13;
    h *= 0xc2b2ae35;
    h ^ h >>u 16;
}

Strings and Characters

A character literal is an i32 code point; a string literal builds an i8 or i16 array ([mut i8] by default, or a named array type with the T # "…" prefix). An i8 array holds the raw UTF-8 bytes; an i16 array holds the UTF-16 code units. See Strings.

Wax

type chars = [i8];
type wide = [i16];

#[export = "newline"]
fn newline() -> i32 {
    '\n';                       // 10
}

#[export = "greeting"]
fn greeting() -> &chars {
    chars # "hi\u{21}";         // the bytes 'h', 'i', '!'
}

#[export = "wide_greeting"]
fn wide_greeting() -> &wide {
    wide # "café";              // the UTF-16 units 'c', 'a', 'f', 'é'
}

Equivalent WAT

wax -f wat keeps the literals as (@char …) / (@string …) annotations, so they round-trip back to Wax unchanged:

(func $newline (export "newline") (result i32) (@char "\n"))

(func $greeting (export "greeting") (result (ref $chars))
  (@string $chars "hi!"))

(func $wide_greeting (export "wide_greeting") (result (ref $wide))
  (@string $wide "café"))

In a Wasm binary these lower to i32.const 10 and an array.new_fixed: the i8 greeting holds the three bytes 104, 105, 33, and the i16 greeting the four UTF-16 units 99, 97, 102, 233. The character comes back as the integer 10, while each string is still recovered (its elements are reasonable text).

Holes (Stack Values)

Holes (_) plug in values that earlier statements leave on the operand stack. The two holes in _ + _ consume x * 2 and y * 3, left-to-right in the order they were produced. See Holes for the full rules.

Wax

#[export = "blend"]
fn blend(x: i32, y: i32) -> i32 {
    x * 2;
    y * 3;
    _ + _;
}

Equivalent WAT

(func $blend (export "blend") (param $x i32) (param $y i32) (result i32)
  local.get $x
  i32.const 2
  i32.mul
  local.get $y
  i32.const 3
  i32.mul
  i32.add)

Multiple Return Values

A function may return several values, and a let can bind them all at once from a parenthesised list of names. The call runs once and its results are stored into the locals. See Local Variables.

Wax

fn divmod(a: i32, b: i32) -> (i32, i32) {
    a /s b;
    a %s b;
}

#[export = "checksum"]
fn checksum(a: i32, b: i32) -> i32 {
    let (q, r) = divmod(a, b);
    q + r;
}

Equivalent WAT

(func $divmod (param $a i32) (param $b i32) (result i32 i32)
  local.get $a
  local.get $b
  i32.div_s
  local.get $a
  local.get $b
  i32.rem_s)

(func $checksum (export "checksum") (param $a i32) (param $b i32) (result i32)
  (local $q i32) (local $r i32)
  local.get $a
  local.get $b
  call $divmod
  local.set $r
  local.set $q
  local.get $q
  local.get $r
  i32.add)

Wide Arithmetic

128-bit integer arithmetic uses the i64:: intrinsics, which take and return their operands as (low, high) pairs of i64 values. Each returns two results, so a multi-value let binds them.

Wax

#[export = "add_u128"]
fn add_u128(a_lo: i64, a_hi: i64, b_lo: i64, b_hi: i64) -> (i64, i64) {
    let (lo, hi) = i64::add128(a_lo, a_hi, b_lo, b_hi);
    lo;
    hi;
}

#[export = "mul_u64_to_u128"]
fn mul_u64_to_u128(a: i64, b: i64) -> (i64, i64) {
    i64::mul_wide_u(a, b);
}

Custom Descriptors

A struct can carry a descriptor: a second struct linked to it by reciprocal descriptor/describes clauses. The descriptor instructions never name the target type: it is recovered from the descriptor operand (the type the operand describes), so all of them share one descriptor(d) clause. struct.new_desc allocates carrying a descriptor, .descriptor reads it back, and as [?]descriptor(d) / br_on_cast [?]descriptor(d) cast on descriptor equality (a leading ? makes the result nullable).

Wax

rec {
    type obj = descriptor obj_desc { x: i32 };
    type obj_desc = describes obj { kind: i32 };
}

#[export = "make"]
fn make(kind: i32, x: i32) -> &!obj {
    { descriptor({obj_desc| kind})| x };
}

#[export = "kind_of"]
fn kind_of(o: &obj) -> i32 {
    o.descriptor.kind;
}

#[export = "narrow"]
fn narrow(v: &?any, d: &!obj_desc) -> &?obj {
    v as ?descriptor(d);
}

Branch hints

The branch-hinting proposal marks a conditional branch likely or unlikely taken. Prefix the branch with #[likely] / #[unlikely]; the hint is preserved through every conversion (no feature flag required).

Wax

#[export = "clamp_low"]
fn clamp_low(x: i32) -> i32 {
    #[likely] if x >=s 0 => i32 {
        x;
    } else {
        0;
    }
}

#[export = "drain"]
fn drain(n: i32) {
    'l: loop {
        #[unlikely] br_if 'l n;
    }
}

Linear Memory

A memory declaration reserves linear memory. load/store methods on it read and write, with the access width in the method name; a narrow (load8/load16) load returns raw bits, so it needs an explicit sign/zero-extending cast.

Wax

memory mem: i32 [1];

#[export = "sum_bytes"]
fn sum_bytes(start: i32, end: i32) -> i32 {
    let total: i32 = 0;
    let p: i32 = start;
    while p <u end {
        total += mem.load8(p) as i32_u;   // byte load, zero-extended
        p += 1;
    }
    total;
}

SIMD

v128 vector operations are method intrinsics with the lane shape baked into the name (mul_i32x4, add_i32x4, extract_lane_i32x4).

Wax

#[export = "madd_i32x4"]
fn madd_i32x4(a: v128, b: v128, c: v128) -> v128 {
    a.mul_i32x4(b).add_i32x4(c);          // a * b + c, four i32 lanes at once
}

#[export = "sum_lanes"]
fn sum_lanes(v: v128) -> i32 {
    v.extract_lane_i32x4(0) + v.extract_lane_i32x4(1)
        + v.extract_lane_i32x4(2) + v.extract_lane_i32x4(3);
}

Stack Switching

A continuation type wraps a function type with cont. A coroutine suspends with a value; a driver resumes it, routing the suspended tag to a handler label that receives the payload and the paused continuation. Note that the continuation’s type changes after a suspend, since resuming it now needs the reply value.

Wax

type task = fn() -> i32;
type k = cont task;
type resumed = fn(i32) -> i32;   // the continuation's type after a suspend
type kr = cont resumed;

tag yield(i32) -> i32;

// A coroutine: yield 10, then return the reply plus one.
fn worker() -> i32 {
    let reply: i32 = suspend yield(10);
    reply + 1;
}

// Run the worker until its first suspend and return the value it yielded.
#[export = "first_yield"]
fn first_yield() -> i32 {
    let c: &?k = cont_new k (worker);
    let (v, rest) =
        'on_yield: do () -> (i32, &kr) {
            _ = resume k [yield -> 'on_yield] (c!);   // worker returned; drop its result
            return -1;
        };
    _ = rest;            // `rest` could be resumed with a reply to continue the worker
    v;
}

Introduction

This guide documents the correspondence between WebAssembly (Wasm) and Wax constructs. It is intended for developers who are familiar with Wasm and want to understand how Wax maps to it.

High-level differences

Wax is an expression-oriented language, whereas Wasm is stack-oriented. However, Wax constructs map very closely to Wasm instructions, often one-to-one or with simple desugaring.

Key differences:

  • Expressions vs Stack: Wax uses variables (let) and nested expressions instead of explicit stack manipulation (local.get/set, drop).
  • Control Flow: Wax uses structured control flow (if, loop, do, try) that resembles high-level languages but maps directly to Wasm structured control instructions.
  • Types: Wax types define a direct mapping to Wasm types, with a slightly more concise syntax.
  • Modules: Wax modules group imports under an import "module" { … } block and mark exports with an #[export] attribute on the field itself, rather than using separate import/export definitions.

Extensions to WAT

Wax understands a few non-standard annotations in WAT. They reuse the standard (@name …) annotation syntax, but Wax gives them a meaning a stock WAT tool would not, so WAT that uses them round-trips only through Wax:

  • Literals: (@char "c") and (@string $t "…") are character and string literals. (@char …) stands in for the i32.const of a code point; (@string …) for the array.new_fixed that builds an i8 or i16 array. They round-trip through WAT, but a Wasm binary keeps only the underlying instructions. See Literals. Use --desugar to expand them into plain wasm.
  • Conditional compilation: (@if <cond> (@then …) (@else …)) guards a module field by a condition that a downstream preprocessor resolves. It is the WAT counterpart of Wax’s #[if]. See Conditional Annotations. Resolve conditions with -D; an unresolved (@if …) makes --desugar fail.

Types

Wax types map directly to WebAssembly types.

Value Types

WasmWaxNotes
i32i3232-bit integer
i64i6464-bit integer
f32f3232-bit float
f64f6464-bit float
v128v128128-bit vector
(ref null <ht>)&?<ht>Nullable reference to heap type <ht>
(ref <ht>)&<ht>Non-nullable reference to heap type <ht>
(ref (exact <t>))&!<t>Reference to exactly concrete type <t>
(ref null (exact <t>))&?!<t>Nullable reference to exactly <t>

Exact references (&!<t> / &?!<t>, gated on -X custom-descriptors) are explained in the language guide.

Storage Types

Storage types are used in fields of structs and arrays to define packed data.

WasmWaxNotes
i8i88-bit integer (packed)
i16i1616-bit integer (packed)

Any value type is also a valid storage type; i8 and i16 are the additional packed types available only in struct and array fields.

Heap Types

WasmWax
funcfunc
externextern
anyany
eqeq
structstruct
arrayarray
i31i31
exnexn
contcont
noexternnoextern
nofuncnofunc
noexnnoexn
nocontnocont
nonenone
<typeidx><identifier>

Composite Types

Structs

type point = { x: i32, y: i32 };
type mutable_point = { x: mut i32, y: mut i32 };

Maps to Wasm (type $point (struct (field i32) (field i32))).

Arrays

type bytes = [i8];
type mutable_bytes = [mut i8];

Maps to Wasm (type $bytes (array i8)).

Functions

type binop = fn(i32, i32) -> i32;

Maps to Wasm (type $binop (func (param i32 i32) (result i32))).

Continuations

A continuation type (from the stack-switching proposal) wraps a function type:

type ft = fn(i32) -> i32;
type k = cont ft;

Maps to Wasm (type $k (cont $ft)). See Stack Switching Instructions for the operations on continuations.

cont and nocont are reserved heap types (like func/struct), so the abstract continuation references &cont, &?cont and &nocont are available directly in addition to references to declared continuation types (&k, &?k). Because these names are reserved, a WebAssembly type literally named $cont is renamed (e.g. to cont_2) when decompiling to Wax.

Recursive Types

Wax allows defining recursive reference types using rec { ... }.

rec {
    type tree = { value: i32, children: &forest };
    type forest = [&?tree];
}

Supertypes and Finality

Types are final by default. To make a type open (extensible), use the open keyword. To specify a supertype, use : supertype before the assignment.

type point = { x: i32, y: i32 };                  // final by default
type open_point = open { x: i32 };                // non-final: extensible
type sub_point : open_point = { x: i32, y: i32 }; // extends open_point

A subtype repeats its supertype’s fields in order; a leading .. abbreviates that repeated prefix (type sub_point : open_point = { .., y: i32 };). There is no Wasm counterpart (every Wasm struct lists all its fields), so .. is expanded on the way to Wasm. Decompiling reverses it: when a subtype’s leading fields exactly match (name and type) its supertype’s, they are collapsed back to ... A renamed or covariantly-refined inherited field does not match, so it is kept explicit.

Custom Descriptors

The custom-descriptors proposal lets a struct carry a descriptor: a second struct associated with it (used, for instance, to model a runtime type or vtable). The described type names its descriptor with a descriptor <name> clause, and the descriptor names what it describes with a describes <name> clause. Both clauses sit between the open marker and the struct body:

rec {
  type obj = descriptor obj_desc { x: i32 };
  type obj_desc = describes obj { };
}

Maps to Wasm:

(rec
  (type $obj (descriptor $obj_desc) (struct (field $x i32)))
  (type $obj_desc (describes $obj) (struct)))

The two types must satisfy several well-formedness rules, all checked during validation:

  • both must be structs, declared in the same rec group;
  • the clauses must be reciprocal: if obj names obj_desc as its descriptor, then obj_desc must describe obj;
  • a described type must be declared before its descriptor;
  • in a subtype hierarchy, if a supertype has a descriptor its subtypes must too (with a descriptor that is itself a subtype), and a described type is inherited covariantly.

Instructions

Wax instructions are expression-oriented.

Literals

A character literal is an i32 constant (its Unicode code point); a string literal builds an i8 or i16 array with array.new_fixed. An i8 array holds the raw UTF-8 bytes; an i16 array holds the UTF-16 code units (so the string must be valid Unicode).

WasmWax
i32.const <code point>'c'
array.new_fixed $t (constant elements)"..." or t # "..."

In WAT both forms are written with annotations ((@char …) and (@string …)), so they round-trip faithfully through WAT. A Wasm binary keeps only the underlying i32.const / array.new_fixed: a character decompiles to a plain integer, and an array.new_fixed is recovered as a string only when its elements, decoded by the array’s element type (UTF-8 for i8, UTF-16 for i16), form a reasonable string (valid text, no control characters other than tab/newline/carriage return); otherwise it stays an array literal.

Numeric Instructions

Binary and unary operations use standard mathematical operators. Signedness is often explicit in the operator.

WasmWax
i32.add / i64.add+
i32.sub / i64.sub-
i32.mul / i64.mul*
i32.div_s / i64.div_s/s
i32.div_u / i64.div_u/u
i32.rem_s / i64.rem_s%s
i32.rem_u / i64.rem_u%u
i32.and / i64.and&
i32.or / i64.or|
i32.xor / i64.xor^
i32.shl / i64.shl<<
i32.shr_s / i64.shr_s>>s
i32.shr_u / i64.shr_u>>u
i32.eqz / i64.eqz!x (logical not)

Floating Point Operations

WasmWax
f32.add / f64.add+
f32.sub / f64.sub-
f32.mul / f64.mul*
f32.div / f64.div/
f32.absf64.sqrtval.abs(), val.ceil(), val.floor(), val.trunc(), val.nearest(), val.sqrt()
f32.minf64.copysignv1.min(v2), v1.max(v2), v1.copysign(v2)

Advanced Integer Operations

WasmWax
i32.clzi64.popcntval.clz(), val.ctz(), val.popcnt()
i32.extend8_si64.extend16_sval.extend8_s(), val.extend16_s()
i32.rotli64.rotrv1.rotl(v2), v1.rotr(v2)

Wide Arithmetic

These 128-bit operations take and return their operands as (low, high) pairs of i64 values, so each is written as a call returning two results (typically bound with a multi-value let).

WasmWax
i64.add128i64::add128(a_lo, a_hi, b_lo, b_hi)
i64.sub128i64::sub128(a_lo, a_hi, b_lo, b_hi)
i64.mul_wide_si64::mul_wide_s(a, b)
i64.mul_wide_ui64::mul_wide_u(a, b)

Conversions

WasmWax
i32.wrap_i64val as i32
i64.extend_i32_sval as i64_s
i64.extend_i32_uval as i64_u
i32.trunc_f32_sval as i32_s
f32.convert_i32_sval as f32_s
f32.demote_f64val as f32
f64.promote_f32val as f64
i32.reinterpret_f32val.to_bits()
f32.reinterpret_i32val.from_bits()

Comparison

WasmWax
eq==
ne!=
lt_s / lt<s / <
lt_u<u
le_s / le<=s / <=
le_u<=u
gt_s / gt>s / >
gt_u>u
ge_s / ge>=s / >=
ge_u>=u

On reference operands (any subtype of &eq), == and != are reference equality: a == b is ref.eq, and a != b is ref.eq followed by i32.eqz (there is no ref.ne instruction).

Variable Instructions

WasmWax
local.get $xx
local.set $xx = val
local.tee $xx := val
global.get $gg
global.set $gg = val
(local $x t)let x : t

When an expression leaves several values on the stack (typically a call to a multi-result function) and a run of local.set instructions immediately stores them into locals, the two collapse into a single multi-binding let. A drop of one of the results becomes a _ binding:

(call $divmod ...)      let (q, r) = divmod(...);
(local.set $r)
(local.set $q)

The number of stores folded is exactly the producer’s result arity, so a local.set that consumes a value already on the stack below the call is left as its own assignment.

A compound assignment x op= val is shorthand for x = x op val; it lowers to a local.get/global.get of x, the operand, the binary operator, and a local.set/global.set back into x. The reverse direction recognises exactly this shape (a set of x whose value is a binary operator with x as its left operand) and reconstructs the compound form (x = x + 1 becomes x += 1). A comparison, or x in the right operand (x = 1 - x), is left as an ordinary assignment.

Control Instructions

WasmWax
blockdo { ... } or { ... }
looploop { ... }
loop + leading back-br idiomwhile cond { ... }
loop { if c { block 'l {...}; step; br } }'l: while c : (step) { ... } (continue-expression)
loop + trailing back-br_if idiomloop { ... br_if 'l cond; } (kept as a plain loop)
if ... else ...if cond { ... } else { ... }
br $lbr 'l
br_if $lbr_if 'l cond
br_table $l* $ldbr_table ['l* else 'ld] val
br_on_null $lbr_on_null 'l val
br_on_non_null $lbr_on_non_null 'l val
br_on_cast $l $t1 $t2br_on_cast 'l t2 val
br_on_cast_fail $l $t1 $t2br_on_cast_fail 'l t2 val
br_on_cast_desc_eq $l $t1 $t2br_on_cast 'l [?]descriptor(d) val
br_on_cast_desc_eq_fail $l $t1 $t2br_on_cast_fail 'l [?]descriptor(d) val
returnreturn val
call $ff(args)
call_ref $t(val as &?t)(args)
return_call $fbecome f(args)
return_call_ref $tbecome (val as &?t)(args)
unreachableunreachable
nopnop
selectcond ? v1 : v2
drop_ = val

A drop becomes an assignment to _, an anonymous binding that evaluates its value and discards it (see Discarding a value). When the dropped value is a bare numeric expression whose width the Wax syntax would not otherwise carry, the conversion pins it with a type annotation, _: t = val, so re-reading the Wax does not re-default the value to i32/f64 and silently change its width. The annotation is emitted only where it is load-bearing; a value already anchored by a typed sub-expression (a local, a call) needs none.

The rows above are the raw instructions. Two common shapes built from them are recovered as higher-level constructs on the way back to Wax: a br_table in the conventional dense void-switch shape becomes a dispatch, and a br_on_cast/br_on_null ladder becomes a match (both below).

Block Labels and Types

Blocks, loops, and ifs can be labeled and typed. Labels are identifiers starting with ' followed by a colon.

'my_block: do { ... }
'my_loop: loop { ... }

Block types (signatures) can be specified using (param) -> result syntax or a single value type. If no type is specified, the do keyword is optional.

do (i32) -> i32 { ... }
do i32 { ... }
{ ... }                  ;; equivalent to do { ... }
if cond => (i32) -> i32 { ... } else { ... }

A single result type can be inferred, so converting from WebAssembly drops it wherever it is redundant: a do i32 { ... } becomes do { ... }, an if cond => i32 { ... } loses its => i32, and likewise for loop, try, and try_table. The type is recovered when reading the Wax back, from the values reaching the block’s exit or from the surrounding context (see Type inference). Parameterized and multi-result block types are always kept.

Branch Hints

The branch-hinting proposal annotates a conditional branch as likely or unlikely taken. Wax writes the hint as an attribute on the branch and preserves it on every round-trip (it is stored in the metadata.code.branch_hint custom section; no feature flag is needed):

#[likely] if cond { ... } else { ... }
#[unlikely] br_if 'l cond

Any conditional branch may be hinted: if, br_if, br_on_null, br_on_non_null, br_on_cast, and br_on_cast_fail. In WAT the same hint is the (@metadata.code.branch_hint "\00"|"\01") annotation preceding the branch ("\01" = likely, "\00" = unlikely).

Dispatch and Match

Two Wax control constructs have no instruction of their own: they are readable spellings of a lower-level shape, so they lower to that shape and are recovered from it on decompilation (both round-trip through the binary). See the language guide for the surface syntax: dispatch and match.

dispatch is a jump table. It lowers to one nested block per case with a br_table in the innermost block and the case bodies (which fall through) after their blocks; a br_table matching that dense void-switch shape decompiles back to dispatch:

(block $big (block $b (block $a           dispatch x ['zero 'one else 'big] {
  (br_table $a $b $big (local.get $x)))     'big:  { … }
  … 'one body …) … 'big body …)             'one:  { … }
                                            'zero: { … } }

match is a multi-way type test. It lowers to a ladder of blocks threading the scrutinee through br_on_cast (and br_on_null for a null arm), each branch delivering the narrowed value out to its arm; such a ladder decompiles back to match:

(block $default (block $arm_1                match v {
  (block $arm (result (ref $point))            p: &point => { … }
    (br_on_null $arm_1                          null      => { … }
      (br_on_cast $arm eqref (ref $point) …)))  _         => { … } }
  … arm body …) … default …)

Reference Instructions

WasmWax
ref.nullnull
ref.func $ff
ref.is_null!val
ref.as_non_nullval!
ref.i31val as &i31
i31.get_sval as i32_s
i31.get_uval as i32_u
ref.castval as &type
ref.testval is &type
ref.cast (ref (exact $t))val as &!t
ref.test (ref (exact $t))val is &!t
ref.cast_desc_eq $tval as descriptor(d)
ref.cast_desc_eq (ref null …) $tval as ?descriptor(d)

A bare function name used as a value, f rather than the call f(args), is ref.func $f: it produces a reference to the function. This works in a global initializer and anywhere a &func reference is expected (for example the callee of an indirect call).

These casts can also chain through a forced intermediate, written as a single as:

  • &ref as i32_s/as i32_u on a reference that is not already &i31 (e.g. an &any or &eq) inserts a ref.cast (ref i31) before the i31.get, so it covers both i31.get_s and ref.cast (ref i31) + i31.get_s.
  • &ref as i64_s/as i64_u widens that further: the i31.get (with the ref.cast above as needed) is followed by i64.extend_i32_s/_u.
  • i64 as &i31 wraps to i32 (i32.wrap_i64) before ref.i31.
  • extern_val as &T for an any-hierarchy T (e.g. a struct type) inserts any.convert_extern before the ref.cast (ref T); likewise an any-hierarchy value as &extern uses extern.convert_any.
  • i32_val as &extern boxes the i32 with ref.i31 before extern.convert_any.

Aggregate Instructions

A struct or array literal takes an optional type prefix ({t| … } for a struct, [t| … ] for an array), but it is omitted whenever the literal’s type can be inferred from context (an assignment, a return, a call argument), which is the common case shown below. Write the prefix only when the type is ambiguous. The {descriptor(d)| … } forms are different: descriptor(d) supplies the runtime descriptor and is part of the construction, not a prefix that can be dropped.

WasmWax
struct.new $t{ field: val, ... }
struct.new_default $t{ .. }
struct.new_desc $t{descriptor(d)| field: val, ... }
struct.new_default_desc $t{descriptor(d)| .. }
struct.get $t $fval.field
struct.set $t $fval.field = new_val
ref.get_desc $tval.descriptor
array.new $t[ val; len ]
array.new_default $t[ ..; len ]
array.new_fixed $t[ val, ... ]
array.new_data $t $d[ d @ offset; count ]
array.new_elem $t $e[ e @ offset; count ]
array.init_data $t $darr.init(d, dest, src, count)
array.init_elem $t $earr.init(e, dest, src, count)
array.fill $tarr.fill(idx, val, count)
array.copy $t1 $t2arr.copy(idx, src_arr, src_idx, count)
array.get $tarr[idx]
array.get_s $tarr[idx] as i32_s
array.get_u $tarr[idx] as i32_u
array.set $tarr[idx] = val
array.lenarr.length()

A packed (i8/i16) struct field or array element read sign- or zero-extends to i32 via the as i32_s/as i32_u cast, as shown above (struct.get_s/_u, array.get_s/_u). Widening straight to i64 (val.field as i64_s or arr[idx] as i64_u) emits the packed read followed by i64.extend_i32_s/_u.

SIMD (Vector) Instructions

v128 vector operations are written as method intrinsics with the lane shape baked into the name. The Wax name is the WebAssembly mnemonic with the leading shape moved to the end: i32x4.add becomes add_i32x4, f32x4.sqrt becomes sqrt_f32x4, and the whole-vector v128.and becomes and_v128. Signed/unsigned variants keep the _s/_u (min_s_i8x16, extract_lane_u_i8x16). Constant lane immediates (lane indices, shuffle indices) come first in the argument list, before any remaining stack operands.

Lanewise unary and binary operations (the receiver is the first operand):

WasmWax
i32x4.adda.add_i32x4(b)
f32x4.mula.mul_f32x4(b)
i8x16.min_s / i8x16.min_ua.min_s_i8x16(b) / a.min_u_i8x16(b)
i16x8.lt_ua.lt_u_i16x8(b)
i32x4.negv.neg_i32x4()
f64x2.sqrtv.sqrt_f64x2()
i8x16.narrow_i16x8_sa.narrow_i16x8_s_i8x16(b)
f32x4.convert_i32x4_sv.convert_i32x4_s_f32x4()

Whole-vector bitwise operations, bit selection (a free function):

WasmWax
v128.and / or / xor / andnota.and_v128(b), a.or_v128(b), a.xor_v128(b), a.andnot_v128(b)
v128.notv.not_v128()
v128.bitselectv128::bitselect(a, b, mask)

Splat, lane access, and shuffle:

WasmWax
i32x4.splatx.splat_i32x4()
i32x4.extract_lanev.extract_lane_i32x4(lane)
i8x16.extract_lane_uv.extract_lane_u_i8x16(lane)
i32x4.replace_lanev.replace_lane_i32x4(lane, x)
i8x16.shufflea.shuffle_i8x16(l0, ..., l15, b)

Shifts, tests, and bitmask:

WasmWax
i16x8.shlv.shl_i16x8(n)
i32x4.shr_s / i32x4.shr_uv.shr_s_i32x4(n) / v.shr_u_i32x4(n)
v128.any_truev.any_true_v128()
i32x4.all_truev.all_true_i32x4()
i8x16.bitmaskv.bitmask_i8x16()

Constants are v128:: free functions (one argument per lane: 16, 8, 4, or 2). v128.const is a constant expression, so it may initialise a global:

WasmWax
v128.const i32x4 1 2 3 4v128::const_i32x4(1, 2, 3, 4)
v128.const f32x4 1.5 2.5 3.5 4.5v128::const_f32x4(1.5, 2.5, 3.5, 4.5)
v128.const i8x16 0 1 ... 15v128::const_i8x16(0, 1, ..., 15)

Memory loads and stores are methods on a memory, like the scalar memory accesses (the optional trailing align/offset arguments work the same way):

WasmWax
v128.loadm.v128_load(p)
v128.storem.v128_store(p, v)
v128.load8x8_s (and 16x4/32x2, _s/_u)m.v128_load8x8_s(p)
v128.load32_zero (and 64_zero)m.v128_load32_zero(p)
v128.load8_splat (and 16/32/64)m.v128_load8_splat(p)
v128.load8_lane (and 16/32/64)m.v128_load8_lane(p, v, lane)
v128.store8_lane (and 16/32/64)m.v128_store8_lane(p, v, lane)

Relaxed-SIMD operations follow the same scheme:

WasmWax
f32x4.relaxed_madda.relaxed_madd_f32x4(b, c)
i8x16.relaxed_swizzlea.relaxed_swizzle_i8x16(b)

No intrinsic can clash with a module entity name: the free-function intrinsics are written as v128::/i64:: qualified paths and every other is a method on a receiver, so a function may freely be named e.g. v128_bitselect without any renaming.

Memory Access

Loads and stores are method calls on a memory. The method name carries the access width; the value’s signedness (for narrow loads) and its i32/i64 type are expressed with the surrounding as iN_s/as iN_u cast, mirroring packed array access.

WasmWax
i32.loadm.load32(p)
i64.loadm.load64(p)
f32.load / f64.loadm.loadf32(p) / m.loadf64(p)
i32.load8_sm.load8(p) as i32_s
i32.load16_um.load16(p) as i32_u
i64.load32_sm.load32(p) as i64_s
i64.load8_sm.load8(p) as i64_s
i64.load16_um.load16(p) as i64_u
i32.store (v: i32) or i64.store32 (v: i64)m.store32(p, v)
i32.store16 or i64.store16 (by v’s type)m.store16(p, v)
i64.store / f64.storem.store64(p, v) / m.storef64(p, v)

A bare narrow load (m.load8(p) with no cast) defaults to unsigned i32, like array.get_u. The store value’s i32/i64 type is inferred from the operand.

Two optional trailing arguments give the align and offset of the access (both constant integers); offset requires align to fill its positional slot:

m.load32(p);          // i32.load
m.load32(p, 1);       // i32.load align=1
m.load32(p, 1, 16);   // i32.load align=1 offset=16

The alignment defaults to the access’s natural alignment and is only printed when it differs; the offset defaults to 0.

The remaining memory operations are also methods on the memory (a data segment is named directly, not as a value):

WasmWax
memory.sizem.size()
memory.growm.grow(n)
memory.fillm.fill(dest, val, len)
memory.copy (within m)m.copy(dest, src, len)
memory.copy m m2 (copy from m2 into m)m.copy(m2, dest, src, len)
memory.init segm.init(seg, dest, src, len)
data.drop segseg.drop()

Atomics

Atomic accesses (the threads proposal) are methods on a memory, named after the WebAssembly mnemonic with . rewritten as _ (the value type stays in the name). They require exactly their natural alignment. atomic.fence has no memory operand and is the atomic::fence() intrinsic.

WasmWax
i32.atomic.loadm.i32_atomic_load(p)
i64.atomic.store8m.i64_atomic_store8(p, v)
i32.atomic.rmw.addm.i32_atomic_rmw_add(p, v)
i64.atomic.rmw16.add_um.i64_atomic_rmw16_add_u(p, v)
i32.atomic.rmw.cmpxchgm.i32_atomic_rmw_cmpxchg(p, expected, replacement)
memory.atomic.notifym.atomic_notify(p, count)
memory.atomic.wait32m.atomic_wait32(p, expected, timeout)
atomic.fenceatomic::fence()

Table Access

A table is indexed like an array, and an indirect call is written as a call through a table slot cast to the callee’s function type.

WasmWax
table.get $tt[i]
table.set $tt[i] = v
call_indirect $t (type $ft)(t[i] as &?ft)(args)
call_indirect $t (result i32)(t[i] as &?fn() -> i32)(args)
return_call_indirect $t (type $ft)return (t[i] as &?ft)(args)

call_indirect is reconstructed from this pattern on conversion to WAT/Wasm, so it round-trips. The cast target names the callee’s function type, a defined type (ft) or an inline one written fn(params) -> results (used when the WAT type is anonymous), and matches the table’s element type: for a funcref table (the usual case) it is the nullable &?ft, as shown. When the element type is already a non-null &ft, the cast may be omitted (t[i](args)).

The other table operations are methods on the table (an element segment is named directly):

WasmWax
table.sizet.size()
table.growt.grow(val, n)
table.fillt.fill(dest, val, len)
table.copy (within t)t.copy(dest, src, len)
table.copy t t2 (copy from t2 into t)t.copy(t2, dest, src, len)
table.init segt.init(seg, dest, src, len)
elem.drop segseg.drop()

A GC array can also be filled from a segment with arr.init(seg, dest, src, len) (array.init_data/array.init_elem, selected by the array’s element type), the segment-from-array counterpart of array.new_data/array.new_elem.

Exception Instructions

WasmWax
throw $tagthrow tag (no payload), throw tag x (one), throw tag (x, y) (several)
throw_refthrow_ref
try_table ... catch $tag $l ...try { ... } catch [ tag -> 'l, ... ]
try_table ... catch_ref $tag $l ...try { ... } catch [ tag & -> 'l, ... ]
try_table ... catch_all $l ...try { ... } catch [ _ -> 'l, ... ]
try_table ... catch_all_ref $l ...try { ... } catch [ _ & -> 'l, ... ]
try ... catch $tag ...try { ... } catch { tag => { ... } ... }
try ... catch_all ...try { ... } catch { _ => { ... } }

The try { ... } catch [ ... ] syntax compiles to WebAssembly’s try_table instruction (the current standard). The try { ... } catch { tag => { ... } } syntax compiles to the older try/catch instructions (deprecated but still supported for compatibility).

Stack Switching Instructions

These correspond to the WebAssembly stack-switching proposal. A continuation type is declared with type k = cont ft; (see Types); <k> and <tag> below are the names of a continuation type and a tag respectively.

WasmWax
cont.new $kcont_new k (func)
cont.bind $k1 $k2cont_bind k1 k2 (args, cont)
suspend $tagsuspend tag (args)
resume $kresume k [] (args, cont)
resume $k (on $tag $l) ...resume k [ tag -> 'l, ... ] (args, cont)
resume $k (on $tag switch) ...resume k [ tag -> switch, ... ] (args, cont)
resume_throw $k $tag ...resume_throw k tag [ ... ] (args, cont)
resume_throw_ref $k ...resume_throw_ref k [ ... ] (exn, cont)
switch $k $tagswitch k tag (args, cont)

Operands are written in WebAssembly stack order, so the continuation reference is the last argument. The handler list in [ ... ] mirrors the try ... catch [ ... ] syntax: tag -> 'l is an (on $tag $l) clause and tag -> switch is an (on $tag switch) clause.

Tags used with suspend/resume may have result types (unlike exception tags); see Tags. When a function reference is passed to cont_new for a continuation whose function type belongs to a recursion group, declare the function with an explicit type (fn f: ft (...) { ... }) so its type matches the continuation’s.

Module Fields

Wax modules are defined by a sequence of top-level fields: types, functions, globals, memories, tables, and tags.

Module Name

A #![module = "..."] inner attribute at the top of the file names the module. It maps to the WebAssembly module name, the symbolic name stored in the module subsection of the name custom section, written $name in WAT:

#![module = "my_module"]

corresponds to the WAT

(module $my_module …)

A module may carry at most one name. See also Module Name in the language guide.

Types

Types are defined using type for single definitions or rec { ... } for mutually recursive types.

Simple Types

type point = { x: i32, y: i32 };
type bytes = [i8];
type callback = fn(i32) -> i32;

Recursive Types

Use rec when types reference each other:

rec {
    type list = { head: i32, tail: &?node };
    type node = { rest: &list };
}

Supertypes and Finality

Types are final (non-extensible) by default. Use open to allow subtypes:

type shape = open { x: i32, y: i32 };
type circle : shape = { x: i32, y: i32, radius: i32 };

Maps to:

(type $shape (sub (struct (field $x i32) (field $y i32))))
(type $circle (sub $shape (struct (field $x i32) (field $y i32) (field $radius i32))))

Functions

Definition

fn name(param: type, ...) -> return_type {
    body
}

Functions without a return type:

fn log_value(x: i32) {
    // no return
}

Function Signatures (Declarations)

Declare a function signature without a body (used with imports):

fn external_function(x: i32) -> i32;

An imported function can be marked exact so that referencing it yields an exact reference: fn g: !ft; for a named type, or fn h!(x: i32) -> i64; for an inline signature. This maps to the WAT (func (exact <type>)) import descriptor.

Block Type Annotation

Functions can have a block type on the body:

fn example() -> i32 'label: {
    // body can use 'label
}

Globals

Immutable Globals

const PI: f64 = 3.14159;
const MAX_SIZE: i32 = 1024;

Mutable Globals

let counter: i32 = 0;
let state: &?any = null;

Imported Globals

import "env" {
    const base: i32;
    let counter: i32;
}

Memories

A memory is declared with memory, followed by its address type (i32 or i64) and, optionally, its limits as [min] or [min, max] (in pages of 64 KiB):

memory mem0: i32 [1, 1000];
memory mem1: i64 [2];
memory mem2: i32;

Maps to:

(memory $mem0 i32 1 1000)
(memory $mem1 i64 2)
(memory $mem2 i32 ...)

When the limits are omitted, the minimum size is derived from the extent of the memory’s data segments (using their literal offsets).

A custom page size is written with a pagesize clause after the limits, mapping to the WAT (pagesize N) form. The page size must be 1 or 65536 (the default), and the limits are counted in pages of that size:

memory small: i32 [4096] pagesize 1;
(memory $small 4096 (pagesize 1))

A shared clause (the threads proposal) marks the memory shared; it must have a maximum size:

memory pool: i32 [1, 16] shared;
(memory $pool 1 16 shared)

Loads and stores use method-call syntax on the memory, with the value’s width in the method name and its signedness expressed by the surrounding as iN_s/as iN_u cast, the same convention as packed array access:

let x: i32 = mem0.load32(p);
let b: i32 = mem0.load8(p) as i32_u;
mem0.store16(p, v);
mem0.store32(p, v, 1, 16);     // align=1, offset=16

See Memory Access for the full instruction mapping.

Data Segments

A memory declaration may carry active data segments in a block, placed at a constant offset:

memory mem1: i64 {
    data _ @ [0x1000] = "hello world";
    data greeting @ [0x2000] = "hi";
}

Top-level data defines a passive segment, or an active segment for a named memory:

data seg = "raw\x00bytes";
data init @ mem0 [0] = "hello";

Data bytes are ordinary string literals; escapes such as \x41 and \x00 (two hex digits) decode to raw bytes.

Tables

A table is declared with table, followed by its element reference type and, optionally, its limits as [min] or [min, max]:

table funcs: &?func [1, 10];
table objs: &?any [0];

Maps to:

(table $funcs 1 10 funcref)
(table $objs 0 anyref)

A table element is read and written with index syntax, like an array: tab[i] is table.get and tab[i] = v is table.set:

let f: &?func = funcs[i];
funcs[i] = g;

There is no dedicated call_indirect syntax: it is written as a call through a table slot, narrowing the slot to the callee’s function type with a cast. WAT call_indirect round-trips through this form:

type cmp = fn(i32, i32) -> i32;

(funcs[i] as &cmp)(x, y)        // call_indirect $funcs (type $cmp)

The other table-management instructions have method syntax too: t.size(), t.grow(init, n), t.fill(dst, val, n), t.copy(dst, src, n), t.init(seg, dst, src, n), and seg.drop() on an element segment (see Instructions).

Element Segments

An element segment is declared with elem, its element reference type, and a bracketed list of constant element expressions. A bare elem is passive; @ table[offset] makes it an active segment that initializes a table:

elem dispatch: &?func = [handler_a, handler_b, handler_c];   // passive
elem init: &?func @ funcs[0] = [handler_a, handler_b];       // active
elem nums: &?i31 = [1 as &i31, 2 as &i31];                   // expression form

A passive element segment can initialize a GC array with array.new_elem, written with the same [t| seg @ off; count] syntax as array.new_data; which one applies is determined by the array’s element type (a reference element selects the element segment):

type handlers = [&?func];

let hs: &handlers = [handlers| dispatch @ 0; 3];

Tags

Tags define exception types for structured exception handling.

Definition

type string = [mut i8];

tag my_error(code: i32);
tag empty_error();
tag multi_arg(a: i32, b: &string);

Maps to:

(tag $my_error (param i32))
(tag $empty_error)
(tag $multi_arg (param i32) (param (ref $string)))

A tag may also declare a result type, in which case it is used as a suspension tag for stack switching rather than for exceptions:

tag yield(value: i32) -> i32;

Imported Tags

import "env" tag js_error(&?extern);

Attributes

Attributes modify module fields. They use the syntax #[name = value], or #[name] when no value is needed, and appear before the field they modify.

Export Attribute

Export a field with a given name:

#[export = "add"]
fn add(x: i32, y: i32) -> i32 { x + y; }

#[export = "PI"]
const PI: f64 = 3.14159;

#[export = "my_error"]
tag my_error(code: i32);

The name is optional: a bare #[export] exports the field under its own Wax name.

#[export]
fn add(x: i32, y: i32) -> i32 { x + y; }

Multiple exports can share the same function:

#[export = "add"]
#[export = "plus"]
fn add(x: i32, y: i32) -> i32 { x + y; }

Start Attribute

Mark a function to run at module instantiation. It takes no value and the function must have no parameters and no results (it maps to the Wasm start field):

#[start]
fn init() {
    // initialization code
}

Imports

Import fields from a host module with an import "module" { … } block; a lone import can use the one-line import "module" <declaration>; form. Each entry is imported under its own Wax name, unless a name-only #[import = "name"] overrides that.

import "env" {
    fn log(msg: i32);
    const memory_base: i32;
    #[import = "js_error"]
    tag error(code: i32);
}

import "env" fn trace(msg: i32);

Combined Import and Export

A field can be both imported and re-exported by adding #[export] to it:

import "env" #[export = "value"] const value: i32;

Conditional Annotations

Both formats can guard module fields by a condition that a downstream preprocessor evaluates. Wax uses #[if(...)] { ... } / #[else] { ... }, with the braces required around each branch; WAT uses the annotation form (@if <cond> (@then ...) (@else ...)).

#[if(ocaml_version >= (5, 1, 0))]
{
    const size: i32 = 16;
}
#[else]
{
    const size: i32 = 20;
}
(@if (>= $ocaml_version (5 1 0))
  (@then (global $size i32 (i32.const 16)))
  (@else (global $size i32 (i32.const 20))))

The conditions are equivalent; note the surface differences: Wax variables are bare (ocaml_version) while WAT variables are $-prefixed; Wax versions are tuples (5, 1, 0) while WAT writes them (5 1 0); Wax combines conditions with all/any/not, WAT with and/or/not.

The conditions are preserved, not evaluated. Type checking (--validate) explores each reachable combination independently (see the Language Guide). The two forms convert to each other, so a conditional written in Wax survives a round-trip through WAT and back.

Module Structure

A typical Wax module follows this structure:

// 1. Type definitions
type point = { x: i32, y: i32 };
type callback = fn(i32) -> i32;

// 2. Imported globals and functions
import "env" {
    fn log(value: i32);
    const base: i32;
}

// 3. Tags
tag my_error(code: i32);

// 4. Module globals
const FACTOR: i32 = 100;
let counter: i32 = 0;

// 5. Internal functions
fn helper(x: i32) -> i32 {
    x * FACTOR;
}

// 6. Exported functions
#[export = "compute"]
fn compute(x: i32) -> i32 {
    counter = counter + 1;
    log(counter);
    helper(x + base);
}

This order is conventional but not required; Wax allows fields in any order.

CLI Reference

The wax binary is the primary interface for the Wax toolchain. It supports conversion between Wax, WebAssembly Text (WAT), and WebAssembly Binary (Wasm) formats.

Usage

wax [OPTIONS] [INPUT]         # convert (the default command)
wax convert [OPTIONS] [INPUT] # the same, named explicitly
wax format [OPTIONS] FILE…    # reformat files
wax check [OPTIONS] FILE…     # validate files

By default wax converts between formats; this command is also available under its explicit name, wax convert (useful when an input filename could be mistaken for a subcommand). The format subcommand reformats files (see Formatting) and check validates them (see Checking).

Positional Arguments

  • [INPUT]: Source file to convert/format. Supported extensions: .wax, .wat, .wasm.
    • If omitted, wax reads from stdin.

Options

  • -o, --output FILE

    • Output file. Writes to stdout if not specified.
  • -f, --format, --output-format FORMAT

    • Specify the output format.
    • Values: wax, wat, wasm.
    • Default: wasm (if not auto-detected from output filename).
  • -i, --input-format FORMAT

    • Specify the input format.
    • Values: wax, wat, wasm.
    • Default: Auto-detected from input filename, or wax if reading from stdin.
  • -v, --validate

    • Force validation, and additionally report unused locals.
    • For Wax: Runs type checking.
    • For Wasm Text: Runs well-formedness checks.
    • A text input (Wax/WAT) is validated by default before it is converted to a different format: the conversion and lowering passes trust that their input is well-formed, so a malformed module is rejected up front rather than reaching them. A same-format conversion (watwat, waxwax) only re-prints and is not validated by default; a Wasm binary input is trusted and not validated. --validate forces validation in every case.
    • Reports a warning for any local that is declared but never read: a Wax let binding, or a Wasm Text (local …). This reporting is only enabled by --validate (the default validation above does not turn it on). Prefix the name with _ (e.g. let _x = …, (local $_x i32)) to mark it intentionally unused and silence the warning; function parameters are never reported. This unused-local warning can be silenced, kept, or promoted to an error with -W (e.g. -W unused-local=error).
    • For input containing conditional annotations (#[if] / (@if ...)), every reachable combination of conditions is checked independently, and each error is reported with the assumption (reachable when ...) under which it occurs.
  • -s, --strict-validate

    • Perform strict reference validation for Wasm Text input (overrides the default relaxed validation). Wasm binary input is always validated strictly, regardless of this flag. When compiling Wax or Wasm Text to a binary, functions referenced by ref.func only inside a function body are automatically declared (via a declarative element segment), so the output passes strict validation.
  • -D NAME[=VALUE], --define NAME[=VALUE]

    • Set a conditional-compilation variable, specializing #[if(...)] (Wax) and (@if ...) (WAT) annotations. A fully determined conditional is removed (its surviving branch is spliced in) and a partially determined one is kept with its condition simplified (the set variables substituted, the rest left). Comments inside a removed branch are dropped.
    • The value kind is inferred: a bare NAME (or NAME=true / NAME=false) sets a boolean; NAME=N.N.N (three integers) sets a version; any other NAME=VALUE sets a string.
    • Repeatable, to set several variables. Has no effect on Wasm binary input (which carries no conditionals).
  • -X NAME[=on|off], --feature NAME[=on|off]

    • Enable (or disable) an optional WebAssembly proposal, off by default. Bare NAME or NAME=on/true/yes enables it; NAME=off/false/no disables it. Repeatable. Known features:
      • custom-descriptors: exact reference types (&!t), descriptor/describes struct clauses, and the descriptor instructions. Without it these constructs are rejected during validation.
      • compact-import-section: group a module’s consecutive same-module imports under one module name in the binary import section. Gated on output only: it is emitted just when enabled, but the compact form is always accepted on input.
  • -W NAME=LEVEL, --warn NAME=LEVEL

    • Set the reporting level of a warning produced during validation.
    • NAME is a single warning, a group, or all:
      • unused-local (group unused): a local that is declared but never read. Produced while validating; shown by default.
      • unused-field (groups unused, correctness): a module field (a function or global) that is defined but never referenced, exported, or used as the start function. The module-level analog of unused-local; prefix its name with _ to silence one. Shown by default.
      • unused-import (groups unused, correctness): an imported function or global that is never referenced, exported, or used as the start function. Like unused-field, but for imports; _ silences one. Shown by default.
      • unused-label (groups unused, correctness): a block label that is declared but never branched to. Prefix its name with _ to silence one. Shown by default.
      • shift-count-overflow (group correctness): a shift by a constant count at least the operand’s bit width. Wasm masks the count modulo the width, so the shift is almost certainly not what was meant. Shown by default.
      • constant-trap (group correctness): an operation that always traps on a constant operand: an integer division or remainder by zero, or a trapping float-to-integer conversion of an out-of-range constant. Shown by default.
      • tautological-comparison (group correctness): a comparison whose result is constant regardless of its variable operand: an unsigned comparison against zero (x >=u 0, x <u 0), or two identical operands (x == x). Shown by default.
      • constant-condition (group correctness): a branch, loop, or select condition that is a constant literal (the idiomatic infinite loop while <nonzero> is not flagged). Shown by default.
      • unused-result (group correctness): the result of a side-effect-free expression is computed and then discarded, as in _ = x + 1. Covers reads, constants, pure arithmetic, and heap allocations (a discarded struct/array literal) whose operands are themselves effect-free. Shown by default.
      • dead-code (group correctness): a statement that can never be reached, following an unconditional branch, return, or unreachable. Shown by default.
      • cast-always-fails (group correctness): a reference cast or test whose operand can never have the target type (the two are unrelated in the type hierarchy), so the cast always traps and the test is always false. Shown by default.
      • eager-select (group correctness): a trapping or effectful operation in a branch of a ?: (which compiles to a select, evaluating both branches unconditionally), so it runs even when the condition selects the other branch. Shown by default. On Wasm input the check covers a folded select (its value operands are distinct subtrees); an unfolded select is not flagged.
      • precedence (group correctness): two operators whose relative precedence is easy to misremember are mixed without parentheses: a shift (<</>>) with arithmetic (+, -, …), or a comparison with a bitwise operator (&, |, ^). The code is correct, but a reader may misread the grouping (1 << nbits - 1 is 1 << (nbits - 1)). Shown by default. Wax-only: WAT/Wasm have no infix precedence.
      • redundant-operation (group redundant): an operation with no effect on its result: an arithmetic identity (x + 0, x * 1, x << 0, …), an absorbing operand (x * 0, x & 0), two identical operands (x - x, x ^ x), a self-assignment (x = x), or a cast to a type the operand already has. Hidden by default (these are common in generated code); enable with -W redundant=warning.
      • truncated-coverage: path-sensitive validation gave up after too many conditional configurations. Shown by default.
      • naming-conflict (group naming): converting from Wasm, a source name collided with another and was renamed (e.g. foofoo_2). Hidden by default.
      • reserved-word-rename (group naming): converting from Wasm, a source name is a Wax reserved word and was renamed (e.g. ifif_2). Hidden by default.
      • generated-name (group naming): converting from Wasm, an unnamed but referenced parameter was given a generated name (e.g. x), since it cannot be rendered anonymously. Hidden by default.
    • LEVEL is one of:
      • hidden: suppress the warning entirely.
      • warning: report it as a warning.
      • error: promote it to an error, so the run fails.
    • Repeatable; later settings override earlier ones. For example, -W all=error -W unused-local=warning makes every warning fatal except unused locals.
    • The validation warnings (truncated-coverage, and the unused and correctness lints) are produced only while validating. truncated-coverage can arise from the validation convert runs on a text input; the unused/correctness lints are turned on only by --validate, and always for check. The naming warnings are produced when converting a Wasm module to Wax.
    • Almost all of these lints apply to WebAssembly text/binary input as well as Wax: the Wasm validator runs the same checks, so wax check foo.wat reports them. (The exceptions are precedence, which is Wax-only since WAT/Wasm have no infix precedence, and eager-select, whose Wasm side covers only a folded select.) On Wasm input unused-result covers discarding a constant or a pure local.get/global.get read; for unused-label a numeric br N counts as a use of the label N levels out; cast-always-fails and redundant-operation reason about ref.cast/ref.test and constant operands directly from the bytecode. As with unused-local, they fire only when unused reporting is on (under check, or under a conversion with --validate).
    • The WAX_WARN environment variable sets default levels applied before the -W options. Its value is a list of NAME=LEVEL specs separated by commas or whitespace, e.g. WAX_WARN="correctness=hidden unused-local=error". Unlike a plain environment fallback, these defaults still apply when -W is given: the command line only refines them (so WAX_WARN=correctness=hidden with -W dead-code=warning hides the tier except dead code). A malformed or unknown entry is reported on stderr and skipped.
  • --color WHEN

    • Colorize output.
    • Values: always, never, auto.
    • Default: auto (colors enabled only if output is a TTY).
  • --fold

    • Fold instructions into nested S-expressions.
    • Affects WAT output (the default form is chosen automatically).
  • --unfold

    • Unfold instructions into flat instruction lists.
    • Affects WAT output (the default form is chosen automatically).
  • --desugar

    • Expand the Wax-specific (@string …) and (@char …) annotations into core WebAssembly (array.new_fixed / i32.const), so the output is plain WebAssembly text that other tools accept. An i8 string keeps its raw UTF-8 bytes; an i16 string is UTF-16-encoded; an untyped string reuses an existing (array (mut i8)) type when the module has one and otherwise pins a synthesised one. A module-level (@string …) global becomes an ordinary global.
    • Only valid with wat output (-f wat); requesting it for wasm or wax output is a usage error.
    • Fails (exit 128) if a conditional-compilation directive (@if …) remains unresolved; resolve them first with -D/--define.
  • --source-map-file FILE

    • Generate a source map file. Only valid with wasm output (-f wasm); requesting one for wat or wax output is an error.
  • --debug CATEGORY

    • Enable developer debug output for a category. Repeatable, and a single value may list several categories separated by commas (--debug timing,…).
    • Categories:
      • timing: log the wall-clock running time of each compiler pass (parse, specialize, validate, type-check, convert, output) to stderr, one line per pass as it finishes. The normal output on stdout is unchanged.

Examples

Convert a Wax file to Wasm binary:

wax input.wax -o output.wasm

Convert a Wasm Text file to Wax (decompilation):

wax input.wat -o output.wax

Format a Wax file (round-trip):

wax input.wax -f wax

Read from stdin and write to stdout:

cat input.wax | wax -f wasm > output.wasm

Specialize conditional compilation while converting:

wax input.wax -D debug=false -D ocaml_version=5.1.0 -D target=wasi -o output.wasm

Formatting

The format subcommand reformats files in their own format (.wat.wat, .wax.wax, .wasm.wasm), detected from each file’s extension. Formatting never validates: it only re-prints; use check to validate.

wax format [OPTIONS] FILE…

Options

  • -i, --inplace
    • Write the formatted output back to each input file.
    • Without this flag (and without --check), exactly one file must be given and its formatted output is written to stdout.
  • -c, --check
    • Write nothing; list the files that are not already formatted and exit with a non-zero status if any are found. Useful in CI. Cannot be combined with --inplace.
  • -f, --format, --input-format FORMAT
    • Treat all input files as this format (wat, wasm or wax), overriding the detection from each file’s extension.
  • -W NAME=LEVEL, --warn NAME=LEVEL: set a warning’s level, as above.
  • --color WHEN: as above (ignored when writing back in place).
  • --fold / --unfold: as above.
  • --debug CATEGORY: as above.

Examples

Format a single file to stdout:

wax format input.wat

Reformat several files in place:

wax format -i a.wax b.wax c.wax

Check formatting in CI (non-zero exit if any file differs):

wax format --check src/*.wax

Checking

The check subcommand validates files (type-checking for Wax, well-formedness for Wasm) without producing any output, reporting diagnostics and exiting with a non-zero status if any file fails. It takes one or more files. Unused-local warnings (see --validate) are reported too, but do not by themselves cause a non-zero exit, unless promoted to an error with -W (e.g. wax check -W unused-local=error src/*.wax).

wax check [OPTIONS] FILE…

Options

  • -f, --format, --input-format FORMAT: force the format of all files, overriding extension detection.
  • -s, --strict-validate: strict reference validation for Wasm Text, as above (Wasm binary is always strict).
  • -D NAME[=VALUE], --define NAME[=VALUE]: set a conditional-compilation variable, as for convert: the conditionals are specialized before validation. A full set validates one configuration; a partial set leaves the remaining conditionals for the path-sensitive check to explore. Repeatable.
  • -X NAME[=on|off], --feature NAME[=on|off]: enable or disable an optional proposal, as for convert. Repeatable.
  • -W NAME=LEVEL, --warn NAME=LEVEL: set a warning’s level, as above.
  • --color WHEN: as above.
  • --debug CATEGORY: as above.

Example

Type-check several Wax files (e.g. in CI):

wax check src/*.wax

Exit status

All three commands share the same exit codes:

CodeMeaning
0Success. For check / format --check, also means every file passed.
123A usage error: an invalid combination of flags (e.g. --source-map-file with text output, or binary output to a terminal), or a format --check run that found files needing formatting.
124A command-line parse error: an unknown flag or a bad option value (reported by the argument parser).
125An internal error (an uncaught exception).
128The input was rejected by a diagnostic: a parse, validation, or type error, or a malformed wasm binary. This is also the status of a check run that found any problem.

The distinction is how you used the tool (123/124) versus the input is bad (128). A rejected input reports 128 wherever the error is found (syntax, validation, or type-checking) so check exits 0 when every input is valid and 128 otherwise, which is what a CI gate wants. For scripting, treat any non-zero status as failure.

Shell completion

wax supports zsh, bash, and PowerShell completion: subcommands, flag names, flag values (--formatwat/wasm/wax, -W/-X names and values, --color, --debug), and input/output file paths.

Completion has two halves. The generic per-shell scripts ship with the cmdliner package; the wax package installs its own completion definitions (and man pages) alongside the binary. Installing wax through opam puts both in place. (These extra files are installed on Unix only; they are skipped on native Windows.)

Then hook the generic scripts into your shell once. For zsh, ensure the site-functions directory is on $fpath before compinit:

FPATH="$(opam var share)/zsh/site-functions:$FPATH"
autoload -Uz compinit && compinit

For bash, with bash-completion installed, point it at the cmdliner share directory. See the cmdliner completion guide for the per-shell details and the PowerShell setup.