Builtins: the TypeScript subset and the leaf rule
Array.prototype.map, JSON.stringify, Promise.all, Object.defineProperty: a
JavaScript engine is mostly its standard library, and most of a codemode script’s time
is spent in it. This page explains how djs writes its builtins, why almost none of them
are hand-written Rust, and how the hot ones still run as compiled Rust.
In short. Hand-written Rust never calls back into JavaScript. It only provides leaf intrinsics, operations that cannot run user code. Every builtin that might run user code is written in a strict subset of TypeScript and compiled by djs-bgen to bytecode that ships in the base snapshot, and, for the hot ones, also to resumable Rust state machines (
@native). Everyday calls with plain arguments skip both through@fastleaf entries.
Why builtins cannot be ordinary Rust
Almost any builtin can run user code. [1, 2, 3].map(f) calls f three times. But even
a builtin that takes no callback can run user code: Array.prototype.includes reads
length and the elements through Get, which calls getters, and it converts
fromIndex with ToIntegerOrInfinity, which may call a valueOf method. Proxies make
every property read a possible call.
In a conventional engine, such a builtin is a C or Rust function that calls the
interpreter recursively whenever it reaches user code. djs cannot do that. A codemode
script must be stoppable after any instruction, including the instructions of a map
callback (the goal to suspend anywhere). When the
interpreter stops, everything it was doing must be in the arena, so that the VM can be
copied into an image and resumed elsewhere. A Rust function halfway through its loop has
state on the Rust stack, and that state cannot be saved.
So djs has one rule:
The leaf rule. Hand-written Rust never calls back into JS. Hand-written Rust is limited to leaf intrinsics (
%ArrayPush,%StringIndexOf,%DefineOwnPropertyFromRecord, …), which never run user code. Every builtin that may run user code is written in the builtin language.
The builtin language
Builtins live in builtins/*.ts, about a thousand functions in a strict subset of
TypeScript. A builtin does the conversions and checks in spec order, where user code can
run, and hands the actual work to leaf intrinsics. Here is Array.prototype.includes
from builtins/array.ts:
/**
* Array.prototype.includes ( searchElement [ , fromIndex ] ), 23.1.3.16.
* @builtin Array.prototype.includes
* @fast
*/
function ArrayPrototypeIncludes(this: Value, searchElement: Value, fromIndex?: Value): boolean {
const o: Obj = ToObject(this);
const len: f64 = LengthOfArrayLike(o);
if (len === 0) {
return false;
}
let n: f64 = ToIntegerOrInfinity(fromIndex);
if (n < 0) {
n = len + n;
if (n < 0) {
n = 0;
}
}
const found: f64 = ArraySearchDense(o, searchElement, n, len, 2);
if (found !== -2) {
return found !== -1;
}
for (let k: f64 = n; k < len; k++) {
if (SameValueZero(Get(o, k), searchElement)) {
return true;
}
}
return false;
}
It reads like the spec, step by step. LengthOfArrayLike, ToIntegerOrInfinity and
Get may run user code. ArraySearchDense is a leaf (D1801): it searches the array’s
dense storage in Rust in one go when every element it looks at is a plain data element,
and returns -2 when it cannot answer (a hole, an accessor, an object that is not an
Array), so the loop below it handles the general case.
The subset is small on purpose (D1171):
- Only top-level functions with explicit types:
Value,Obj,Str,Sym,Fn,boolean, and the numeric typesi32,u32andf64. Numbers never mix types implicitly, so both backends (below) agree on every result. - No property access, object literals, closures,
new,switch,typeofor generators.o.xisGet(o, "x"), and user functions are called throughCall(f, thisArg, ...). If something is awkward without them, the answer is a new intrinsic. - Conditions are booleans: there is no truthiness.
Intrinsics are declared in builtins/intrinsics.d.ts, each tagged @leaf (never runs
user code) or @reentrant (may call exactly one user function, whose result completes
the operation, such as Get calling a getter). Today about 280 are leaves and about 30
are reentrant. Builtins tagged @intrinsic join them, so that the interpreter’s slow
paths and other builtins can call them as %Name(...). They are all numbered in one
table (D1270); the intrinsics reference lists them.
A call to a reentrant intrinsic or another reentrant builtin is a resume point: the
builtin may stop there and resume later. djs-bgen finds every resume point and lists
them in builtins/gen/resume-points.txt, which is checked in, so a review shows when a
builtin starts calling user code.
Trade-off. A subset of TypeScript over hand-written Rust builtins. The subset keeps every builtin resumable: its state lives in frames in the arena, never on the Rust stack. Builtins read like the spec they implement, and editors type-check them with plain
tsc. The cost: a compiler to maintain (djs-bgen), a style without property syntax or closures, and builtins that run as interpreted bytecode unless they get one of the faster paths below.
Trade-off. Our own parser for the subset over the TypeScript compiler or oxc (D1170). djs-bgen is a dependency-free Rust crate with a hand-written parser and type checker, so regenerating needs only cargo, and accepting only the subset is most of the checking. The cost: its own parser and messages to maintain, and no full-TS tooling inside bgen.
Two backends from one source
djs-bgen compiles every builtin twice.
The bytecode backend covers every builtin. bgen erases the types and writes
builtins/gen/prelude.js: plain JavaScript with %Name(...) intrinsic calls, an
installation call per builtin (%InstallMethod, %InstallConstructor, …, D1172,
D1310), and the list of @native and @fast builtins. djs compiles it in privileged
mode, the only mode in which %Name(...) compiles: to the call_intrinsic opcode.
| opcode | byte | immediates | size | pop → push | cost | description |
|---|---|---|---|---|---|---|
call_intrinsic | 157 | Intrinsic, Argc | 5 | imm1 → 1 | Call | a1 .. an -> r: privileged code only. |
The prelude runs once, when the base snapshot is built (D1321). Every VM starts as a copy of that image, with all builtins installed and compiled, so no VM ever compiles the prelude. Privileged templates keep no line tables or local names, which saves space in that image (D1430, D1980).
The Rust backend is opt-in. A builtin tagged @native (ten of them: push, sort
and its merge, JSON.stringify’s slow path, promise reaction jobs, …) is also
compiled to a Rust state machine in
crates/djs-core/src/builtins/gen/natives.rs (D1632). bgen splits the function into
basic blocks, and each block becomes one arm of a match on the state. A resume point
ends a block. Here is the loop of Array.prototype.push for several items, excerpted
from the generated file. The source is the plain loop
for (let i = 0; i < argCount; i++) { Set(o, len, Get(items, i)); len++; }:
crates/djs-core/src/builtins/gen/natives.rs, lines 108–125:
7 => {
if (l4f < l2f) { st = 8; continue; } else { st = 10; continue; }
}
8 => {
// resume point: Set
match { let a0 = ex.n_slot(0); let a1 = ex.n_slot(1); let a2 = { let a0 = ex.n_slot(4); ex.n_rest_get(0, a0) }; ex.n_set(&[a0, a1, a2], 11) }? { Flow::Inline => { st = 11; continue } Flow::Leave(s) => return Ok(s) }
}
9 => {
let rv = (l4f + 1.0_f64); l4f = rv; ex.n_set_slot(4, num(rv));
if ex.n_backedge(7) { return Ok(None); } st = 7; continue;
}
10 => {
// resume point: Set
match { let a0 = ex.n_slot(0); let a1 = ex.n_str("length")?; let a2 = ex.n_slot(1); ex.n_set(&[a0, a1, a2], 12) }? { Flow::Inline => { st = 12; continue } Flow::Leave(s) => return Ok(s) }
}
11 => {
ex.n_result();
let rv = (l1f + 1.0_f64); l1f = rv; ex.n_set_slot(1, num(rv));
A native builtin runs in a native frame: an ordinary frame record in the arena with
a header bit set, its state in place of the program counter and its locals in the
frame’s slots (D1631). A typed local such as the counter i (l4f above) also lives in
a Rust variable, but every write goes to the slot too (D2000), so the frame always holds
the whole state. The common case stays in the same Rust loop. State 8’s Set is a
resume point: on an Array it completes inline (Flow::Inline) and the loop goes on to
state 11. When a call needs a JavaScript frame (a setter, a proxy trap), the native
writes the callee’s frame and returns to the interpreter (Flow::Leave), which runs it
and later re-enters the state machine at the resume state. A loop back-edge
(n_backedge) charges fuel and is a safepoint, so a native loop over a million elements
can stop and be saved in the middle, like bytecode.
The callback loops of forEach, map, filter, some, every, find, findIndex
and reduce are bytecode builtins. Their dense part is one intrinsic, %EachDense
(D2303). It enters the callback’s frame once and, on each return, completes the step
(store, test, accumulate) and enters the same frame again for the next element. No
native state machine runs per element, and the builtin’s own spec loop takes over at the
first hole, proxy or other array. That made these builtins about twice as fast per
element as their native versions, and dropping them from the Rust backend left the
module smaller.
Two rules keep this safe:
- No collection inside a state arm. An allocation never collects; it only sets a pending flag. The collector runs at state transitions (back-edges, and calls that return to the interpreter), where every value the builtin holds is in a frame slot. The one exception is collect-and-retry at the heap cap (D1900): a native operation that hits the cap collects and runs again, but only at points where bgen knows that the generated code holds no other heap value in a Rust local.
- Fuel per transition. A native frame charges 4 fuel per back-edge and 2 per call (D1633). Bytecode charges per instruction, so the fuel a run uses depends on the backend. Results do not.
Both backends must behave the same, and they are tested against each other: djs run,
the difftest runner and the test262 runner take --backend bytecode|native, and the
gate runs the corpus with both (D1635, How djs is tested).
Trade-off. An opt-in Rust backend over making every builtin native. Generated Rust runs much faster than the same builtin as bytecode (when it landed, 3.4 times for
mapand 5.3 times on the higher-order benchmark), but every native function adds wasm code, and wasm size is part of startup (fast startup in wasm). The native API is kept out of line in wasm to limit that cost (D1634), andscripts/native-sizes.shprints the size per function. A builtin becomes@nativeonly when a benchmark shows that it is hot. The cost of having the option: two implementations of every native builtin’s semantics to keep equal, which is why both are tested in the gate.
@fast: leaf entries for everyday calls
Many hot builtins rarely meet user code: "a,b".split(","), xs.includes(3),
Math.max(a, b), xs.push(v). Even as native code, entering a frame and running
ToObject and LengthOfArrayLike costs more than the work itself. A builtin tagged
@fast has a hand-written Rust entry of the same name in
crates/djs-core/src/builtins/fast.rs (D1850). With the native backend, a call first
runs the entry, which either returns the builtin’s result or declines, and then the
builtin runs as usual. About 80 builtins have one.
An entry is allowed to answer only when nothing observable can happen: primitive
arguments, dense arrays whose elements are plain data, no proxies. Every case that would
throw declines, so errors and their stack traces always come from the builtin itself. An
entry that reaches the heap cap declines too (D1910), so that the builtin’s own
allocations collect and retry. A builtin is @fast or @native, not both, except
Array.prototype.push (D2020): the leaf covers the common case, and the bytecode
version handles the rest.
Here is the slow path at work. The array has a getter at index 1, so includes cannot
use its fast entry or its dense leaf and runs the getter from its loop. The map
callback runs inside the native map, and its stack trace shows the native frame:
const arr = [1, 2, 3];
Object.defineProperty(arr, 1, { get() { console.log("getter ran"); return 2; } });
console.log(arr.includes(2));
const doubled = [1, 2, 3].map((x) => {
if (x === 2) console.log(new Error("in the callback").stack.trimEnd());
return x * 2;
});
console.log(doubled);
getter ran
true
at <anonymous> (example.js:5:37)
at map (native)
at <eval> (example.js:4:30)
[2,4,6]
function 0 <anonymous>: params=2 locals=4 stack=6 captures=0
handler [9, 132) -> 132 depth 0
0 get_arg arg1
3 new_box_init loc0 (console)
6 async_start loc1 (<state>)
9 push_i8 1
11 push_i8 2
13 push_i8 3
15 array_from 3
18 put_loc loc2 (arr)
21 get_global "Object" ic0 ; 2:1
28 get_method "defineProperty" ic1 ; 2:7
35 get_loc loc2 (arr) ; 2:23
38 push_i8 1
40 object
41 fclosure child0
46 define_method "get"
51 call 3 ; 2:22
54 drop
55 get_box loc0 (console) ; 3:1
58 get_method "log" ic2 ; 3:8
65 get_loc loc2 (arr) ; 3:13
68 get_method "includes" ic3 ; 3:16
75 push_i8 2
77 call 1 ; 3:25
80 call 1 ; 3:12
83 drop
84 push_i8 1
86 push_i8 2
88 push_i8 3
90 array_from 3
93 get_method "map" ic4 ; 4:26
100 fclosure child1
105 call 1 ; 4:30
108 put_loc loc3 (doubled)
111 get_box loc0 (console) ; 8:1
114 get_method "log" ic5 ; 8:8
121 get_loc loc3 (doubled) ; 8:13
124 call 1 ; 8:12
127 drop
128 undefined
129 async_return loc1 (<state>)
132 async_reject loc1 (<state>)
function 0.0 get: params=0 locals=0 stack=3 captures=1
0 get_cap cap0 (console) ; 2:41
3 get_method "log" ic0 ; 2:48
10 push_const #0 ("getter ran")
15 call 1 ; 2:52
18 drop
19 push_i8 2
21 return
22 return_undef
function 0.1 <anonymous>: params=1 locals=0 stack=4 captures=1
0 get_arg arg0 ; 5:7
3 push_i8 2
5 strict_eq ; 5:9
6 jump_if_false -> 57
11 get_cap cap0 (console) ; 5:16
14 get_method "log" ic0 ; 5:23
21 get_global "Error" ic1 ; 5:32
28 push_const #0 ("in the callback")
33 construct 1 ; 5:37
36 get_field "stack" ic2 ; 5:56
43 get_method "trimEnd" ic3 ; 5:62
50 call 0 ; 5:70
53 call 1 ; 5:27
56 drop
57 get_arg arg0 ; 6:10
60 push_i8 2
62 mul ; 6:12
63 return
64 return_undef
Run the example to walk the VM's arena.
Run the example to see it suspend and snapshot.
Trade-off. Hand-written fast entries over making the generic path faster. An entry does in a few Rust instructions what the builtin does in dozens of steps, for the calls codemode scripts make most. The cost: each entry duplicates its builtin’s logic for the cases it accepts, and nothing but tests keeps them in sync. The corpus checks the entries against QuickJS, including every case where they must decline, and the gate’s bytecode-backend run, which never takes them, keeps testing the TypeScript path.
Where to look
builtins/*.tsand builtins/intrinsics.d.ts: the sources and the intrinsic declarations (reference).crates/djs-bgen/: the parser, checker, resume-point analysis (analysis.rs) and the two emitters (emit_js.rs,emit_rust.rs).builtins/gen/: the generated prelude and resume-point report;crates/djs-core/src/builtins/gen/natives.rs: the generated state machines.crates/djs-core/src/interp/run/native.rs: native frames;crates/djs-core/src/builtins/fast.rsandbuiltins/dense.rs: the@fastentries and dense leaves.
Decisions on this page:
- D1170: djs-bgen is Rust with its own parser for the subset
- D1171: Builtin subset rules
- D1172: Generated prelude format and installation
- D1270: Intrinsic ids
- D1310: Builtin installation forms
- D1321: The base snapshot (djs-prelude)
- D1430: Privileged code keeps no line tables or local names
- D1631: Native frames are ordinary frame records with a header bit
- D1632: The bgen Rust backend: states are basic blocks, calls go through call_intrinsic
- D1633: Fuel per transition; the backend is part of the engine’s identity
- D1634: Code size: the native API is out of line in wasm
- D1635: Both backends are tested
- D1801: Dense-array leaves for the element loops of Array builtins
- D1850: Leaf fast paths for everyday builtins (
@fast) - D1900: Native builtins collect and retry at the heap cap
- D1910: Dense-array loops, fromCharCode and the URI functions as
@fast; entries decline at the heap cap - D2000: Typed locals of native builtins live in Rust variables, written through
- D2020:
@fastbuiltins are not also@native, except Array.prototype.push - D2060: The base is decoded or injected straight into the first VM’s arena
- D2090: Fused dense HasProperty + Get in natives; one-pass string search and in-arena joins