jz (javascript zero) is minimal functional JS that compiles to WASM.
import jz from 'jz'
const { exports: { dist } } = jz`export let dist = (x, y) => (x*x + y*y) ** 0.5`
dist(3, 4) // 5jz distills "the good parts" (Crockford) and compiles JS ahead-of-time to WASM: no runtime, no GC, no legacy, no spec creep, near-native perf with unlocked SIMD. Valid jz is valid JS – run and test as JS, compile to portable WASM.
| Good for | Not for |
|---|---|
| Numeric / math compute | UI / frontend |
| DSP / audio / bytebeats | Backend / APIs |
| Parsing / transforms | Async / I/O-heavy logic |
| WASM utilities | JavaScript runtime |
npm install jz
import jz, { compile } from 'jz'
// Compile + instantiate
const { exports: { add } } = jz('export let add = (a, b) => a + b')
add(2, 3) // 5
// Compile only — raw WASM binary
const wasm = compile('export let f = (x) => x * 2')
// Async startup
const asyncMod = await WebAssembly.compile(wasm)
const asyncInst = await WebAssembly.instantiate(asyncMod)
asyncInst.exports.f(21) // 42Options
Options are passed as jz(source, opts) or compile(source, opts). Common ones:
| Option | Use |
|---|---|
modules: { specifier: source } |
Static ES imports to bundle. CLI import resolution does this from files automatically. |
imports: { mod: host } |
Host imports import { fn } from "mod". |
memory |
Pass memory: N for owned memory with N initial pages, or memory: jz.memory() / WebAssembly.Memory to share across modules. |
host: 'js' | 'wasi' |
Runtime-service lowering. Default js; wasi for standalone runtimes. |
optimize |
false/0 off, 1 size-only, true/2 default (all stable passes), 3 trades size for speed. String aliases: 'size', 'balanced' (= default), 'speed'. Object form overrides individual passes. |
strict: true |
Enforce the pure canonical subset: skip jzify lowering (so var/function/class/==/… are rejected, not accepted) and reject dynamic fallbacks (obj[k], for-in, unknown receiver methods). Off by default — broader JS is lowered automatically. |
alloc: false |
Omit allocator exports (_alloc/_clear) for standalone modules that never marshal heap values. |
randomSeed |
Math.random seeding — default draws from host entropy (non-reproducible); a number fixes it for a reproducible sequence, true forces entropy explicitly. |
wat: true |
compile() returns WAT text instead of WASM binary. |
profile |
Mutable sink for compile-stage timings; set profile.names = true for a WASM name section. |
npm install -g jz
jz program.js # → program.wasm
jz program.js --wat # → program.wat
jz program.js -o out.wasm # custom output (- for stdout)
jz program.js -O3 # optimization: -O0 off, -O1 size, -O2 balanced, -O3 speed
jz program.js --host wasi # standalone WASI output
jz --strict program.js # pure canonical subset (also implied by .jz extension)
jz -e "1 + 2" # eval → 3jz --help
jz v0.5.1 - min JS → WASM compiler
Usage:
jz <file.js> Compile JS to WASM (full JS subset; .jz = strict)
jz --strict <file.js> Strict mode — pure canonical subset, no lowering
jz --jzify <file.js> Transform JS → jz source (auto-derives output file)
jz -e <expression> Evaluate expression
jz --help Show this help
Examples:
jz program.js # → program.wasm
jz program.js --wat # → program.wat
jz program.js -o out.wasm # custom output name
jz program.js -o - # write to stdout
jz program.js -O3 # aggressive optimization
jz program.js -Os # optimize for size
jz program.js --host wasi # emit WASI Preview 1 imports
jz --strict program.js # strict mode
jz --jzify lib.js # → lib.jz
jz -e "1 + 2"
Options:
--output, -o <file> Output file (.wat, .wasm, or - for stdout)
-O<n>, --optimize <n> Optimization level: 0 off, 1 size-only, 2 default,
3 aggressive. Aliases: -Os/size, -Ob/balanced, -Of/speed.
--host <js|wasi> Runtime-service lowering (default js)
--no-alloc Omit _alloc/_clear allocator exports (standalone wasm)
--names Emit wasm name section for profilers/debuggers
--strict Pure canonical subset: reject full-JS syntax + dynamic fallbacks
--jzify Transform JS to jz source (no compilation)
--eval, -e Evaluate expression or file
--wat Output WAT text instead of binary
--resolve Resolve bare specifiers via Node.js module resolution
--imports <file> JSON file with host import specs (e.g. {"env":{"fn":{"params":2}}})
--version, -v Show version number
jz is a strict modern functional JS subset. Built-in jzify transform extends support to legacy patterns.
┌────────────────────────────────────────────────────────────────────────┐
│ ┌────────────────────────────────────────────────────────────────────┐ │
│ │ jz strict │ │
│ │ let/const => ...xs destructuring import/export │ │
│ │ if/else for/while/do-while/of/in break/continue │ │
│ │ try/catch/finally throw │ │
│ │ operators strings booleans numbers arrays objects `${}` │ │
│ │ Math Number String Array Object JSON RegExp Symbol null │ │
│ │ ArrayBuffer DataView TypedArray Map Set │ │
│ │ parseInt parseFloat encodeURIComponent Error BigInt │ │
│ │ console setTimeout/setInterval Date performance │ │
│ └────────────────────────────────────────────────────────────────────┘ │
│ jz default (jzify) │
│ var function arguments switch new Foo() │
│ class new this extends super static #private │
│ == != instanceof undefined WeakMap WeakSet │
│ │
└────────────────────────────────────────────────────────────────────────┘
Not supported
async/await Promise function* yield
delete getters/setters eval Function with
Proxy Reflect
import() DOM fetch Intl Node APIs
What are the differences with JS?
- Numbers are f64; integer-proven values (
| 0, loop counters) arei32and wrap at ±2³¹. - Strings are UTF-8 bytes —
.length,charCodeAt, indexing,slice,indexOf, regex count bytes ("中".lengthis3);toUpperCase/toLowerCase/trimare ASCII-only. UTF-8 skips UTF-16's 2× and a multi-KB Unicode case table. - Objects are fixed-shape structs — keys added after the literal stay readable but don't enumerate (
Object.keys/for…in); use aMapfor dynamic keys. - Typed arrays are fixed-size —
arr.length = nwon't compile, and out-of-bounds reads give0. - No GC — call
memory.reset()between batches;WeakMap/WeakSetare plainMap/Set. String(number)keeps ~9 significant digits (String(Math.PI)→"3.14159265"), so it may not round-trip;NaN/Infinity/integers are exact. Exact shortest-form needs a multi-KB Ryū/Grisu formatter.- Errors are just their message — a caught error is the value you threw (no
.message, notinstanceof Error), andnull.xyieldsundefinedinstead of throwing. It keepsthrowand member reads free of object machinery and per-access checks. Dategetters return UTC (getHours≡getUTCHours) – the IANA timezone database is hundreds of KB.Math.randomis deterministic, takes arandomSeedoption for a reproducible stream.
Can I use existing npm packages or JS libraries?
Only the ones that fit the jz subset. There's no runtime, so packages touching the DOM, async/Promise, the network, or Node APIs won't compile — but pure numeric/algorithmic source does.
- Relative imports (
./dep.js) bundle at compile time. - Bare specifiers (
import { x } from "pkg") resolve through Node module resolution only with the--resolveCLI flag, or by passing the source yourself via{ modules }. The package's source still has to be valid jz.
jz is for compiling your numeric/DSP/parser code, not for running the npm ecosystem.
Can I use import/export?
Standard import/export syntax is bundled at compile time into a single WASM — no runtime module resolution.
const { exports } = jz(
'import { add } from "./math.js"; export let f = (a, b) => add(a, b)',
{ modules: { './math.js': 'export let add = (a, b) => a + b' } }
)Transitive imports work (main → math → utils → …); circular imports error at compile time. The CLI resolves filesystem imports automatically. In the browser, fetch sources yourself and pass them via { modules } — the compiler stays synchronous and pure, no I/O.
Can I call into the host (functions, objects)?
import … from 'host' with the { imports } option wires a runtime binding — a JS function, constant, or whole namespace. Numbers pass directly; strings, arrays, and objects cross via memory.*.
// Custom function
jz('import { log } from "host"; export let f = (x) => { log(x); return x }',
{ imports: { host: { log: console.log } } })
// Whole namespace — sin, cos, PI, … all auto-wired (functions as imports, numeric constants folded)
jz('import { sin, PI } from "math"; export let f = () => sin(PI / 2)',
{ imports: { math: Math } })
// globalThis works too
jz('import { parseInt } from "window"; export let f = () => parseInt("42")',
{ imports: { window: globalThis } })Can I interpolate values (template literals)?
jz is a tagged template — interpolated values are baked into the source at compile time. Numbers and booleans inline directly; strings, arrays, and objects compile as jz literals:
jz`export let f = () => ${'hello'}.length` // 5
jz`export let f = () => ${[10, 20, 30]}[1]` // 20
jz`export let f = () => ${{name: 'jz', count: 3}}.count` // 3
const scale = (x) => x * 10
jz`export let f = (n) => ${scale}(n) + 1` // f(2) → 21, host-calledInterpolated functions become host calls. Non-serializable values (host objects, class instances) fall back to post-instantiation getters automatically.
How to pass numbers, strings, arrays, objects JS ↔ WASM?
Numbers cross natively as f64/i32. Heap values — strings, arrays, objects, typed arrays — cross as NaN-boxed f64 pointers into linear memory, allocated through the module's _alloc/_clear exports. That pointer-plus-allocator convention is the whole ABI (a few hundred bytes, documented in layout.js with a worked example in test/abi.js). The one shortcut: arrays of ≤ 8 elements come back as plain JS arrays via WASM multi-value.
The memory codec — returned by jz() and by jz/interop's instantiate() — handles both directions: it marshals arguments in, decodes pointer returns out, and turns a wasm throw into a real Error:
const { exports, memory } = jz`
export let greet = (s) => s.length
export let dist = (p) => (p.x * p.x + p.y * p.y) ** 0.5
export let rgb = (c) => [c, c * 0.5, c * 0.2]
export let process = (buf) => buf.map(x => x * 2)
`
// Pass in
exports.greet(memory.String('hello')) // 5
exports.dist(memory.Object({ x: 3, y: 4 })) // 5
// Get back
exports.rgb(100) // [100, 50, 20] — auto-decoded JS array
memory.read(exports.process(memory.Float64Array([1, 2, 3]))) // Float64Array [2, 4, 6]memory.String / .Array / .Float64Array/etc / .Object allocate on the heap and return a pointer; memory.read(ptr) decodes one back. memory.Object() is fixed-layout — its keys must match a compiled schema's key set (order is free, fields place by name).
Do I need jz at runtime?
The compiler runs at build time. At runtime you ship the .wasm and, at most, a small bridge — never the compiler, the parser, or a language runtime.
- Pure-number modules — nothing but the
.wasm. Instantiate with rawWebAssembly, zero jz dependency:(await WebAssembly.instantiate(wasmBytes)).instance.exports.dist(3, 4). Compile with{ alloc: false }to drop the_alloc/_clearexports too. - Heap values (strings, arrays, objects) — the
.wasmplusjz/interop.import { instantiate } from 'jz/interop'adds a ~6 KB-gzipped bridge (no compiler, no parser) that builds the sameModule+Instanceyou'd build by hand and wires the allocator and thememorycodec from the previous question (plus WASI /wasm:js-stringimports if the module uses them). - No JavaScript host at all — compile with
host: 'wasi'; see the next question.
For contrast, Rust (wasm-bindgen), Go (TinyGo), and C/Zig (Emscripten/WASI-libc) emit per-build generated glue and usually bundle a language runtime. jz keeps the ABI fixed and the optional bridge ~6 KB gzipped.
Can I run the `.wasm` without a JavaScript host (WASI)?
There's two possible host targets:
js(default) — runs inside a JavaScript host (browser, Node, Deno, Bun).jz()andjz/interopwire the neededenv.*services automatically (overridable viaopts.imports.env), and you get full value marshaling across the boundary.wasi— runs on a standalone WASM engine with no JavaScript (wasmtime, wasmer, deno run). jz emits WASI Preview 1, so the module needs no host shims — but there's no host-side marshaler, so heap values must be passed by hand.
Either way the .wasm carries at most one import namespace (none, env, or wasi_snapshot_preview1). The difference is only in how a few runtime services are serviced:
| What your code does | js (default) |
wasi |
|---|---|---|
console.log() |
env.print — host stringifies |
WASI fd_write |
Date.now() / performance.now() |
env.now → f64 |
WASI clock_time_get |
setTimeout / setInterval |
env.setTimeout — host schedules |
WASM timer queue + __timer_tick |
dynamic obj.method() |
env.__ext_call (JS resolves) |
error at compile time |
How does memory work?
jz uses a bump allocator: every heap value (string, array, object, typed array) bumps a single pointer forward — no free list, no GC. The heap starts at byte 1024 — the first 1 KB holds static data (string/array literals laid out from offset 0, plus the bump pointer itself at byte 1020 when memory is shared across threads). It grows the WASM memory automatically when full, and if the literals overflow that 1 KB the heap simply starts past them. Memory is never reclaimed implicitly — a long-running program that allocates per call grows without bound. Reset between independent batches:
for (let i = 0; i < 1000; i++) {
const sum = exports.process(100) // allocates an array each call
memory.reset() // drop everything; heap ptr → 1024
}After memory.reset() all previously returned pointers are invalid — read what you need first, then reset. For finer control, memory.alloc(bytes) returns a raw offset on the same pointer. Pure scalar modules (no heap values) compile without the allocator at all.
Can modules share memory?
jz.memory() creates a shared memory that modules compile into. Schemas accumulate, so objects created in one module are readable by another:
const memory = jz.memory()
const a = jz('export let make = () => { let o = {x: 10, y: 20}; return o }', { memory })
const b = jz('export let read = (o) => o.x + o.y', { memory })
b.exports.read(a.exports.make()) // 30 — same memory, merged schemas
memory.read(a.exports.make()) // {x: 10, y: 20}Pass an existing WebAssembly.Memory to wrap it: jz.memory(new WebAssembly.Memory({ initial: 4 })).
Each compiled module exposes two call surfaces:
.exports— the JS-wrapped surface: it marshals JS arguments into the heap and decodes pointer return values back to JS values (and turns a wasmthrowinto anError). Use it by default — it's also how you hand a value from one module to another, as in the example above (the value is re-marshaled through the shared memory)..instance.exports— the rawWebAssembly.Instanceexports: numbers pass through untouched, and a pointer return comes back as a raw NaN-boxed handle. Decode it on the host withmemory.read(ptr). Don't pass a raw pointer back in as an argument, though — the JS↔wasmf64boundary canonicalizes its NaN payload and the pointer is lost; let.exportsmarshal across instead.
How big is the output?
No runtime, no GC — a module is your code plus a small bump allocator. The geomean across the bench corpus is on par with AssemblyScript and smaller than Porffor; most modules are single-digit kB — the ZzFX synth is ~10 kB, mandelbrot ~7 kB. Shrink it further:
optimize: 'size'— keeps every size pass, drops loop unrolling and SIMD.alloc: false— omit the allocator for pure-numeric modules that never marshal heap values.host: 'wasi'— no JS-host import shims (the debugnamesection is already off unless you setprofile.names).
Hand-written WAT is still ~3–8× smaller on tight kernels — jz carries generic allocator and stdlib helpers a specialist omits; closing that gap is ongoing. Size budgets are gated in CI alongside speed (full table).
Which optimizations are applied?
Ordinary JS is already fast — jz infers the right machine type for your numbers, so you write plain JS. What it does, all on at the default optimize: 2 (each line is also the habit that triggers it):
- Type narrowing — parameters/results pinned to
i32/f64/bool/typed-array elements from their call sites, off the boxed path. AFloat64Array/Int32Arrayis direct memory access; a plain[]works too, with a little more overhead. - Escape analysis & arena rewind — fixed-shape arrays/objects/typed-arrays become WASM locals; scratch a function doesn't return is freed on exit (no manual cleanup).
- Loops — invariant hoisting, CSE, typed-array address reuse, induction-variable strength reduction, small fixed-count unrolling (mat4, biquad).
- SIMD-128 — independent iterations (
a[i] = a[i]*2 + b[i]) run several lanes at once: lane-pure maps, reductions (sum/product/min·max), conditional maps (bitselect), byte scans (memchrviai8x16). Loops that look back (a[i-1]) or carry a running total stay sequential. - Smaller encoding — tree-shaking, copy-propagation + dead-store elimination, local/string-pool reordering for 1-byte indices, pointer-call specialization, constant pooling; JS strings you only read aren't copied.
Codegen also adapts to the target: host: 'js' lowers console/timers to tiny env.* imports, a constant JSON.parse folds to a literal, JS strings stay zero-copy. Levels 0–3 or 'size'/'balanced'/'speed' (or a per-pass object): 'balanced' (= 2) is the default; 'speed' trades size for inlined constants and larger buffers; 'size' drops unrolling and SIMD.
How does jz work?
A source string flows through six stages into wasm bytes — no IR leaves the process, the whole thing is one pass per compile():
your .js
│ parse jessie parser (subscript) → AST
│ jzify lower legacy JS to the canonical subset (var/function/class/==/…)
│ prepare resolve & bundle imports, normalize the AST
│ compile type inference (i32 vs f64) + emit WAT IR; module/ handlers lower operators
│ optimize WAT-level passes — CSE, DCE, const-fold, inline, peephole
│ encode watr: WAT → WASM binary
▼
.wasm
Each stage lives in its own place: parsing in subscript's jessie grammar, jzify/ for the legacy-JS lowering, src/prepare/ for module bundling, src/compile/ for inference + codegen (with built-ins in module/ and heap layout in src/abi/), src/optimize/ + src/wat/ for the WAT passes, and watr for the final encode. Shared compile state is one ctx object (src/ctx.js).
Why no type annotations?
Because let x: i32 isn't valid JS — annotations would break the promise that valid jz runs and tests as plain JS. So jz reads the types from signals you already write:
export let bits = (a, b) => a | b // i32 — a bitwise op pins both operands
export let half = (n) => n * 0.5 // f64 — 0.5 isn't an integerLiterals (0 vs 0.5), operators (| << & ⇒ i32), and how a value is used pin it to i32, f64, string, object, or typed array. Anything still ambiguous stays dynamic — always correct, just type-checked at runtime (a little slower).
Is jz production-ready?
It's experimental (pre-1.0) — the supported subset and the wasm ABI may still change, so pin a version and re-test on upgrade. What's solid: every push runs the full test suite, the test262 conformance subset, the benchmark gate, and the self-host build in CI, so regressions surface immediately.
Can I compile in the browser or a Worker?
Yes. The compiler is pure and synchronous (no I/O — you hand it the sources), so it runs anywhere JavaScript does — main thread, a Web Worker, or a build step — and compiling a kernel takes single-digit-to-tens of milliseconds, fast enough to do on the fly. The .wasm it produces is just a module: instantiate it in any WebAssembly host — browser main thread, Web/Service Worker, Node/Deno/Bun, or a standalone engine.
Can jz compile itself?
Yes — fully. jz compiles its own entire source to dist/jz.wasm: the whole pipeline (parse → jzify → prepare → compile → encode) runs inside WASM, taking a source string and returning wasm bytes with no host help. In other words, dist/jz.wasm is jz compiled by jz.
npm run test:self is the CI gate — it builds dist/jz.wasm, then round-trips real programs through the in-wasm compiler and runs their output, proving the wasm-hosted compiler produces working modules.
Can I compile jz to C?
jz program.js -o program.wasm
wasm-opt -O3 program.wasm -o program.opt.wasm
wasm2c program.opt.wasm -o program.c
cc -O3 program.c -o programThe full native pipeline (jz → wasm-opt -O3 → wasm2c → clang -O3 -flto + PGO) lowers to standalone native code that beats V8 on the watr example corpus (19/21 wins, 2 ties, M4 Max). Details and the regression gate live in scripts/native/README.md.
Geomean speed across the bench corpus →.
Local snapshot (M4 Max, darwin/arm64). Bun/Zig/Rust/Go/NumPy rows are hand-run reference points.
game-of-life — Conway's Life straight into shared pixel memory. |
lenia — continuous cellular automaton; smooth-kernel "digital life". |
reaction-diffusion — Gray-Scott; organic coral / labyrinths. |
interference — two-source wave field, recomputed every frame. |
plasma — FBM domain-warp; the classic flowing shader plasma. |
chladni — Camerata-style plate; frequency sweeps the nodal figure. |
mandelbrot — escape-time fractal with smooth coloring. |
attractors — de Jong map, millions of iters → luminous curves. |
raymarcher — an SDF sphere field; Shadertoy on the CPU. |
rfft — live log/mel spectrogram from a jz real FFT. |
zzfx — the unmodified ZzFX sfx synth, compiled as-is. |
jukebox — looping procedural-jazz arpeggio floatbeat; tap to play/pause. |
From small, fast JS subset to full JS spec, bundled engine:
- AssemblyScript — TS-like dialect → WASM; small, fast output, but needs type annotations (not JS).
- awasm-compiler — reproducible WASM assembled through a typed builder API.
- Porffor — AOT JS→WASM (and C) targeting the full spec, grown against test262.
- jawsm — JS→WASM in Rust on WasmGC; no interpreter, but leans on the engine's GC.
- Javy — embeds QuickJS; runs almost any JS, but ships a full interpreter (large, interpreter-speed).
- ComponentizeJS / jco — WASM Component via embedded SpiderMonkey; standards-complete, but bundles a JS engine.
- subscript — JS parser. Minimal, extensible, builds the exact AST jz needs. Jessie subset keeps the grammar small and deterministic.
- watr — WAT to WASM compiler. Binary encoding, validation, and peephole optimization. jz emits WAT text, watr turns it into valid
.wasm.
MIT • ॐ