Converting WAT to Wasm with wat2wasm

This guide covers the assembler at the centre of every hand-written WebAssembly workflow: turning a .wat file into a .wasm binary, enabling the proposals a module uses, reading the errors when it refuses, and going back the other way to inspect something a compiler produced.

wat2wasm is small and unglamorous, and it is the tool that makes the text format useful rather than decorative. Feature-detection probes, test fixtures, minimal reproductions for engine bugs and teaching examples all start here.

The round trip and what each direction is for Assembling turns readable text into the binary an engine loads, which is how hand-written modules and test fixtures are produced. Disassembling turns a compiler's output back into text, which is how you inspect what a toolchain actually emitted. probe.wat readable, diffable, editable by hand the source of truth for a fixture probe.wasm what the engine validates and runs what a compiler also produces wat2wasm — assemble wasm2wat — inspect The round trip preserves behaviour exactly. It does not preserve your comments, and it only preserves names if the name section survived. So keep the .wat as the source and treat the .wasm as a build output, exactly as you would with any other compiler.

Prerequisites

  • [ ] wabt installed — wat2wasm, wasm2wat, wasm-validate (wat2wasm --version)
  • [ ] A .wat file, or a .wasm you want to read
  • [ ] xxd or similar, if you intend to embed the bytes in JavaScript
  • [ ] Knowledge of which proposals your module uses, because they are opt-in

Step-by-step procedure

1. Assemble

wat2wasm add.wat -o add.wasm
wasm-validate add.wasm && echo 'valid'

Assembly does not imply validity — wat2wasm will produce output that a stricter validator rejects in some edge cases, so running wasm-validate afterwards is a habit worth having.

2. Enable the proposals the module uses

wat2wasm --enable-simd simd-probe.wat -o simd-probe.wasm
wat2wasm --enable-threads --enable-bulk-memory shared.wat -o shared.wasm
wat2wasm --enable-all experimental.wat -o experimental.wasm

--enable-all is convenient while iterating and a poor default for a build, because it will happily assemble a module using instructions your target engines do not support.

3. Read the errors

The assembler reports a line, a column and a caret, which makes most mistakes obvious:

add.wat:4:5: error: type mismatch in i32.add, expected [i32, i32] but got [i32]
    i32.add
    ^^^^^^^

That message is a stack-depth error: one operand was missing at that instruction. Tracking the stack in comments as you write is the practical prevention.

4. Disassemble a compiler’s output

wasm2wat app_bg.wasm -o app.wat
wasm2wat --fold-exprs app_bg.wasm | head -40

--fold-exprs produces the nested s-expression form, which is easier to read for following data flow; the flat default is easier for reasoning about stack effects.

5. Emit bytes for embedding

wat2wasm --enable-simd probe.wat -o probe.wasm
xxd -i probe.wasm > probe.h        # C-style array
node -e "console.log(JSON.stringify([...require('fs').readFileSync('probe.wasm')]))"

Generating the array in the build rather than pasting it once is what keeps an embedded probe correct after an edit.

6. Wire it into the build

{
  "scripts": {
    "build:fixtures": "for f in test/fixtures/*.wat; do wat2wasm --enable-simd \"$f\" -o \"${f%.wat}.wasm\"; done"
  }
}

Expected output

$ wat2wasm add.wat -o add.wasm && ls -l add.wasm
-rw-r--r-- 1 dev dev 41 add.wasm

$ wasm2wat add.wasm
(module
  (type (;0;) (func (param i32 i32) (result i32)))
  (func (;0;) (type 0) (param i32 i32) (result i32)
    local.get 0
    local.get 1
    i32.add)
  (export "add" (func 0)))

Forty-one bytes for a complete module with one exported function is a useful thing to have seen: the format’s overhead is genuinely small, which is why hand-built probe modules cost nothing to embed.

Notice also what the round trip lost. The original file’s $a and $b parameter names became 0 and 1, because names live in a custom section the assembler does not emit by default. The behaviour is identical; the readability is not.

Where forty-one bytes go Eight bytes of header, then a type section describing one signature, a function section pointing at it, an export section naming it, and a code section holding three instructions. Nothing in the format is large. header 8 bytes type 7 bytes function 4 bytes export "add" 10 bytes code 12 bytes Ten of the forty-one bytes are the string "add" and its export entry — names are the largest single item in a tiny module. That is why a feature probe with no exports at all can be barely thirty bytes and still answer a real question.

Gotchas

error: unexpected token on valid-looking code. Usually a proposal that needs enabling — v128, memory.copy, an atomic instruction. The assembler will not guess.

A validator accepts what an engine rejects. The engine may not support a proposal the assembler does. Test in the target runtime, not just the toolchain.

