A JavaScript engine written in pure Zig, with a JavaScriptCore C-API-compatible surface. No JSC, no V8, no external C libraries — just Zig.
zig-js is a small, embeddable engine for Zig applications, tools, and runtimes that want to own their JS stack. Use it directly as a Zig module, or link it in place of JavaScriptCore.framework when a host already targets the JSC C API.
It tracks the ECMAScript spec closely and is graded against the real tc39/test262 corpus — currently 41,898 / 47,928 (87.4%) of the scored "can we run it" tests pass. See Conformance for the full breakdown.
const js = @import("js");
const ctx = try js.Context.create(allocator);
defer ctx.destroy();
const v = try ctx.evaluate("let x = 40; x + 2");
// v == .{ .number = 42 }- How it works
- Conformance
- Performance
- Language & runtime coverage
- Using it
- Architecture
- Build & test
- Multithreading roadmap
- License
The engine has two execution tiers that share one object model, so behavior is identical no matter which runs:
- A tree-walking interpreter — the correctness oracle and the fallback for anything not yet lowered.
- A suspendable stack bytecode VM — lowers the hot subset of the language plus generators, async functions, and async generators (their bodies must suspend/resume, so they run only on the VM).
Top-level and function code compiles to bytecode and runs on the VM; any construct the compiler can't yet lower transparently falls back to the tree-walker. A shared microtask queue drives Promises and async jobs.
Status: maturing. Most of the language and the core built-in library are implemented and spec-faithful enough to satisfy test262's
propertyHelper(brand checks, attribute fidelity, exact error types). The main gaps areIntl/CLDR locale data,Temporaledge cases, full regex-engine coverage, and a handful of early-error subsystems.
Measured by zig build test262 against the pinned tc39/test262 submodule. The score is split on two honest axes so a weak parser can't flatter itself — valid tests measure whether we can run a program, negative tests measure strictness (rejecting invalid input). Mixing them lets a parser "pass" negatives by failing to parse valid code too, so they're kept apart:
| axis | meaning | passing |
|---|---|---|
| valid | can we run the program? (scored corpus) | 41,898 / 47,928 (87.4%) |
| negative | do we reject invalid input? (early errors — partial) | 3,213 / 4,668 (68.8%) |
Of the valid corpus: 119 parse failures, 5,911 runtime failures, 0 host failures. The runner currently skips 581 tests that need more harness work (top-level-await modules, some async-harness protocols, unloadable includes). Remaining valid failures concentrate in intl402 (CLDR data), Temporal edge cases, Annex B, and the regex engine.
| area | passing | area | passing |
|---|---|---|---|
language |
17,304 / 19,070 (90.7%) | Object |
3,353 / 3,411 (98.3%) |
Array |
2,958 / 3,081 (96.0%) | RegExp |
1,482 / 1,687 (87.8%) |
String |
1,118 / 1,223 (91.4%) | TypedArray |
1,434 / 1,446 (99.2%) |
TypedArrayConstructors |
729 / 738 (98.8%) | Uint8Array |
70 / 70 (100%) |
Map |
204 / 204 (100%) | Set |
381 / 383 (99.5%) |
BigInt |
77 / 77 (100%) | Symbol |
98 / 98 (100%) |
Boolean |
51 / 51 (100%) | Math |
327 / 327 (100%) |
DataView |
561 / 561 (100%) | Number |
340 / 340 (100%) |
WeakSet |
85 / 85 (100%) | WeakMap |
141 / 141 (100%) |
WeakRef |
29 / 29 (100%) | FinalizationRegistry |
47 / 47 (100%) |
Temporal |
3,431 / 4,603 (74.5%) | intl402 |
1,442 / 3,341 (43.2%) |
annexB |
961 / 1,071 (89.7%) | staging |
694 / 1,028 (67.5%) |
SharedArrayBuffer |
103 / 104 (99.0%) | ArrayBuffer |
216 / 221 (97.7%) |
Atomics |
308 / 388 (79.4%) | — | — |
SuppressedError |
22 / 22 (100%) | ThrowTypeError |
14 / 14 (100%) |
AbstractModuleSource |
8 / 8 (100%) | AggregateError |
25 / 25 (100%) |
parseFloat |
54 / 54 (100%) | parseInt |
55 / 55 (100%) |
decodeURI |
55 / 55 (100%) | decodeURIComponent |
56 / 56 (100%) |
encodeURI |
31 / 31 (100%) | encodeURIComponent |
31 / 31 (100%) |
AsyncIteratorPrototype |
9 / 9 (100%) | eval |
10 / 10 (100%) |
global |
29 / 29 (100%) | Function |
509 / 509 (100%) |
Proxy |
310 / 310 (100%) | Reflect |
153 / 153 (100%) |
zig build test262prints each subtree's pass rate plusparse-fail/runtime-fail/host-failcounts, so the work stays data-driven.zig build conformancekeeps a separate 33/33 always-green smoke suite for fast iteration. Refresh the corpus withgit submodule update --remote test262.
Each tier is gated by test262 (never regress correctness for speed) and timed by zig build bench:
| tier | what | status | vs tree-walk |
|---|---|---|---|
| 0 | tree-walk interpreter | ✅ | 1× (baseline) |
| 1 | stack bytecode VM — lowers nearly the whole language (objects, arrays, members, new, methods, ++, instanceof) |
✅ | ~1.1× |
| 2 | slot-allocated locals + frame-linked closures — params/locals resolved to a flat frame array at compile time | ✅ | 1.3–1.85× |
| 3 | object shapes (hidden classes) + inline caches — shared shape-transition tree, flat slots, monomorphic IC per property site | ✅ | 1.6–1.7× |
| 4 | NaN-boxed values | next | — |
| 5 | generational GC (replaces the arena) | planned | — |
| 6 | baseline → optimizing JIT | planned | — |
Tier-2 nearly doubled compute/call-heavy code; tier-3 brought object-property churn from a 1.33× laggard up to 1.73× (objects no longer allocate a per-instance hashmap, and repeat property access is an inline-cache hit). The tree-walker remains the oracle and the fallback for not-yet-lowered constructs.
Literals & operators — numbers (int/float/hex/octal/binary/exp, spec ToString), strings (full escape set incl. \u{…}), true/false/null/undefined, objects (shorthand, computed keys, getters/setters, spread), arrays (incl. holes/sparse), regex literals, template literals + tagged templates; the full operator set incl. **, ??, ?., &&=/||=/??=, bitwise/shift, in/instanceof/typeof/delete/void, comma.
Bindings & scope — var/let/const, block scoping + TDZ, destructuring (array/object, defaults, rest) in declarations, parameters, and assignment; with; eval (direct & indirect).
Functions — declarations/expressions (incl. named-expression self-binding), arrows, default/rest params (including destructuring rest), arguments (mapped & unmapped), closures, new, new.target, getters/setters; Function.prototype call/apply/bind/toString.
Classes — fields, private members + methods, static members + blocks, accessors, super (calls and member access), derived constructors, extends.
Generators & async — function* + yield/yield* (with throw/return delegation, destructuring-assignment-with-yield), async functions + await, async function* + for await … of — all driven on the suspendable VM.
Control flow — if/else, while/do…while, for/for-in/for-of, switch, labels, break/continue, throw/try/catch/finally.
Modules — import/export (default, named, namespace, re-export, export *), graph linking with live bindings and live namespace objects (see Conformance for scoring status).
Built-in library — Object, Function, Array (incl. holes/sparse, fromAsync, freeze/seal), String + a homegrown RegExp backed by zig-regex, Number, Boolean, Math, JSON, Symbol (+ well-known symbols), Map/Set/WeakMap/WeakSet, Promise (combinators, subclassing/species, microtask ordering), Date, the Error family, Proxy/Reflect, globalThis, typed arrays + ArrayBuffer/SharedArrayBuffer/DataView/Atomics, WeakRef/FinalizationRegistry, and partial Temporal + Intl. Each is brand-checking and attribute-faithful enough to satisfy test262's propertyHelper.
const js = @import("js");
const ctx = try js.Context.create(allocator);
defer ctx.destroy();
const v = try ctx.evaluate("let x = 40; x + 2");
// v == .{ .number = 42 }Link libzig-js.a in place of JavaScriptCore.framework. The exported symbols match Apple's <JavaScriptCore/JSValueRef.h> / <JSObjectRef.h>:
JSGlobalContextRef ctx = JSGlobalContextCreate(NULL);
JSStringRef script = JSStringCreateWithUTF8CString("1 + 1");
JSValueRef result = JSEvaluateScript(ctx, script, NULL, NULL, 0, NULL);
double n = JSValueToNumber(ctx, result, NULL); // 2.0Implemented C-API symbols:
- Context lifecycle —
JSGlobalContextCreate/Release/Retain,JSContextGetGlobalObject,JSEvaluateScript,JSGarbageCollect. - Value inspection —
JSValueGetType,JSValueIs*,JSValueIsEqual/StrictEqual. - Constructors & coercion —
JSValueMake*,JSValueTo*,JSValueProtect/Unprotect. - Objects —
JSObjectMake,JSObjectMakeArray,JSObjectGet/SetProperty,JSObjectGetPropertyAtIndex,JSObjectCallAsFunction,JSObjectCallAsConstructor,JSObjectMakeFunctionWithCallback,JSObjectIsFunction/IsConstructor. - Strings —
JSStringCreateWithUTF8CString,JSStringRetain/Release,JSStringGetLength,JSStringGetUTF8CString.
JSObjectCallAsFunction/CallAsConstructor drive the interpreter, so JS functions and the built-in Error constructors are callable across the C boundary; thrown JS values surface as the C-API exception out-param. JSObjectMakeDeferredPromise raises a NotImplemented exception until the deferred-promise plumbing lands.
┌─► compiler ─► bytecode ─► VM ──┐ (hot subset + generators/async)
source ─► lexer ─► parser ─┤ ├─► Value
(AST) └─► tree-walk interpreter ───────┘ (oracle + fallback)
│
c_api.zig (JSC drop-in exports)
| file | responsibility |
|---|---|
src/value.zig |
Value union + ToBoolean/ToNumber/ToString/typeof, equality, Object (shapes, per-index attrs, accessors, array elements/holes) |
src/lexer.zig |
single-pass tokenizer |
src/ast.zig |
unified expression/statement/module node |
src/parser.zig |
recursive-descent + precedence climbing (parseProgram / parseModule) |
src/interpreter.zig |
tree-walking evaluator, environments, and the built-in library |
src/compiler.zig |
AST → stack bytecode (functions, generators, async) |
src/bytecode.zig |
instruction set + chunk/function templates |
src/vm.zig |
the suspendable bytecode VM (frames, generators, async drivers) |
src/shape.zig |
hidden-class (shape) transition tree |
src/promise.zig |
Promise state machine + microtask queue |
src/context.zig |
engine instance (arena, persistent global env, module loader/linker) |
src/jsstring.zig |
refcounted JSStringRef backing |
src/c_api.zig |
the exported JavaScriptCore C-API symbols |
src/root.zig |
@import("js") entry point |
Requires Zig 0.17.0-dev.
zig build # builds libzig-js.a (the JSC drop-in)
zig build test # runs the unit + C-API test suite
zig build conformance # runs the always-green smoke suite (33/33)
zig build test262 # runs the real tc39/test262 corpus, prints pass %
zig build test262 -Dtest262=DIR # …with an explicit corpus root
zig build bench # times the bytecode VM against the tree-walkerThe test262 corpus is vendored as the test262/ git submodule (git submodule update --init); zig build test262 uses it by default and skips cleanly if it isn't present. For speed it runs ReleaseFast under subprocess isolation, so a single pathological test can't abort the run.
Today a Context is single-thread-affine: the interpreter, VM, global object graph, environments, microtask queue, and arena allocator all assume one mutating thread. The first multithreaded target should be isolated JavaScript agents, not shared mutable ordinary objects.
To get there:
- Thread-affinity contract — make
Contextownership explicit, reject accidental cross-thread use, and document which C-API handles are agent-local. - Worker agents — one
Contextper OS thread with its own global object, realms, job queues, allocator state, and module loader hooks. - Structured clone & transfer —
structuredClone, message passing, ArrayBuffer transfer/detach, and the host hooks for worker lifecycle and cancellation. - Shared-memory baseline — finish
SharedArrayBuffer, typed-array views over shared storage,Atomics,Atomics.wait/notify, and the real test262$262.agentharness. - Heap & lifetime model — replace or contain the arena before shared lifetimes leak between agents; a future GC needs clear rooting, write barriers, and cross-agent ownership rules.
- Scheduler & queues — separate per-agent microtask queues from host task queues, define blocking behavior for waits, and keep promise jobs deterministic within each agent.
- Concurrency tests — stress transfer/detach races, shared typed-array atomics, worker teardown, and host callback reentrancy before optimizing.
The TC39 structs proposal (Stage 2) is worth tracking: fixed-layout structs, shared structs, and Atomics.Mutex/Atomics.Condition. Shared structs are designed to be passed between agents without copying, which makes them a good future data model for parallel JS — but they should come after the baseline worker, structured clone, SharedArrayBuffer, and Atomics stack is correct.
MIT — see LICENSE.