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. You can try it in your browser on the Playground: type Wax, see the WAT and diagnostics live, with no install.

Installation

npm

The easiest way to get wax is from npm; it ships a single cross-platform WebAssembly build that runs on any Node 22+ (Linux, macOS, Windows):

npm install -g @wax-wasm/wax

Prebuilt binaries

Native wax executables for Linux, macOS (Apple silicon and Intel), and Windows (with SHA256SUMS) are attached to each release. Download the one for your platform, make it executable, and put it on your PATH:

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

The edge prerelease carries the same binaries built from the latest main, rebuilt on every push.

opam

wax is published on the opam repository, so if you have an OCaml toolchain it builds and installs with:

opam install 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

Wax works in Visual Studio Code through a dedicated extension, and in any editor with a Language Server Protocol client (Neovim, Emacs, Helix, and others) through the built-in wax lsp server paired with the tree-sitter-wax grammar. Both give the same features: diagnostics, hover, go to definition, find references, rename, completion, signature help, formatting, and more. See Editor Support for the details and per-editor setup.

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

Playground

Wax source
WAT output
Loading the Wax toolchain…

    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]
    Compilation hints#[freq = n] / #[never_opt] / #[always_opt], #[targets(f: 0.73)], #[priority = n] / #[optimization = n] / #[run_once]
    Custom page sizespagesize
    Extended name section$-identifiers for types, tables, memories, globals, data/element segments, fields, and labels survive the binary round-trip
    WAT numeric valuestyped numeric runs in data segments (data d = [i16: -1, 2] ++ [f32: 0.5, nan] ++ [v128: i32x4(1,2,3,4)];); runs survive the wax↔wat round-trip

    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-sectionwrites same-module imports under one module name in the binary import section. From a text input it lowers a import "m" { … } block / (import "m" (item …) …) group to a compact entry (a shared-type group when the items’ types match, else one type per item; a one-item block flattens; separate imports are never merged). From a binary input it coalesces runs of consecutive same-module plain imports. Groups written explicitly (in WAT, or already present in a binary) round-trip regardless of the flag

    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.)

    Features

    A module can declare the optional proposals it uses (the -X/--feature set, such as custom-descriptors or compact-import-section) with a #![feature = "..."] inner attribute at the top of the file, one feature per attribute:

    #![feature = "custom-descriptors"]
    
    rec {
        type obj = descriptor obj_desc { x: i32 };
        type obj_desc = describes obj { };
    }

    The file is then self-describing: it compiles, validates, and round-trips without every consumer passing -X, and the editor needs no configuration. The value is a feature name exactly as -X spells it; an unknown name is an error listing the known features. Repeating an attribute is allowed (declaring a feature is idempotent).

    The attribute states a fact (“this module uses X”), while an explicit -X x=off states a policy; neither silently overrides the other:

    • The file declares X and the command line is silent: X is enabled.
    • The file is silent and the command line passes -X x: X is enabled.
    • Both declare and enable X: X is enabled.
    • The file declares X but the command line passes -X x=off: this is an error, reported once at the attribute.

    (This maps to a module-level (@feature "...") annotation in WAT, and to a +name entry of the conventional target_features custom section in the binary; see Module Fields. Decompiling a binary restores the attribute from that section and from the gated encodings the module actually uses, so even a binary from a producer that wrote no section decompiles to a self-describing module.)

    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

    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;
    }

    Unlike Rust, a local is freely mutable: there is no let mut, and no immutable-local form. (mut exists in Wax, but it marks struct fields and array element types, not bindings; the immutable module-level form is const.)

    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).

    A let global that nothing ever assigns is reported by the unnecessary-mut warning, since it could be a const. Exported globals are exempt: the host may assign those.

    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

    Unlike Rust, ! on an integer is logical not, not bitwise complement: it yields 1 for 0 and 0 for any non-zero value (Wasm’s eqz), so !5 is 0, not -6. For a bitwise complement, write x ^ -1.

    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::i32x4(1, 2, 3, 4)         // a v128 constant (also a constant expression)
    v128::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 separated by ;. The final ; of a block is optional: a block’s last statement may omit it. Unlike Rust, writing that trailing ; does not discard the statement’s value — the value stays on the stack and becomes the block’s or function’s result either way (a trailing ; is just an empty statement, which contributes nothing). The formatter always writes the canonical trailing ;. 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 commas, and the mandatory final 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.

    Compilation Hints

    The compilation-hints proposal extends the same mechanism to profile data an engine can use to decide what to optimize. Two attributes carry it, and they prefix an expression rather than only a branch:

    #[freq = 16] 'l: loop {          // runs about 16 times per call
        #[never_opt] slow_path();    // never worth optimizing
    }
    
    #[targets(draw_rect: 0.73, draw_circle: 0.21)]
    shape.draw();                    // the indirect call's likely callees
    • #[freq = n] is how often the instruction runs per call of its function. #[never_opt] and #[always_opt] are the two reserved values. It is meaningful on a call or a control instruction.
    • #[targets(f: 0.73, …)] lists the likely callees of an indirect call, each with how often it is taken as a fraction of 1. The fractions must total at most 1; a shortfall says other, unlisted callees take the remainder. Naming a function here is not a use of it, so one reachable only through a target list is still reported by the unused-field lint.

    A hint attribute takes the whole expression that follows it. That makes the extent unambiguous, at the price of rejecting a placement where a reader could not tell what was meant:

    #[freq = 4] f(x);            // fine: the hint is on the call
    let y = #[freq = 4] f(x);    // fine: the initialiser is delimited
    (#[freq = 4] f(x)) + 1       // fine: parentheses say which operand
    #[freq = 4] f(x) + 1         // error: the hint lands on the `+`

    This is the rule Rust’s experimental stmt_expr_attributes uses. Several hints may stack on one instruction, and a hint on a block statement needs no trailing ;, as a plain block statement does not.

    The proposal’s third section is per-function, so it is an ordinary field attribute rather than an expression prefix:

    #[priority = 1] #[run_once]
    fn init() { /* compiled early, never optimized */ }
    
    #[priority = 5] #[optimization = 10]
    fn hot(x: i32) -> i32 { x }

    #[priority = n] says how soon to compile the function relative to the others, and is what makes the entry exist at all. The optimization priority is optional: #[optimization = n], or #[run_once] for the reserved value saying the function is expected to run once, so optimizing it is wasted work. A function states at most one of those two, and neither without #[priority]. It needs a body to attach to, so it is allowed on a defined function, not an imported one.

    Like branch hints, these are advisory and preserved across every conversion, 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::i32x4(1, 1, 1, 1);

    Memory accesses are methods on a memory, named like the scalar accesses with the access shape in the name (a widening load keeps its _s/_u suffix, as the value ops do); the full-width pair takes the family letter, loadv128/storev128 (like loadf32/storef32):

    memory buf: i32 [1];
    
    fn sum2(p: i32) -> v128 {
        let v = buf.loadv128(p);                // v128.load
        let w = buf.load32x2_s(p, offset: 16);  // v128.load32x2_s
        buf.storev128(p, v);                    // v128.store
        v.add_i64x2(w);
    }

    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, following the same naming convention as the plain loads and stores: the access width is in the method name, the i32/i64 value type comes from the operand and result types, and a narrow load returns raw bits, resolved by an as iN_u cast:

    memory mem: i32 [1, 2] shared;
    
    fn atomics(p: i32, v: i32, w: i64) -> i32 {
        _ = mem.atomic_load32(p);              // i32.atomic.load
        _ = mem.atomic_load8(p) as i32_u;      // i32.atomic.load8_u (zero-extend)
        _ = mem.atomic_load32(p) as i64_u;     // i64.atomic.load32_u
        mem.atomic_store16(p, v);              // i32.atomic.store16 (v is i32)
        mem.atomic_store16(p, w);              // i64.atomic.store16 (w is i64)
        _ = mem.atomic_rmw_add32(p, v);        // read-modify-write: the old value
        _ = mem.atomic_rmw_add16(p, w);        // i64.atomic.rmw16.add_u
        _ = mem.atomic_rmw_cmpxchg32(p, v, v); // compare-and-exchange
        _ = mem.atomic_wait32(p, v, w);        // wait: expected value and timeout
        mem.atomic_notify(p, 1);               // wake waiters: how many woke
    }

    A narrow atomic load zero-extends: there is no sign-extending form, so as iN_s is rejected on it (use as iN_u, then .extend8_s()/.extend16_s() if you need the sign; atomic_load32(p) as i64_s compiles as the plain i32.atomic.load followed by i64.extend_i32_s). An atomic access must use its natural alignment, the access width from the name. 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

    A segment’s contents may concatenate (with ++) string literals and numeric runs — a bracketed list [type: values] whose element type is stated once and whose values are packed little-endian — instead of hand-escaping every byte:

    data table = "hdr" ++ [f32: 0.2, 0.3, 0.4] ++ [i16: -1, -2] ++ [i8: 1, 2, 3, 4];

    The scalar element types are i8, i16, i32, i64, f32, and f64; values (including nan/inf) are ordinary literals. A v128 run holds lane groups written shape(lanes):

    data vectors = [v128: i32x4(1, 2, 3, 4), f64x2(1.0, 2.0)];

    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, offset: 16, align: 1);

    The optional offset and align of the access are labelled arguments (constant integers), written after the operands in either order.

    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 catch arm, compiling to WebAssembly’s standard try_table plus a block ladder. Like a block it may carry a result type (try i32 { … }). An arm enters with the caught tag’s payload on the operand stack, picked up with holes _; a tag & => { … } arm also delivers the exception reference (&exn) above the payload, and a bare _ => { … } (or _ & => { … }) matches any exception, grammar-enforced last. Leave the catch-all out to let unmatched exceptions propagate.

    Like dispatch and match, the arms are honest trailing code in clause order:

    • the body’s normal completion escapes past all arms, supplying the try’s value (one implicit branch);
    • an arm’s completion falls into the next arm — its completion stack must match that arm’s entry (payload, plus &exn for a & arm) — and the last arm’s completion supplies the try’s value;
    • diverging arms (throw, return, become, a br out) are exempt, as usual.

    An early arm that produces the result escapes explicitly through a label on the try: the label is the join, so branching to it exits the try carrying the value, block-like. (In expression position no label can prefix the try, so such an arm must diverge, or the code restructures.)

    fn lookup(k: i32) -> i32 {
        't: try i32 {
            find(k);                     // may throw `overflow` or `timeout`
        } catch {
            overflow => { br 't _; }     // `_` is the thrown i32 payload
            timeout & => { _ = _; br 't (0 - 1); }  // drop the &exn, escape
            _ => { 0; }                  // catch-all supplies the value
        }
    }

    The fall-through makes the normalize, then handle idiom direct — the first arm’s completion is the next arm’s payload, with no re-throw:

    let res: &eq = try &eq {
        apply(f);
    } catch {
        javascript_exception => { wrap_exception(_); }  // normalize; falls through
        ocaml_exception => { _; }                       // handle either
    };

    Branching handlers (try_table)

    The low-level spelling branches to a label instead of running an inline arm, mapping one-to-one to a raw 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. Braced arms are the same clauses with the target blocks written inline.

    Legacy exceptions (try_legacy)

    try_legacy compiles to the deprecated try/catch instructions, kept for targets that still use the legacy exception proposal. Its arms have the old semantics — each arm independently produces the try’s result (no fall-through, no & forms):

    try_legacy i32 { find(k); } catch { overflow => { _; } _ => { 0; } }

    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
    }

    A continuation is created with the T::new constructor of its declared type (the T:: namespace constructs a &T, extending the v128::/i64:: qualified-intrinsic pattern), and driven with the resume, resume_throw and resume_throw_ref methods on the continuation reference. T::bind binds leading arguments away, yielding a &T; its source type, like the type immediate of the resume methods, is inferred from the continuation operand’s static type — the call_ref model, so the receiver must have a declared continuation type.

    Continuations carry no runtime type information, so as with a continuation target is a compile-time ascription, not a cast: it is accepted exactly when it is a provable no-op — the operand’s type is already a subtype of the target ((c as &k0).resume(x) resumes through the supertype signature, selecting the resume $k0 immediate), a null literal with a nullable target, or dead code the ascription pins — and it compiles to no instruction. A &cont can never be narrowed: an abstract reference cannot be resumed, and no cast can fix it, so give the value its precise type where it is introduced (a parameter, local or block-result annotation). is and br_on_cast reject continuation targets outright (they are inherently runtime tests).

    type unit_task = fn() -> i32;
    type k0 = cont unit_task;
    
    fn spawn() -> &k { k::new(worker); }        // wrap `worker` (a task)
    fn prime(c: &k) -> &k0 { k0::bind(7, c); }  // bind the argument
    fn go(c: &k0) -> i32 { c.resume(); }        // run until it suspends

    Handlers routing a suspended tag to a label are a postfix on clause on the call, a bracket of tag -> 'label or tag -> switch arms (the same shape and placement as try { … } catch [t -> 'l]), omitted when empty:

    c.resume(x) on [yield -> 'on_yield]

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

    c.switch(args, tag: t) hands control straight to another continuation c instead of suspending back to a handler, passing args and the current continuation; the enabling tag is the required labelled tag: immediate, and the resume that drives things enables it with a t -> switch arm in its handler list.

    The receiver of these methods compiles last: operands compile in the instruction’s stack order, which for continuations — as for call_ref — puts the receiver after the arguments.

    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.

    Not every position accepts a hole. The scrutinee of a match, the index of a dispatch, and the condition of a while cannot be a hole. Each of these desugars to a nested block structure, and a hole inside a block draws only from that block’s own stack, not from the values pending in the enclosing sequence, so there is nothing there for it to pick up:

    fn bad(x: &any) {
        x; match _ { … }    // Error: A hole '_' cannot be used as a 'match' scrutinee.
    }

    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  // 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 arms (payload via hole _)
    try_legacy t { } catch { oops => { _; } }        // deprecated try/catch instructions
    throw oops(42);                                   // throw
    try { } catch [oops -> 'h]                       // branch-to-label form (try_table)
    type k = cont ft;                                // continuation type
    let c = k::new(f);                               // cont.new (k::bind binds args)
    suspend yield(x);   c.resume(args) on [tag -> 'l];    // suspend / resume

    Examples

    Complete examples demonstrating Wax features and their WebAssembly equivalents.

    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 {
        try i32 {
            safe_divide(a, b);
        } catch {
            divide_by_zero => { 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);
    }

    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))

    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)

    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;
    }

    Data Segments with Numeric Values

    A data segment’s contents can mix string literals with typed numeric runs — [type: values], packed little-endian — instead of hand-escaping every byte.

    Wax

    memory mem: i32 [1];
    
    // "GIF89a" header, then a 4-lane f32 palette and two i16 dimensions.
    data header @ mem[0] =
        "GIF89a"
        ++ [f32: 1.0, 0.5, 0.25, 0.0]
        ++ [i16: 640, 480];

    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);
    }

    Feature Declaration

    A #![feature = "..."] inner attribute declares an optional proposal the module uses, so it compiles and validates with no -X flag.

    Wax

    #![feature = "custom-descriptors"]
    
    rec {
        type obj = descriptor obj_desc { x: i32 };
        type obj_desc = describes obj { };
    }

    Equivalent WAT

    (module
      (@feature "custom-descriptors")
      (rec
        (type $obj (descriptor $obj_desc) (struct (field $x i32)))
        (type $obj_desc (describes $obj) (struct))))

    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

    #![feature = "custom-descriptors"]
    
    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;
        }
    }

    Compilation hints

    The compilation-hints proposal carries profile data: how often an instruction runs, and which callees an indirect call usually reaches. These attributes prefix an expression, and take the whole of it, so a call can be hinted too.

    Wax

    type shape_fn = fn(i32) -> i32;
    
    fn draw_rect(x: i32) -> i32 { x }
    
    fn draw_circle(x: i32) -> i32 { 0 - x }
    
    #[export = "render"]
    fn render(shape: &?shape_fn, n: i32) -> i32 {
        let total = 0;
        #[freq = 64]
        'l: loop {
            total = total + (#[targets(draw_rect: 0.73, draw_circle: 0.21)] shape(n));
            n = n - 1;
            #[likely] br_if 'l n;
        }
        total
    }
    
    #[export = "cold"]
    fn cold(x: i32) -> i32 {
        #[never_opt] draw_rect(x)
    }

    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 = k::new(worker);
        let (v, rest) =
            'on_yield: do () -> (i32, &kr) {
                _ = c!.resume() on [yield -> 'on_yield];  // 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. Decompiling a module and recompiling it reproduces the original; see Round-Tripping for what is preserved exactly and the few inert divergences.

    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 ['l1, 'l2, 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).

    The compilation-hints proposal adds two more sections of the same family, addressed the same way — by the instruction’s byte offset from the start of the function body — and likewise preserved with no feature flag:

    WaxWATSection
    #[freq = n](@metadata.code.instr_freq (freq n))metadata.code.instr_freq
    #[never_opt](@metadata.code.instr_freq (never_opt))
    #[always_opt](@metadata.code.instr_freq (always_opt))
    #[targets(f: 0.73)](@metadata.code.call_targets (target $f 0.73))metadata.code.call_targets
    #[priority = n](@metadata.code.compilation_priority (priority n))metadata.code.compilation_priority
    #[optimization = n]… (optimization n)
    #[run_once]… (run_once)

    instr_freq is one byte: an offset base-2 logarithm of the executions-per-call ratio, max 1 (min 64 (floor (log2 r) + 32)), so 32 means once. 0 is never_opt and 127 is always_opt. A byte outside {0, 127} ∪ [1, 64] — only reachable from a hand-written binary — stands for no ratio, so WAT prints it in the raw-byte form (@metadata.code.instr_freq "\NN"), which Wax has no spelling for. Both text forms are accepted on input, per the proposal.

    call_targets holds LEB128 (function index, percentage) pairs. Both text surfaces write the frequency as a fraction of 1 and store the whole percent, so the round-trip is exact. Only the structured (target …) form can name a target; the raw-byte form’s indices are numeric.

    compilation_priority is the one section of the family that addresses a function rather than an instruction, so its entries are keyed at offset 0. Its payload is a compilation priority, optionally followed by an optimization priority; 127 is the “runs once” value that prints as (run_once). (The proposal’s prose gives 127; its own worked example renders the value as 31. We follow the prose, and a binary carrying 31 round-trips as the plain number it is.) A trailing byte beyond what is required is read past and ignored, per the proposal’s forward-compatibility rule.

    Each section is emitted before the code section, since its offsets are only known once the bodies are encoded. A hint belongs to the operation itself, never to a folded group around it: the opcode is emitted after the folded operands, so that is where the offset is taken and where a decoder puts the hint back.

    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)
    (no instruction)val as &k (k a continuation type: a compile-time ascription)

    There is no ref.cast (or ref.test/br_on_cast) to a continuation type: as with a continuation target is a compile-time ascription, accepted only when the operand’s static type is already a subtype of the target (or the operand is a null literal / stack-polymorphic), and it emits nothing — its use is pinning the type immediate of a resume through a supertype signature.

    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). The constant lane immediates of the value-receiver operations (lane indices, shuffle indices) come first in the argument list, before any remaining stack operands; the lane index of a memory lane access is instead the labelled lane: argument (see the loads and stores below).

    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::i32x4(1, 2, 3, 4)
    v128.const f32x4 1.5 2.5 3.5 4.5v128::f32x4(1.5, 2.5, 3.5, 4.5)
    v128.const i8x16 0 1 ... 15v128::i8x16(0, 1, ..., 15)

    Memory loads and stores are methods on a memory, like the scalar memory accesses (the optional labelled offset:/align: arguments work the same way): the v128. mnemonic prefix is dropped and the access shape stays in the name, _s/_u included; the full-width pair takes the family letter, loadv128/storev128 (extending the loadf32 convention). The lane index of a lane access is the labelled lane: argument, mandatory and in any order with offset:/align::

    WasmWax
    v128.loadm.loadv128(p)
    v128.storem.storev128(p, v)
    v128.load8x8_s (and 16x4/32x2, _s/_u)m.load8x8_s(p)
    v128.load32_zero (and 64_zero)m.load32_zero(p)
    v128.load8_splat (and 16/32/64)m.load8_splat(p)
    v128.load8_lane (and 16/32/64)m.load8_lane(p, v, lane: l)
    v128.store8_lane (and 16/32/64)m.store8_lane(p, v, lane: l)

    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.

    The offset and align of the access (both constant integers) are optional labelled arguments, written after the operands in either order:

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

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

    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, following the scalar-access naming convention: the access width is in the method name (atomic_load16, atomic_rmw_add8), the i32/i64 value type comes from the operand and result types, and a narrow load is resolved by an as iN_u cast. The narrow accesses are the zero-extending _u forms, the only kind that exists, so no suffix or cast signedness choice arises (as iN_s on a narrow atomic load is rejected; atomic_load32(p) as i64_s has no fused form and compiles as i32.atomic.load then i64.extend_i32_s). They take the same labelled offset: argument as the other accesses; an align: argument is accepted but must be exactly the natural alignment (the access width from the name), which is also the default. atomic.fence has no memory operand and is the atomic::fence() intrinsic.

    WasmWax
    i32.atomic.loadm.atomic_load32(p)
    i64.atomic.loadm.atomic_load64(p)
    i32.atomic.load8_um.atomic_load8(p) as i32_u
    i64.atomic.load32_um.atomic_load32(p) as i64_u
    i32.atomic.store (v: i32) or i64.atomic.store32 (v: i64)m.atomic_store32(p, v)
    i64.atomic.store8 (v: i64)m.atomic_store8(p, v)
    i32.atomic.rmw.add (v: i32)m.atomic_rmw_add32(p, v)
    i64.atomic.rmw16.add_u (v: i64)m.atomic_rmw_add16(p, v)
    i32.atomic.rmw.cmpxchgm.atomic_rmw_cmpxchg32(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_table + block laddertry { ... } catch { tag => { ... } tag & => { ... } _ => { ... } }
    try ... catch $tag ...try_legacy { ... } catch { tag => { ... } ... }
    try ... catch_all ...try_legacy { ... } catch { _ => { ... } }

    Both try forms compile to WebAssembly’s try_table instruction (the current standard): the bracket form is the raw instruction (each clause branches to a label), and the braced structured form adds one block per arm around it — each catch clause branches to its arm’s block, the arm bodies are the trailing code between the block ends (so an arm’s completion falls into the next arm), and the body’s completion escapes past all arms with a single branch carrying the try’s value. The decompiler recovers the structured form from exactly that ladder shape (a label on the try is the join block), falling back to the bracket form for any other use of try_table. try_legacy compiles to the older try/catch instructions (deprecated but still supported for compatibility), and legacy-instruction modules decompile to it.

    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.

    The resume family and switch are methods on the continuation reference c; cont.new and cont.bind are the T::new / T::bind constructors of the declared continuation type (the T:: namespace constructs a &T). The type immediates are not written: the $k of resume/resume_throw/resume_throw_ref/switch, and the source type of bind, are inferred from the static type of the continuation expression, exactly as call_ref’s type immediate comes from the callee’s — so the receiver must have a declared continuation type (an abstract &cont is rejected; a cast can never narrow a continuation, so give the value its precise type where it is introduced). An identity/upcast ascription (c as &k0).resume(x) selects the supertype’s immediate (resume $k0) and emits no instruction; decompilation inserts exactly this ascription when an instruction’s type immediate is a strict supertype of its continuation operand’s own type, so the immediate survives the round trip.

    WasmWax
    cont.new $kk::new(func)
    cont.bind $k1 $k2k2::bind(args, cont)
    suspend $tagsuspend tag(args)
    resume $kc.resume(args)
    resume $k (on $tag $l) ...c.resume(args) on [tag -> 'l, ...]
    resume $k (on $tag switch) ...c.resume(args) on [tag -> switch, ...]
    resume_throw $k $tag ...c.resume_throw(tag(payload)) on [...]
    resume_throw_ref $k ...c.resume_throw_ref(exn) on [...]
    switch $k $tagc.switch(args, tag: tag)

    Operands compile in WebAssembly stack order: for continuations — as for call_ref — that puts the receiver last, so c.resume(x) evaluates x before c even though c is written first (this diverges from receiver-first methods like arr.fill, whose instruction takes the receiver first on the stack). The handler list of a postfix on [...] clause mirrors the try ... catch [...] syntax — a keyword-led handler bracket after the guarded operation: tag -> 'l is an (on $tag $l) clause and tag -> switch an (on $tag switch) clause; the clause is omitted when empty. resume_throw raises its tag applied to the payload, tag(payload), exactly as throw tag(payload) spells it, and switch’s enabling tag is the required labelled tag: immediate.

    Tags used with suspend/resume may have result types (unlike exception tags); see Tags. When a function reference is passed to T::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, and it must not appear inside a conditional (#[if]/#[else]): the name applies in every configuration. See also Module Name in the language guide.

    Feature Declarations

    A #![feature = "..."] inner attribute declares an optional proposal the module uses (see Features in the language guide). It maps to a module-level (@feature "...") annotation in WAT:

    #![feature = "custom-descriptors"]

    corresponds to the WAT

    (@feature "custom-descriptors")

    Both are read on input and emitted on output, so the declaration survives a Wax/WAT round-trip. In the binary format, each declared feature becomes a +name entry of the conventional target_features custom section (tool-conventions):

    (@custom "target_features" "\01+\12custom-descriptors")
    

    Other producers’ entries in that section (standard names like +simd128, or - entries) are preserved verbatim through a binary round-trip but do not become attributes. Decompiling a binary restores the declarations from the union of the section’s recognised + entries and the gated encodings the module actually uses (a descriptor type, an exact reference, a compact import section, …), so even a binary whose producer wrote no section decompiles to a module that recompiles standalone.

    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, offset: 16, align: 1);

    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.

    A segment’s contents may also concatenate (with ++) strings and typed numeric runs[type: values], whose element type is stated once and whose values are packed little-endian. This is the WebAssembly numeric values text-format extension, and it round-trips through WAT:

    data pixels = "hdr" ++ [f32: 0.2, 0.3, 0.4] ++ [i16: -1, -2, -3] ++ [i8: 1, 2, 3, 4];

    The scalar element types are i8, i16, i32, i64, f32, and f64; the values (including nan/inf) are ordinary literals. A v128 run holds lane groups written shape(lanes), e.g. [v128: i32x4(1, 2, 3, 4), f64x2(1.0, 2.0)]. The bytes are the same as if the values were written out as an escaped string.

    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
    }

    An imported function can also be the start function, running host-provided initialization at instantiation:

    import "env" #[start] fn init();

    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.

    Round-Tripping

    Decompiling a Wasm module to Wax and recompiling it (wasm → wax → wasm) reproduces the instruction stream opcode-for-opcode, dead code included. The type section is preserved as well: structurally identical types stay distinct and keep their order and numbering. The rest of this page lists the places where the round trip diverges. Every divergence is semantically inert: the recompiled module behaves identically, it just is not byte-for-byte identical.

    Divergences

    1. Locals and names. Local order and numbering are not preserved; every use is rewritten consistently. The name section is rewritten.

    2. eq then eqz fuses to ne. A t.eq followed by i32.eqz decompiles to a != b, which recompiles as a single t.ne. (--faithful keeps the pair.)

    3. Flat cast-dispatch chains are canonicalised. A flat run of br_on_cast_fail blocks is recovered as a match, which re-lowers to the nested br_on_cast ladder rather than the flat chain. The ladder shape itself round-trips byte-for-byte. (--faithful keeps the flat chain.)

    4. Redundant ref.cast erased (best-effort). A ref.cast decompiles to an as ascription, which is dropped when the operand’s inferred type is already a subtype of the target (an identity cast or an upcast), so it recompiles to no instruction. A genuine downcast is kept and re-emits ref.cast. The decompiler also inserts scaffolding casts of its own (a member-access receiver, a call_ref callee) to nullable reference types, and these are indistinguishable from a redundant source cast to a nullable type, so cast keeping is best-effort. A few load-bearing ascriptions (a width pin, a bottom-reference type name, a continuation type immediate) are never dropped.

    5. A typed select may lose its immediate. The value type is always preserved through the arms, but the explicit (result t) immediate form is not guaranteed to be reproduced.

    6. Shared spellings. A few distinct opcode streams share a single Wax spelling, so the recompiler picks one canonical encoding for both:

      • i64.extend32_s is spelled (x as i32) as i64_s, the same wrap-then-sign-extend pair a hand-written i32.wrap_i64; i64.extend_i32_s produces, and both recompile to i64.extend32_s.
      • A 32-bit load widened to i64. i32.load; i64.extend_i32_u and the fused i64.load32_u are both m.load32(x) as i64_u and recompile to i64.load32_u. The atomic analogue behaves the same: i32.atomic.load; i64.extend_i32_u and i64.atomic.load32_u are both m.atomic_load32(x) as i64_u. (The 8- and 16-bit narrow loads carry an inner as i32_s, so the pair and the fused load stay distinct; only the zero-extending _u load32 fuses.)
      • The call family. call and call_indirect desugar through call_ref (via a ref.func or a table access) and are recovered by pattern; a shape that cannot re-fuse recompiles to the desugared call_ref form.
      • Float negation of a literal. f32.neg/f64.neg of a constant folds into the negated literal (Wax spells a negative float literal -X), so both const -X and const X; neg recompile the same way.
      • Empty else. An if with an empty else branch decompiles to a branchless if, which recompiles without the else.
    7. Types may become more precise. A declared type wider than the value it holds tightens to the value’s precise type across a round trip: the decompiler infers each binding’s type from its content and drops the wider annotation, and the recompiler re-infers the precise type. The module still validates (the precise type is a subtype of the original) and behaves identically. This affects a local or a const global declared at a supertype but only ever assigned a narrower value, a block result type declared wider than its body when no consumer pins the wider type, and a br_on_cast’s source-type immediate re-inferred from its operand.

    Faithful round-tripping

    The --faithful flag (wax output only) preserves the reachable instruction structure the reshaping recoveries would otherwise change. It keeps the eq; eqz pair rather than fusing to t.ne (divergence 2), keeps a flat br_on_cast_fail chain rather than recovering the br_on_cast ladder (divergence 3), and keeps a redundant non-null ref.cast as an as ascription (divergence 4). The other divergences are inert normalisations it does not gate.

    How this is checked

    These guarantees are enforced by a differential fuzzing harness that compares per-opcode histograms across a round trip, over both a curated corpus and a wasm-smith campaign.

    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
    wax lsp                       # run the language server (over stdin/stdout)
    

    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), check validates them (see Checking), and lsp starts a Language Server Protocol server for editors (see Language server).

    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: wax (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: write same-module imports under one module name in the binary import section. What the flag enables depends on the input, because the text form is authoritative for import layout. From a text input (Wax or WAT), a import "m" { … } block / (import "m" (item …) …) group is lowered to a compact entry — a name-only group sharing one type when the items’ types all match, else one type per item; a Wax block of one item flattens to a plain import, and imports the source left separate are never merged. From a WASM binary — which has no authorial text layout — the flag instead coalesces runs of consecutive same-module plain imports (the “compress this binary” mode). Groups written explicitly, or already present in a binary, are preserved through WAT↔WAT and WASM↔WASM round-trips on their own, no flag needed.
      • A module can also declare the features it uses itself, with a #![feature = "NAME"] inner attribute (WAT: a module-level (@feature "NAME") annotation); see Features. The declared and enabled sets are unioned. The one exception is a conflict: an explicit -X NAME=off against a module that declares NAME is an error, reported at the attribute, since the file states a fact and the flag states a policy and neither silently wins.
      • A binary persists the declarations as +NAME entries of the conventional target_features custom section, one entry per declared feature; other producers’ entries there are preserved verbatim and not interpreted. Decompiling a binary restores the attributes from the union of those entries and the gated encodings the module actually uses.
    • -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. Carries a quick-fix edit that inserts a _ at the name’s start.

        • unused-field (groups unused, correctness): a module field that nothing reachable references — a function, global, memory, table, tag, type, or a passive data or element segment. An active segment (and a declarative one) runs at instantiation, so it counts as used whether or not an instruction names it; only a passive segment, reachable solely through memory.init/table.init and data.drop/elem.drop, can be dead. The module-level analog of unused-local; prefix its name with _ to silence one. Shown by default.

          Liveness is reachability from the roots, not the mere presence of a reference, so a dead cycle is caught: two functions that only call each other reference one another, yet neither can ever run. The roots are the functions reachable from outside — exported, or the start function — and every reference made from a module-level context, which runs at instantiation: a global or table initializer, an element or data segment. A declarative element segment is the exception, since it installs nothing and runs nothing: it exists so that a ref.func elsewhere validates, so the function it names is reachable exactly when that other ref.func is. Liveness then follows calls, and taking a function reference counts as calling it, since where the reference ends up is not tracked — so the analysis never reports a function that might run. A field that only dead code references is dead in turn: a global read solely from an unreachable function is reported. One consequence worth knowing: in a module that exports nothing and has no start function, nothing can run, so every field is reported.

          Types take part in the same analysis, with one extra kind of edge: a type definition’s own components reference types (its supertype, a field or element type, a descriptor clause), and those only count if the definition itself is live. So a rec group nothing outside it names is dead as a whole, however much its members name each other, and a type named only by an unreachable function’s signature or body is dead too. Only definitions written in the source are candidates: the implicit function types interned for an inline block or call_indirect signature are not reported.

          A type is used wherever the source names it, including the positions whose reference the compiled module carries implicitly: a table’s element type, an imported function’s signature, and the array type a string literal builds (a bare string names no type, so a like-typed definition it is compiled onto counts as used). The converse also holds, and is why a Wax module can report a type its compiled form references: type t = fn(); #[export] const g = f; names t nowhere, so it is reported, even though the global’s lowered type (ref $t) points at the definition (a reference type can only name a type). Deleting it recompiles: the lowering then interns an anonymous function type in its place.

        • unused-import (groups unused, correctness): an imported function, global, memory, table, or tag that nothing reachable references. Like unused-field — same reachability analysis — 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. On the Wax side it carries a quick-fix edit that deletes the whole 'name: prefix.

        • unnecessary-mut (group redundant): a global declared mutable but never assigned, so it could be immutable — Wax const instead of let, Wasm without mut. Only a module-defined, non-exported global is flagged: an import’s mutability is part of the linking contract, and an exported mutable global may be assigned by the host. A _ prefix silences one, and a global that is not used at all is reported as unused-field instead, so a declaration never draws both. Shown by default (unlike the rest of the redundant group).

        • 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. In a chain only the innermost such cast is reported: an outer cast or test over a value that can never be produced says nothing the inner verdict does not already, and the fix belongs at the inner cast.

        • 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. Carries a quick-fix edit that wraps the tighter-binding sub-expression in parentheses.

        • 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.

        • confusable-unicode (group correctness): a string the module carries — an export/import name, a string literal, a data segment, or a conditional string — contains a “Trojan Source” bidirectional control character (U+202A/B/D/E, U+2066–2069, U+206C) that can make the displayed source read differently than it runs. Shown by default, reported on Wax, WAT and Wasm input alike.

        • 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.

        • compound-assignment (group suggestion): a plain assignment x = x op e can be written with the compound operator x op= e (arithmetic and bitwise operators only; x must be the operator’s first operand, so x = e - x is left alone). Hidden by default.

        • field-punning (group suggestion): a struct field initialised from the like-named local or global, {x: x}, can use the punning shorthand {x}. Hidden by default.

        • redundant-annotation (group suggestion): a type the inferred type already makes redundant and so can be dropped: a let annotation (let x: t = elet x = e, including the anonymous _: t = e and each binding of a tuple let (a: t, b) = e), a module-level global annotation (let counter: i32 = 0let counter = 0, likewise for a const), a construction type name ({T| …}{…}), a block result type (do t { … }do { … }), or an if result type (if c => t { … }if c { … }). Hidden by default.

      • The suggestion group is reported at a distinct Suggestion severity (not a warning), and each entry carries a machine-applicable rewrite. These are optional simplifications, so they are hidden by default and surface as on-demand quick fixes in the VS Code extension; wax check prints them when enabled with -W suggestion=warning (or an individual name). A redundant cast removal is offered too, but as a fix attached to the existing redundant-operation warning rather than a separate suggestion.
      • 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).
    • --error-format FORMAT

      • How diagnostics are rendered.
      • Values: human (source snippets, the default), json, or short.
      • With human, a named warning’s -W name is appended to the header as [name] (e.g. Warning [unused-local]:), so at a glance you can tell which warning fired and which -W name silences or promotes it.
      • With json, every diagnostic — errors, warnings, and syntax errors — is written to stderr as one JSON object per line (JSON Lines), for an editor, CI job, or AI assistant to parse. Each object has severity, file, startLine/startColumn/endLine/endColumn (1-based line, 0-based column), startOffset/endOffset (byte offsets), message, warning (the -W name, or null), hint, and related. A diagnostic carrying a machine-applicable fix (a suggestion, a fixable warning like a redundant cast, an unused local, an unused label, or a confusing precedence mix, or a syntax error whose recovery insertion derives one — e.g. a missing ;, under --all-errors) also has an edit object: the same span fields for the slice to replace (an empty span, for an insertion), plus its newText. Syntax errors report their hint and related here too.
      • With short, each diagnostic is one file:line:col: severity: message line (gcc/rustc style, 1-based column), for an editor with a line-based error parser (Vim’s errorformat, Emacs Flymake, …). A named warning’s -W name is appended as [name].
      • Exit codes are unchanged. Also accepted by check and format.
    • --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.
      • Synthesises the declarative element segment ((elem declare func …)) that Wax’s lenient reader lets a module omit for a function used by ref.func only inside a body, so the output passes strict/spec reference validation. This heals a conditional module: WAT emitted from a #[if] module keeps its (@if …) and omits the segment, but once the conditionals are resolved with -D and desugared the segment is added. It is a no-op when the segment is already present (as on the wax-to-wat and binary-to-wat paths).
      • 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.
      • Only Wax-specific annotations go. The metadata.code.* hints of the branch-hinting and compilation-hints proposals are real WebAssembly text, so they stay.
      • A (@feature "…") declaration is stripped, so none remains in the output. Feature resolution has already run by then, so the declaration still gates during processing; re-ingesting the desugared text needs -X again, exactly as desugared strings do not come back as literals. A module using gated constructs is not an error — those are real (proposal) WebAssembly, and removing annotations is orthogonal to whether the consumer supports the proposal.
    • --faithful

      • Decompile faithfully: turn off the stream-reshaping recoveries, so a wat/wasm module decompiled to Wax recompiles with the same reachable instruction structure. It keeps !(a == b) instead of fusing t.eq; i32.eqz into a single a != b, and keeps a flat br_on_cast_fail chain instead of recovering it as a match (which re-lowers to the nested ladder). It also keeps a redundant non-null ref.cast.
      • Only valid with wax output (-f wax); requesting it for wat or wasm output is a usage error.
      • Width fidelity is not faithful-only: constant, leftover, dead-code and convert / extend32_s widths are pinned on the default decompile too, so --faithful preserves the opcode structure the reshaping recoveries would change, not the widths.
      • Some divergences remain (all semantically inert): local ordering and numbering, the name section, type renumbering, compiler-inserted casts, a typed select’s immediate, and the shared spellings (extend32_s, a 32-bit load widened to i64, the call family, float neg-of-literal). See Round-Tripping.
    • --source-map

      • Generate a source map file alongside the output file and insert a sourceMappingURL custom section. Only valid with wasm output (-f wasm) to a file, when the source is a text file (not a Wasm binary). Requesting one for wat or wax output, when the source is a Wasm binary, or when outputting to stdout, 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.
        • width-check: on a wasm or wat to wax conversion, report a width disagreement instead of repairing it. The conversion records the type of each value the source opcodes produce, and the type checker — which already runs over the decompiled module — reconciles its own inference with those records: an expression that would recompile at another width (an i64 operation re-read as i32, say) is PINNED with a cast at the width the WebAssembly states. That is how the decompiler keeps widths faithful, so a pin appearing in the output is the mechanism working, not a fault. This category reports each such pin as an error instead, which is what a tool developer needs to see WHERE the decompiler is relying on it and why — it is a self-check on the decompiler, not a check on the input, and it does nothing for any other conversion. A disagreement a pin cannot fix — the value’s type is fixed by its context, so a cast would convert the value instead of grounding it — is an error either way; that means the WebAssembly is invalid (a binary input is trusted, never validated — check it with wax check) or the decompiler is wrong.
    • --version

      • Print the toolchain version and exit. In a released build this is the git tag it was built from; a plain development build reports dev.
    • --help

      • Show the manual page and exit. Also available per subcommand (wax format --help, etc.).

    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
    

    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.

    With no FILE, format reads standard input and writes the formatted result to standard output. The format cannot be detected without a filename, so --format is required in this mode (and --inplace / --check, which act on named files, are not allowed). This is the interface an editor formatter or a shell pipe uses:

    echo 'fn f(x:i32)->i32{x*x;}' | wax format -f wax
    
    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 — or no file, to format stdin to stdout (see above).
    • -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).
    • --error-format FORMAT: human, json, or short, as above (a malformed file still reports its syntax error).
    • --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.
    • --all-errors: report every syntax error instead of stopping at the first. Normally the parser gives up at the first unexpected token; with this flag it uses panic-mode error recovery and continues, so a single run lists all the syntax errors. Wax resynchronizes at statement and block boundaries (;, }, ), ], and the keywords that begin a new item or statement); WAT, being fully parenthesized, resynchronizes on the parentheses (dropping an incomplete group, auto-closing an unclosed one). The recovered module is then checked too — type-checked for Wax, validated for WAT — so genuine type or validation errors in the intact regions surface alongside the syntax errors, while the cascades a dropped construct would otherwise trigger (an unbound name in Wax; a warning or wrong stack height in WAT) are suppressed. Text input only, Wax and Wat (ignored for a Wasm binary).
    • --color WHEN: as above.
    • --error-format FORMAT: human, json, or short, as above.
    • --debug CATEGORY: as above.

    Example

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

    wax check src/*.wax
    

    Report all syntax errors in a Wax file at once:

    wax check --all-errors src/foo.wax
    

    Language server

    The lsp subcommand starts a Language Server Protocol server for Wax and WebAssembly text, speaking JSON-RPC over stdin/stdout. It runs the same analysis the VS Code extension runs in-process, exposed to any LSP-capable editor (Neovim, Emacs with Eglot or lsp-mode, Helix, Kakoune, Zed, and others).

    wax lsp
    

    It reads no files of its own; the editor tells it which documents are open. Supported requests: diagnostics (pushed on open/change), hover, go-to-definition, go-to-type-definition, find-references, document highlight, rename (with prepare), document symbols (outline), completion, signature help, inlay hints, folding ranges, selection ranges, semantic tokens, and document formatting. The hover, navigation and completion features that need the typed tree are Wax-only, while formatting, diagnostics and the outline work for .wat too (dispatched by the document’s URI extension).

    A lint diagnostic carries the extra metadata editors use: the -W name as its code (linked to this reference), and DiagnosticTag.Unnecessary on the removable/unreachable lints (unused bindings, dead code) so the editor fades them.

    The one setting is wax.define, a list of conditional-compilation defines (mirroring the -D flag, e.g. ["debug=true", "arch=wasm64"]). Diagnostics and completion specialize to it, so a definition or an error in a branch the defines rule out is dropped. The server reads it from the client’s initializationOptions at startup and from workspace/didChangeConfiguration live (under a wax section), re-checking the open documents when it changes.

    Documents are synchronized in full (each change carries the whole buffer). The position encoding is negotiated: the server uses UTF-8 when the client offers it and otherwise UTF-16, the LSP default that every client supports. The --stdio flag is accepted (and ignored) for the editors that pass it by convention.

    Editor setup

    Most editors just need the launch command wax lsp bound to the wax (and, if desired, wat) file type through their LSP client. Ready-to-use configurations for Neovim, Helix, and Emacs — paired with the tree-sitter-wax grammar for highlighting — live under editors/ in the repository (one directory per editor, each with a README).

    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 with text output or binary input, 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.

    Editor Support

    Wax integrates with editors in two ways. Visual Studio Code has a dedicated extension that bundles everything. Every other editor combines two pieces: the tree-sitter-wax grammar for syntax highlighting, and the built-in wax lsp language server (see CLI Reference) for everything else. The extension and the server run the same analysis as the wax toolchain, so the language features are the same across editors.

    Features

    Available in VS Code, and in any editor with a Language Server Protocol client through wax lsp:

    • Diagnostics as you type: syntax errors, type errors, and lints.
    • Hover types, and inlay hints showing the inferred type on each un-annotated let binding.
    • Go to definition, go to type definition, find references, and document highlight.
    • Rename (a punned struct field is expanded so the field is preserved).
    • Completion: the names in scope, a struct value’s fields and a numeric / array / v128 / memory / table receiver’s methods after ., and an intrinsic namespace’s functions after ::.
    • Signature help while typing a call.
    • Document outline, folding ranges, selection ranges, and semantic tokens.
    • Formatting.

    WebAssembly text (.wat) gets the same features: formatting, diagnostics, the outline, hover types, go-to-definition, go-to-type-definition, find references, document highlight, rename, folding, selection ranges, signature help, semantic tokens (identifiers coloured by the kind of index they resolve to), completion (where an index is expected, the names of the space it wants — functions, globals, locals, types, labels — since the instruction fixes the kind), inlay hints (after a numeric index, the name of the definition it refers to, so (local.get 0) reads as (local.get 0 $x)), and inactive-branch dimming for (@if) conditional compilation. Syntax highlighting otherwise comes from the extension’s own grammar in VS Code and from tree-sitter-wax elsewhere.

    Visual Studio Code

    The Wax extension (source in editors/vscode/) covers both .wax and .wat files with the full feature set above, plus snippets and commands to preview the compiled WAT or decompiled Wax side by side. It runs the toolchain compiled to WebAssembly in-process, with no separate wax install, so it works the same in desktop and web VS Code (including vscode.dev).

    Other editors

    Any editor with a Language Server Protocol client gets the feature set above by launching wax lsp. The editor starts the server itself, so wax only needs to be on your PATH; pair it with the tree-sitter grammar for highlighting. Ready-to-use configurations, each with a README, live under editors/:

    • Neovim: register the tree-sitter-wax parser (nvim-treesitter or the built-in loader) and point vim.lsp at wax lsp.

    • Helix: add the grammar and a [language-server.wax] entry running wax lsp (a ready-to-paste languages.toml is included).

    • Emacs: wax-ts-mode provides tree-sitter highlighting, indentation, and imenu, with Eglot driving wax lsp for the rest. Requires Emacs 29+ and can be installed directly from GitHub using use-package with package-vc:

      (use-package wax-ts-mode
        :vc (:url "https://github.com/ocsigen/wax"
             :branch "main"
             :rev :newest
             :lisp-dir "editors/emacs")
        :hook (wax-ts-mode . eglot-ensure))
      
      ;; Optional: completion popup as you type. Without it, Eglot still serves
      ;; completions through the built-in `completion-at-point' (C-M-i).
      (use-package corfu
        :ensure t
        :custom
        (corfu-auto t)
        (corfu-auto-prefix 1)  ; pop up after one character, not the default 3
        :hook (wax-ts-mode . corfu-mode))
      

    The server’s flags and behaviour (document sync, position-encoding negotiation) are documented under wax lsp in the CLI reference.

    AI coding agents

    Coding assistants that support the SKILL.md agent-skill format (Claude Code, Antigravity, Copilot, Codex, …) can be taught Wax with the wax skill shipped in the repository. Wax has almost no presence in model training data, so the skill bundles the complete language reference as a single context-loadable file and instructs the agent to validate everything it writes with wax check. Install it with the skills CLI:

    npx skills add ocsigen/wax
    

    or copy skills/wax/ into the agent’s skills directory (for Claude Code, .claude/skills/).