The disassembled output does not match the source. Names and comments are lost. Keep the .wat in the repository as the source; treat disassembly of a compiler’s output as read-only.

Embedded bytes drift from the source. They were pasted once and the .wat changed afterwards. Generate them in the build.

wat2wasm succeeds and the module traps immediately. Assembly checks syntax and types, not logic. A start function that traps assembles perfectly.

Three levels of "it works" Assembling checks that the text parses and that types line up. Validating applies the full specification rules an engine would. Running is the only step that exercises the logic, and a module can pass the first two and still trap immediately. wat2wasm parses, checks types and stack it is syntactically a module wasm-validate applies the full specification an engine would accept it run it the only check of the logic it does the right thing A fixture that assembles and validates can still trap on its first instruction — which for a deliberately trapping fixture is exactly right.

Performance note

Assembly is instant for the module sizes anyone writes by hand — a few milliseconds — so there is no reason to cache or skip it in a build. Where the tool has a performance role at all is in the artifacts it produces: a hand-written probe module for feature detection is thirty bytes, which is what makes validating one cheap enough to run unconditionally at startup.

The other place it pays is fixtures. A test that needs a module with a specific, deliberate shape — an import that is missing, a memory with a small maximum, a function that traps on the second call — is far easier to write in WAT than to coax a compiler into producing, and the resulting .wasm is stable across toolchain upgrades in a way a compiled fixture is not.

Building a fixture library

The most valuable thing this tool produces is a set of small, deliberate modules that exercise exactly one behaviour each. A handful of them makes testing a host — a loader, a runtime embedding, an error handler — straightforward in a way that compiled artifacts never quite manage.

A useful starter set, all of them under twenty lines:

fixtures/
  minimal.wat          a module with no imports, one export, no memory
  traps.wat            an export whose body is `unreachable`
  needs-import.wat     imports env.log; instantiating without it must LinkError
  grows.wat            exports a function that calls memory.grow
  big-memory.wat       declares a memory with a large minimum
  simd-probe.wat       one v128 instruction, for feature detection
  invalid.wasm         a truncated binary, for testing your error path

Each one answers a question a compiled module answers ambiguously. Does the loader surface a LinkError distinctly from a CompileError? Does the code that caches views survive a growth? Does the error path show a useful message when the bytes are corrupt? Those are exactly the paths that are hard to trigger with a real module and trivial with a purpose-built one.

Build them in the test setup rather than committing the binaries, so the fixtures stay readable and a toolchain upgrade does not leave stale artifacts behind:

{
  "scripts": {
    "pretest": "for f in test/fixtures/*.wat; do wat2wasm --enable-simd \"$f\" -o \"${f%.wat}.wasm\"; done"
  }
}

The exception is the deliberately invalid file, which cannot be assembled by definition — that one is generated by truncating a valid binary, or committed as a small blob with a comment explaining what is wrong with it.

Reading someone else’s module

The reverse direction is the one you will use more often. Disassembling an unfamiliar .wasm answers three questions quickly: what does it import, what does it export, and how large is its memory. Those three determine what the module can reach and what it will cost you to run.

wasm2wat module.wasm | grep -E '^\s*\((import|export|memory)' | head -30

If the module was stripped the function names will be indices, but imports, exports and the memory declaration survive stripping — they are structural rather than debug information. That is enough for a capability review even on a binary you cannot otherwise read, which makes this a two-minute check worth running on anything third-party before it goes near production.

Frequently Asked Questions

Should the .wasm be committed? Generally no — commit the .wat and build the binary, exactly as with any other source. The exception is a fixture that must be byte-stable across toolchain versions, where committing the binary is the point.

Can I write a whole application in WAT? You can, and you should not. It is the right tool for probes, fixtures and reproductions; for anything with real logic a compiler produces better code and far more maintainable source.

Is wasm-tools parse an alternative? Yes, and it handles component text as well. For core modules the two are interchangeable; wabt has the longer history and wasm-tools the newer format support.

Can I generate WAT programmatically? Yes, and for a family of similar fixtures it beats maintaining them by hand — a small script emitting text and piping it through the assembler produces exactly the modules a test matrix needs. Because the format is plain text with no whitespace significance, generating it needs no library.

Does the assembler optimise anything? No, and that is a feature. What you write is what you get, byte for byte, which is exactly what a fixture or a probe needs. Optimisation is wasm-opt’s job, and running it on a hand-written module is usually pointless — there is nothing to remove.

Is there a formatter for WAT? Not a canonical one; disassembling with wasm2wat is the closest equivalent and normalises the layout.

← Back to WebAssembly Text Format (WAT) Basics