staticjs is a JavaScript engine for a pure, total subset of the language: every program it accepts ends, and gives the value Node gives or a fault.
[
{ id: 1, open: true, total: 120, due: "2024-02-10" },
{ id: 2, open: false, total: 80, due: "2024-01-10" },
{ id: 3, open: true, total: 300, due: "2024-02-25" },
]
.filter((order) => order.open)
.toSorted((a, b) => b.total - a.total)
.map((order) => ({
id: order.id,
late: Temporal.PlainDate.from(order.due)
.until("2024-03-01").days,
}))[{"id":3,"late":5},{"id":1,"late":20}]Like JavaScript, staticjs has numbers, BigInt, strings, template literals, arrays, objects, Set, Map, Temporal and Date, arrow functions, destructuring with defaults and rest, spread, optional chaining and the usual operators. A program is one expression, or a module of imports, const declarations and exports. Unlike JavaScript, it has no loops and no recursion.
staticjs leaves out loops (for, while, do), recursion, classes, async/await and promises, generators, try/catch/throw, var, labels, switch, eval, this, prototypes, getters and setters, and I/O. A function may change only a value it owns, such as an array it made; shared values never change.
What staticjs gives for four valid JavaScript programs:
[1, 2] === [1, 2] // fault: === on two objects compares identity, which staticjs does not have
"5" * 2 // fault: arithmetic in staticjs is on numbers
({}).a.b // fault: cannot read a property of undefined
() => { for (;;) {} } // refused when compiled: staticjs has no loopsstaticjs is freestanding C11: it needs no C library, only stdint.h, stddef.h and stdbool.h, and has one public header, staticjs.h.
Static and shared libraries for Linux and macOS on x86_64 and arm64. Link with any C11 compiler.
cc -std=c11 -Iinclude app.c lib/libstaticjs.astaticjs.c and staticjs.h, two files to add to a C project. They build with clang.
clang -O2 -I. app.c staticjs.cA WebAssembly module for browsers and Node. It imports nothing and brings its own allocator.
await WebAssembly.instantiate(bytes, {})On a 4-core x86_64 machine, evaluating a compiled small expression from C takes about 0.14 µs, and compiling it takes 6.4 µs. Node’s vm.Script.runInContext takes 1.4 µs for the same expression, and creating a fresh V8 context takes 330 µs. Over 33 evaluation cases, Node with its JIT takes 0.43 of staticjs’s time, so Node’s JIT is faster. Node with --jitless takes about as long as staticjs, and QuickJS-NG compiled to WebAssembly takes 4.8 times as long. Benchmark report
staticjs and QuickJS-NG, both compiled to WebAssembly, run eight programs in a web worker, and a row is timed only when both engines return the same result.
Waiting to start.
Each time is the median of five samples of at least 40 ms. staticjs is called from JavaScript once per run, as a host calls the C API. QuickJS-NG (quickjs-wasi) loops inside its engine and is called once per sample.
staticjs is basically CEL with JavaScript syntax and user functions.
| staticjs | QuickJS | CEL | Starlark | |
|---|---|---|---|---|
| Syntax | Subset of JavaScript | All of JavaScript (ES2025) | C-like syntax of its own | Dialect of Python |
| Functions in programs | Arrow functions | All JavaScript functions | None; the host provides them | def functions |
| Loops | None | All JavaScript loops | None; macros such as all, map, filter | for over finite sequences |
| Every evaluation ends | Yes; fuel the host pushes | No; the host’s interrupt handler stops it | Yes; not Turing complete | Yes; no recursion, no unbounded loops |
| What a program can reach | Values and modules the host passes in | — | Variables and functions the host provides | Hermetic |
| Determinism | Same value and fuel on every machine | — | — | Deterministic |
| Memory limits | Host’s allocator; limits set by the host | A memory limit on the runtime | — | — |
| Coercion and identity | Faults on both | Follows JavaScript: "5" * 2 is 10 | — | — |
| Implementations | Freestanding C11, WebAssembly | A few C files; QuickJS-NG fork | Go, Java, C++ | Go, Java, Rust |
staticjs has four stages: the parser, the compiler, the evaluator and the writer. A caller pushes into each one: bytes into the parser, which sends events to a sink, operands before their operator; the compiler is that sink and makes bytecode as the events arrive; fuel into the evaluator; buffer space into the writer. None blocks or calls back for more input, so a host can stream source in chunks, and there is no recursion anywhere in the C code.
The compiler also tags functions whose shape a built-in can run in C, such as x => x.key or (a, b) => a.k - b.k. A tagged function saves fuel and never changes a value. A host can pause an evaluation and read its frames, for example the program, map at element 2 of 4, and the callback, each with a position.
The program below runs in this page one unit of fuel at a time, and after each unit the page reads the evaluation’s frames. The comparator (a, b) => a - b has a shape that toSorted runs in C, so it opens no frame of its own.
[[3, 1], [2]].map((row) => row.toSorted((a, b) => a - b).map((n) => n * 10))Running the program…
The explorer runs staticjs.wasm in the browser and shows the parser’s events, the compiler’s functions and shapes with the fuel used with and without them, the evaluator one unit of fuel at a time with its frames, and the writer’s output in 16-byte pieces.
Open the explorer Every value is a 16-byte sjs_value: its kind, a boolean, and a number or a pointer to a reference-counted heap object. Copying the struct does not retain the object.