watr

July 6, 2026 · View on GitHub

Fast WebAssembly Text Format (WAT) compiler for JavaScript/Node.js.

Supports phase 5, phase 4 and phase 3 features, full spec syntax and passes the WebAssembly test suite.
Includes stringref, rounding mode control, and wide arithmetic.
Enables polyfills and optimizations.

FeatureStatusPolyfill
MVPyesn/a
BigInt / i64yesn/a
Multi-valueyespartial (no blocks)
Sign extensionyesyes
Non-trapping conversionsyesyes
Bulk memoryyescopy/fill only
Reference typesyesfuncref only
Typed function refsyesyes
Tail callsyesyes
Extended constyesyes
Multiple memoriesyesn/a
Memory64yesno
SIMDyesno
Relaxed SIMDyesno
Threadsyesno
GCyesi31/struct/array/cast
Exceptionsyesno
Annotationsyesn/a
Wide arithmeticyesn/a
JS string builtinsyesn/a
Stringrefyesno
Rounding mode controlyesn/a

Install

npm install watr

API

watr`...`

Compile and instantiate, returns exports.

import watr from 'watr'

// basic
const { add } = watr`(func (export "add") (param i32 i32) (result i32) (i32.add (local.get 0) (local.get 1)))`
add(2, 3) // 5

// auto-import JS functions
const { test } = watr`(func (export "test") (call \${console.log} (i32.const 42)))`
test() // 42

// interpolate numbers
watr`(global (export "pi") f64 (f64.const \${Math.PI}))`       // f64
watr`(func (export "f") (result i64) (i64.const \${123n}))`    // i64 BigInt

// interpolate config
watr`(memory (export "mem") \${pages})`                        // memory size
watr`(func (export "f") (call \${0}))`                         // indices
watr`(func \${id} ...)`                                        // identifiers

// interpolate binary data
watr`(memory (export "mem") (data \${new Uint8Array([1,2,3])}))`
watr`(data (i32.const 0) \${[1, 2, 3, 4]})`

// interpolate code strings
const ops = '(i32.add (i32.const 1) (i32.const 2))'
watr`(func (export "f") (result i32) \${ops})`

// string argument
watr('(func (export "f") (result i32) (i32.const 42))')

compile(source)

Compile to binary. Accepts a string, AST, or template literal.

import { compile } from 'watr'

compile(`(func (export "f"))`)                       // string
compile(['func', ['export', '"f"']])                 // AST
compile`(func (export "f") (f64.const \${Math.PI}))` // template
// Uint8Array

polyfill and optimize are heavy, so they ship as separate entries rather than bloating the core. Compose them with compile:

import { compile } from 'watr'
import polyfill from 'watr/polyfill'
import optimize from 'watr/optimize'

compile(polyfill(src))             // polyfill newer features to MVP
compile(optimize(src))             // optimize for size/speed
compile(optimize(polyfill(src)))   // both

polyfill(ast, options?)

Transform AST to polyfill newer WebAssembly features for older runtimes.

import polyfill from 'watr/polyfill'
import { parse, compile } from 'watr'

// auto-detect and polyfill all
const ast = polyfill(parse(src))
compile(ast)

// specific features
polyfill(ast, 'funcref')              // space-separated string
polyfill(ast, { funcref: true })      // object

Available polyfills:

FeatureTransformsNotes
funcrefref.funci32.const, call_ref/return_call_refcall_indirectCreates hidden table
sign_exti32.extend8_s → shift pairs, etcShift left + arithmetic shift right
nontrappingi32.trunc_sat_f32_s → helper function, etcInjects helper functions
bulk_memorymemory.copy/fill → loop helpersByte-by-byte loops
return_callreturn_callreturn + callLoses tail call optimization
i31refref.i31i32.and, i31.get_s/u → shift/mask31-bit tagged integers
extended_constglobal.get in initializers → evaluated constantCompile-time evaluation
multi_valueMultiple results → single + globalsPartial: functions only, not blocks
gcstruct.new/get/set, array.new/get/set/len → memory opsBump allocator, type tags
ref_castref.test, ref.cast, br_on_cast → type tag checksRuntime tag comparison

Not polyfillable:

FeatureReason
SIMDScalar emulation too slow
Threads/AtomicsRequires host support
Memory64Cannot emulate 64-bit address space
Exception handlingComplex control flow transforms
externrefRequires JS-side reference tracking

optimize(ast, options?)

Optimize AST for smaller size and better performance.

import optimize from 'watr/optimize'
import { parse, print, compile } from 'watr'

// auto-detect and apply all optimizations
const ast = optimize(parse(src))
compile(ast)

// specific optimizations
optimize(ast, 'fold')                   // constant folding only
optimize(ast, 'treeshake fold')         // multiple optimizations
optimize(ast, { fold: true })           // object form

// can accept string directly
print(optimize('(func (i32.add (i32.const 1) (i32.const 2)))'))
// (func (i32.const 3))

optimize(ast) runs every default-on pass below, up to 3 rounds, until a fixpoint or a round would inflate the encoded compile(ast) byte size (the round is then reverted). Passes that can grow output, change observable behavior, or only make sense when you control the host are opt-in (marked ◌); the rest are on by default.

OptimizationDescriptionExample
treeshakeRemove unused definitionsFunctions/globals/types/tables not exported or reachable
foldConstant folding (incl. reinterpret/convert)(i32.add (i32.const 1) (i32.const 2))(i32.const 3); (i64.reinterpret_f64 (f64.const 256))(i64.const 4643211215818981376)
deadcodeRemove unreachable codeCode after unreachable, br, return
localsRemove unused localsLocals never read or only-written
identityRemove identity ops(i32.add x (i32.const 0))x
strengthStrength reduction(i32.mul x (i32.const 2))(i32.shl x (i32.const 1))
branchSimplify constant branches(if (i32.const 1) A B)A
propagateForward single-use locals & tiny consts(local.set $x (i32.const 1)) … (local.get $x)(i32.const 1)
inlineInline tiny functionsSingle-expression functions without locals — may duplicate bodies
inlineOnceInline functions called from exactly one siteDrops the callee and its call site; never duplicates
vacuumRemove no-opsNops, drop-of-pure, empty branches
mergeBlocksUnwrap blocks whose label is never targeted(block $L body) with no br $L inside → body (saves block/end framing)
coalesceShare local slots between non-overlapping same-type localsTwo i32 locals with disjoint live ranges → one local
peepholeAlgebraic identitiesx - x0, x & 00
globalsPropagate immutable global constantsglobal.get of never-written global → its constant (size-aware)
offsetFold offsets into load/store(i32.load (i32.add ptr (i32.const 4)))(i32.load offset=4 ptr)
unbranchRemove redundant trailing brbr $label at end of its own block
loopifyCollapse block+loop+br_if while-idiom(block $A (loop $B (br_if $A (i32.eqz cond)) … (br $B)))(loop $B (if cond (then … (br $B))))
stripmutStrip mut from never-written globalsEnables further globals propagation
brifConvert if-then-br to br_if(if cond (then (br $l)))(br_if $l cond)
foldarmsMerge identical trailing if arms(if C (then A X) (else B X))(if C (then A) (else B)) X
dedupeEliminate duplicate functionsReplace duplicates with a single canonical function
reorderReorder functions by call frequencyHot functions first for smaller LEB indices
dedupTypesMerge identical type definitionsRemove structurally duplicate (type …) nodes
packDataPack data segmentsTrim trailing zeros, merge adjacent constant-offset segments
minifyImportsShorten import module/field namesa, b, aa… — only safe when you control the host

◌ = opt-in (optimize(ast, 'foldarms') or optimize(ast, { foldarms: true })).

pin — array (or Set) of function names inline/inlineOnce must never dissolve, even when single-caller. Use it when a later pass of yours rewrites those call nodes and needs them intact — e.g. a downstream auto-vectorizer that maps $math.exp/$math.log calls to their f64x2 mirrors after optimize runs. Keeps the no-inline policy with the caller, not hardcoded in watr.

optimize(ast, { pin: ['$math.exp', '$math.log'] })   // these calls survive inlining

Profiles — named presets that layer pass overrides under your own opts (an explicit key always wins over the profile, same rule as pin). Never consulted by optimize(ast) or optimize(ast, true) — profiles are opt-in, so default behavior is unchanged.

ProfileTradePasses
speeda little size for less per-call dispatch overheadoutline, tailmerge, rettail off
optimize(ast, { profile: 'speed' })                  // object form
optimize(ast, 'speed')                               // string form
optimize(ast, { profile: 'speed', outline: true })   // override one pass on top

speed turns off three passes that trade an inline computation for a shared call/branch: outline extracts a repeated pure expression across function boundaries into one helper (a call at every occurrence instead of the computation); tailmerge/rettail share byte-identical early-exit epilogues behind a block+br_if. Keeping everything inline costs bytes but removes that dispatch from every hot call site. Measured on a helper-call- heavy self-hosted-compiler wasm module (jz compiling itself, 22-program corpus): geomean compile-time ratio 1.43x → 1.32x (~8% faster) for ~19% more bytes — outline alone drives ~99% of it; tailmerge/rettail add negligible bytes but let one more corpus case compile without exhausting its bump allocator. Not every workload shares this shape (licm and devirt were measured on the same corpus and didn't help — a regression and a no-op, respectively) — speed is an opt-in trade, not a replacement default.

parse(source, options?)

Parse to AST.

import { parse } from 'watr'

parse('(i32.add (i32.const 1) (i32.const 2))')
// ['i32.add', ['i32.const', 1], ['i32.const', 2]]

// options: comments, annotations
parse('(func ;; note\n)', { comments: true })
// ['func', [';', ' note']]

print(tree, options?)

Format WAT code or AST to string. Accepts a string or AST array.

import { print } from 'watr'

// prettify string
print('(module(func(export "add")(param i32 i32)(result i32)local.get 0 local.get 1 i32.add))')
// (module
//   (func (export "add") (param i32 i32) (result i32)
//     local.get 0
//     local.get 1
//     i32.add))

// print AST
print(['module', ['func', ['export', '"f"'], ['result', 'i32'], ['i32.const', 42]]])
// (module
//   (func (export "f") (result i32)
//     (i32.const 42)))

// minify
print('(module\n  (func (result i32)\n    (i32.const 42)))', { indent: false, newline: false })
// (module (func (result i32) (i32.const 42)))

// options: indent (default '  '), newline (default '\n'), comments (default true)

Syntax

;; folded
(i32.add (i32.const 1) (i32.const 2))

;; flat
i32.const 1
i32.const 2
i32.add

;; abbreviations
(func (export "f") ...)           ;; inline export
(func (import "m" "n") ...)       ;; inline import
(memory 1 (data "hello"))         ;; inline data

;; numbers
42  0x2a  0b101010                ;; integers
3.14  6.02e23  0x1.8p+1           ;; floats
inf  -inf  nan  nan:0x123         ;; special
1_000_000                         ;; underscores

;; comments
(; block ;)

Features

BigInt / i64

watr`(func (export "f") (result i64) (i64.const ${9007199254740993n}))`
watr`(func (export "g") (param i64) (result i64) (i64.mul (local.get 0) (i64.const 2n)))`

Multi-value

(func (result i32 i32) (i32.const 1) (i32.const 2))
(func (param i32 i32) (result i32 i32) (local.get 1) (local.get 0))  ;; swap
(block (result i32 i32) (i32.const 1) (i32.const 2))

Sign extension

(i32.extend8_s (i32.const 0xff))         ;; -1
(i32.extend16_s (i32.const 0xffff))      ;; -1
(i64.extend8_s (i64.const 0xff))         ;; -1
(i64.extend16_s (i64.const 0xffff))      ;; -1
(i64.extend32_s (i64.const 0xffffffff))  ;; -1

Non-trapping conversions

(i32.trunc_sat_f32_s (f32.const 1e30))   ;; clamps instead of trapping
(i32.trunc_sat_f32_u (f32.const -1.0))   ;; 0 instead of trap
(i64.trunc_sat_f64_s (f64.const inf))    ;; max i64 instead of trap

Bulk memory

(memory.copy (i32.const 0) (i32.const 100) (i32.const 10))  ;; dst src len
(memory.fill (i32.const 0) (i32.const 0xff) (i32.const 64)) ;; dst val len
(memory.init $data (i32.const 0) (i32.const 0) (i32.const 4))
(data.drop $data)
(table.copy (i32.const 0) (i32.const 1) (i32.const 2))
(table.init $elem (i32.const 0) (i32.const 0) (i32.const 2))
(elem.drop $elem)

Multiple memories

(memory $a 1)
(memory $b 2)
(i32.load $a (i32.const 0))
(i32.store $b (i32.const 0) (i32.const 42))
(memory.copy $a $b (i32.const 0) (i32.const 0) (i32.const 10))

Memory64

(memory i64 1)                           ;; 64-bit memory
(memory i64 1 100)                       ;; with max
(i64.load (i64.const 0))                 ;; 64-bit addresses
(i32.store (i64.const 0x100000000) (i32.const 42))

SIMD

(v128.const i32x4 1 2 3 4)
(v128.const f32x4 1.0 2.0 3.0 4.0)
(i32x4.add (local.get 0) (local.get 1))
(f32x4.mul (local.get 0) (local.get 1))
(i8x16.shuffle 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
  (local.get 0) (local.get 1))
(i32x4.extract_lane 0 (local.get 0))
(i32x4.replace_lane 0 (local.get 0) (i32.const 99))

Relaxed SIMD

(i32x4.relaxed_trunc_f32x4_s (local.get 0))
(f32x4.relaxed_madd (local.get 0) (local.get 1) (local.get 2))  ;; a * b + c
(f32x4.relaxed_nmadd (local.get 0) (local.get 1) (local.get 2)) ;; -(a * b) + c
(i8x16.relaxed_swizzle (local.get 0) (local.get 1))
(f32x4.relaxed_min (local.get 0) (local.get 1))

Tail calls

(func $factorial (param $n i64) (param $acc i64) (result i64)
  (if (result i64) (i64.le_u (local.get $n) (i64.const 1))
    (then (local.get $acc))
    (else (return_call $factorial
      (i64.sub (local.get $n) (i64.const 1))
      (i64.mul (local.get $n) (local.get $acc))))))

(return_call_indirect (type $fn) (local.get 0) (i32.const 0))

Extended const

(global $base i32 (i32.const 1000))
(global $offset i32 (i32.add (global.get $base) (i32.const 100)))
(global i64 (i64.mul (i64.const 1024) (i64.const 1024)))

Reference types

(table $t 10 funcref)
(table.get $t (i32.const 0))
(table.set $t (i32.const 0) (ref.func $f))
(table.size $t)
(table.grow $t (ref.null func) (i32.const 5))
(table.fill $t (i32.const 0) (ref.null func) (i32.const 10))
(global $ext (mut externref) (ref.null extern))
(ref.is_null (local.get 0))

Typed function refs

(type $fn (func (param i32) (result i32)))
(func $double (type $fn) (i32.mul (local.get 0) (i32.const 2)))
(call_ref $fn (i32.const 21) (ref.func $double))  ;; 42
(ref.null $fn)
(ref.is_null (ref.null $fn))
(block (result (ref null $fn)) (ref.null $fn))

GC

;; struct
(type $point (struct (field $x f64) (field $y f64)))
(struct.new $point (f64.const 1.0) (f64.const 2.0))
(struct.get $point $x (local.get $p))
(struct.set $point $y (local.get $p) (f64.const 3.0))

;; array
(type $arr (array (mut i32)))
(array.new $arr (i32.const 0) (i32.const 10))  ;; value, length
(array.get $arr (local.get $a) (i32.const 0))
(array.set $arr (local.get $a) (i32.const 0) (i32.const 42))
(array.len (local.get $a))

;; recursive types
(rec
  (type $node (struct (field $val i32) (field $next (ref null $node)))))

;; casts
(ref.cast (ref $point) (local.get 0))
(br_on_cast $label anyref (ref $point) (local.get 0))

Exceptions

(tag $e (param i32))
(throw $e (i32.const 42))
(try_table (result i32) (catch $e 0)
  (call $might_throw)
  (i32.const 0))
(try_table (catch_all 0) (call $fn))

JS string builtins

(import "wasm:js-string" "length" (func $strlen (param externref) (result i32)))
(import "wasm:js-string" "charCodeAt" (func $charAt (param externref i32) (result i32)))
(import "wasm:js-string" "fromCharCode" (func $fromChar (param i32) (result externref)))
(import "wasm:js-string" "concat" (func $concat (param externref externref) (result externref)))

Threads

(memory 1 10 shared)
(i32.atomic.load (i32.const 0))
(i32.atomic.store (i32.const 0) (i32.const 42))
(i32.atomic.rmw.add (i32.const 0) (i32.const 1))
(i32.atomic.rmw.cmpxchg (i32.const 0) (i32.const 0) (i32.const 1))
(memory.atomic.wait32 (i32.const 0) (i32.const 0) (i64.const -1))
(memory.atomic.notify (i32.const 0) (i32.const 1))
(atomic.fence)

Wide arithmetic

;; 128-bit addition: (a_lo, a_hi) + (b_lo, b_hi) -> (lo, hi)
(i64.add128 (local.get 0) (local.get 1) (local.get 2) (local.get 3))
(i64.sub128 (local.get 0) (local.get 1) (local.get 2) (local.get 3))
;; wide multiply: a * b -> (lo, hi)
(i64.mul_wide_s (local.get 0) (local.get 1))
(i64.mul_wide_u (local.get 0) (local.get 1))

Annotations

(@custom "name" "content")
(@name "my_module")
(@metadata.code.branch_hint "\00") if ... end  ;; unlikely
(@metadata.code.branch_hint "\01") if ... end  ;; likely

See Also