LambdaJS
June 18, 2026 · View on GitHub
Part of the LambdaJS detailed-design set. This document covers how a JavaScript value is represented at runtime: the
Itemtagged-value layout, the JS type ↔ LambdaTypeIdmapping, theundefined/null/TDZ/deleted sentinels, the BigInt and Symbol-key encodings, the GC heap + bump nursery memory model,JsFunctionpool allocation, the transient call-argument stack, module-variable storage, theJsRuntimeStatecapsule, and which Lambda subsystems LambdaJS reuses.Primary sources:
lambda/lambda.h/lambda.hpp(Item,Container,Map,TypeId, packing macros),lambda/lambda-data.hpp(TypeMap/ShapeEntry),lambda/js/js_runtime.h(ITEM_JS_UNDEFINED/ITEM_JS_TDZ/JS_SYMBOL_BASE/JS_DELETED_SENTINEL_VAL),lambda/js/js_runtime_internal.hpp(js_is_symbol/js_is_bigint/js_symbol_to_key/JsFunction),lambda/js/js_runtime_value.cpp(js_typeof/js_make_number/conversions),lambda/js/js_coerce.cpp(js_to_primitive),lambda/js/js_runtime_state.{hpp,cpp}(JsRuntimeState, module vars, batch reset),lambda/js/js_runtime_function.cpp(arg stack,JsFunctionallocation),lambda/lambda-mem.cpp(heap_calloc/heap_alloc/GC roots),lambda/lambda-decimal.cpp(BigInt). Audience: engine developers. Convention:file:linereferences drift; confirm against symbol names.
1. Purpose & scope
LambdaJS does not invent a value representation: every JS value is a Lambda Item (a 64-bit tagged word, lambda.h:477), allocated from the same GC heap, bump nursery and name pool that Lambda script uses, and read through the same get_type_id dispatch (lambda.hpp:293). This document is the map of that shared substrate — how the JS type lattice is projected onto TypeId, where each kind of value physically lives, and how JS-specific lifetime requirements (module variables, closure environments, call arguments) are kept reachable across a non-moving collector. The object shape machinery (Map/TypeMap/ShapeEntry, MapKind, property attributes) is layered on top of this and is owned by JS_06 — Objects, Properties & Prototypes; closure-environment structure is in JS_05 — Functions & Closures; float-boxing performance is in JS_15 — Performance & Optimization.
2. The Item tagged-value representation
Item is a union over a raw uint64_t plus a set of bitfield views and direct container pointers (lambda.hpp:88). The high byte [63:56] is the TypeId tag for scalars; the low 56 bits hold either an inline value or a pointer. Item::type_id() (lambda.hpp:159) reads the high byte; if it is zero (a container, whose pointer occupies the full word) it dereferences the pointer and reads the TypeId stored at offset 0, and a fully-zero word reads as LMD_TYPE_NULL.
Three storage classes exist:
- Packed scalars — value lives entirely in the word.
null(ITEM_NULL,lambda.h:749), booleans (ITEM_TRUE/ITEM_FALSE,:762), and small integers (ITEM_INT+ a sign-extended 56-bit payload viai2it,:774) carry no heap object.INT56_MAX/INT56_MIN(:766) bound the inline-int range;Item::get_int56()(lambda.hpp:240) sign-extends from bit 55. Sub-word numerics (i8..u32,f16,f32) use theLMD_TYPE_NUM_SIZEDlayout — value in[31:0], sub-type in[55:48](NUM_SIZED_PACK,lambda.h:216). - Tagged-pointer scalars — high byte is the tag, low 56 bits are a heap pointer:
LMD_TYPE_INT64(l2it),LMD_TYPE_FLOAT(d2it),LMD_TYPE_DECIMAL(c2it, also BigInt),LMD_TYPE_STRING(s2it),LMD_TYPE_SYMBOL(y2it),LMD_TYPE_DTIME(k2it) (lambda.h:780–786). The pointer is recovered by masking off the tag (0x00FFFFFFFFFFFFFF). - Containers — the word is the pointer (no tag byte), so
it2map/it2arr/etc. are bare casts (lambda.h:848); theTypeIdis read from the pointee's first byte. All extendstruct Container(lambda.h:525).
JS arithmetic results funnel through js_make_number (js_runtime_value.cpp:1071): an exact integer in int56 range becomes a packed ITEM_INT, otherwise a double is boxed in the nursery via heap_alloc + d2it. Two extra guards matter for JS — -0.0 is preserved as a boxed float (never collapsed to int 0), and integers with magnitude ≥ JS_SYMBOL_BASE are forced to float boxing to avoid colliding with the Symbol encoding (§4).
3. JS type ↔ Lambda TypeId mapping
The JS language types are a projection of the Lambda EnumTypeId enum (lambda.h:83). js_typeof (js_runtime_value.cpp:2077) is the authoritative mapping back to spec type names:
| JS type / value | Lambda TypeId | typeof | Notes |
|---|---|---|---|
undefined | LMD_TYPE_UNDEFINED | "undefined" | ITEM_JS_UNDEFINED; distinct from null (§5). |
null | LMD_TYPE_NULL | "object" | the typeof null quirk (:2086). |
| boolean | LMD_TYPE_BOOL | "boolean" | packed in low byte. |
| number (int) | LMD_TYPE_INT | "number" | packed int56 (unless a Symbol — see below). |
| number (non-int / boxed) | LMD_TYPE_FLOAT | "number" | heap double. |
| number (sized) | LMD_TYPE_NUM_SIZED | "number" | typed-array element reads. |
| bigint | LMD_TYPE_DECIMAL | "bigint" | Decimal with unlimited == DECIMAL_BIGINT (§4). |
| string | LMD_TYPE_STRING | "string" | heap String. |
| symbol | LMD_TYPE_INT (negative) or LMD_TYPE_SYMBOL | "symbol" | well-known symbols are negative ints (§4). |
| function | LMD_TYPE_FUNC | "function" | a JsFunction (§6). |
| object / array / Proxy / class ctor | LMD_TYPE_MAP, LMD_TYPE_ARRAY, LMD_TYPE_ELEMENT | "object"/"function" | js_typeof returns "function" for callable Proxies and class-constructor maps (those carrying __instance_proto__), :2107. |
A subtlety the table flags twice: a value tagged LMD_TYPE_INT is usually a JS number, but a sufficiently negative one is a JS Symbol; js_typeof calls js_key_is_symbol to decide (:2093), and js_make_number keeps the number domain clear of that range. LMD_TYPE_NUMBER, LMD_TYPE_DTIME, LMD_TYPE_BINARY, LMD_TYPE_RANGE, LMD_TYPE_OBJECT, LMD_TYPE_PATH, etc. exist in the Lambda enum but are not produced by ordinary JS code paths (they fall to the default: "object" arm).
4. Symbol-as-property-key & BigInt encoding
These two encodings are easy to confuse because both reuse an existing Lambda TypeId rather than adding one.
Symbol — a JS Symbol is encoded as a negative LMD_TYPE_INT: the value ≤ -(JS_SYMBOL_BASE), where JS_SYMBOL_BASE = 1LL << 40 (js_runtime.h:699). The base is deliberately beyond the int32 range so a bitwise-op result can never be misread as a symbol. js_key_is_symbol (js_runtime_internal.hpp:658) and js_is_symbol (:620) both test it2i(key) <= -(int64_t)JS_SYMBOL_BASE; the symbol id is recovered as -(value + JS_SYMBOL_BASE). For property storage, a symbol key is canonicalized to an interned __sym_N string by js_symbol_to_key (:663) — N is the decimal id, and well-known symbols use fixed ids (Symbol.iterator is __sym_1, Symbol.toPrimitive is __sym_2, Symbol.toStringTag is __sym_4; the __sym_2 key is also hard-coded in js_to_primitive, js_coerce.cpp:22). The full property-key side — how __sym_N keys store, enumerate-filter and reverse-map — is owned by JS_06 — Objects, Properties & Prototypes; here we only fix the value encoding and the js_to_property_key entry (js_runtime_state.cpp:98), which routes symbols through js_symbol_to_key and everything else through ToPrimitive(string) + ToString.
BigInt — a JS BigInt is not a packed integer. It reuses LMD_TYPE_DECIMAL: a heap Decimal whose dec_val is an mpd_t* (libmpdec arbitrary-precision) and whose unlimited field is set to the DECIMAL_BIGINT marker (lambda.h:755). bigint_push_result (lambda-decimal.cpp:963) allocates the Decimal with heap_alloc and tags it LMD_TYPE_DECIMAL; js_is_bigint (js_runtime_internal.hpp:626) is simply get_type_id(v) == LMD_TYPE_DECIMAL. There is no int56 fast path for small BigInts — even 0n and 1n are full mpd_t allocations (bigint_from_int64, lambda-decimal.cpp:983). Mixing a BigInt with a non-BigInt operand throws TypeError (js_check_bigint_arithmetic, js_runtime_internal.hpp:630), matching the spec. (The "56-bit / negative-int" wording sometimes attached to BigInt actually describes the Symbol encoding above; the BigInt path is arbitrary-precision via libmpdec.)
5. undefined/null/TDZ & deleted sentinels
JS needs undefined distinct from null; Lambda already separates them at the type level.
undefined—ITEM_JS_UNDEFINED = (LMD_TYPE_UNDEFINED << 56)(lambda.h:751);make_js_undefined()(js_runtime_internal.hpp:645) is the canonical constructor.LMD_TYPE_UNDEFINEDis a distinct enum member (lambda.h:120), soundefinedandnullnever alias.null—ITEM_NULL = (LMD_TYPE_NULL << 56)(lambda.h:749).typeof nullreturns"object"(§3).- TDZ —
let/constbindings before initialization holdITEM_JS_TDZ = (LMD_TYPE_UNDEFINED << 56 | 1)(lambda.h:752) — the same type tag as undefined but with the low bit set, sojs_check_tdz(js_runtime_state.cpp:238) can throw a ReferenceError on access while ordinaryundefinedreads pass through. The same sentinel doubles as the "this not yet bound" marker in derived constructors:js_get_this(:706) andjs_resolve_lexical_this(:728) throw the "Must call super constructor" ReferenceError whenjs_current_thisequalsITEM_JS_TDZ. - Dense array hole sentinel —
JS_DELETED_SENTINEL_VAL = 0x7E00DEAD00DEAD00(js_runtime.h:26) uses the unused tag0x7Eand marks empty denseArray::itemsslots. Ordinary object/FUNC/ARRAY companion-map delete state isJSPD_DELETEDonShapeEntry, not this rawItem.js_own_shape_slot_statusstill treats retained raw holes as deleted when reading shaped storage defensively; the deletion mechanics live in JS_06. - Iterator done —
JS_ITER_DONE_SENTINEL = 0x7F00DEAD00000000(js_runtime.h:31) uses the unused tag0x7Fso it cannot collide with any real value; see JS_08 — Iterators & Generators.
6. Memory model: GC heap, nursery, pool
LambdaJS allocates from the EvalContext's three regions, all shared with Lambda script.
- GC heap (
gc_heap_t) — a dual-zone non-moving mark-and-sweep collector (lib/gc/gc_heap.c:4). The object zone is a size-class free-list allocator for object structs (Map,List,String,Decimal,JsAccessorPair, …); the data zone is a bump-pointer allocator for variable-size buffers such asMap.data(gc_heap.h:96). JS objects are created viaheap_calloc(lambda-mem.cpp:381), which zeroes the struct (so a fresh map isMAP_KIND_PLAINand a freshShapeEntryis a default data property for free) and setsContainer::is_heapfor heap-vs-arena discrimination. The JIT hot path usesheap_calloc_class(:395) with a pre-computed size class and a bump-pointer fast path. Non-moving is the load-bearing property: a pointer handed to JIT code, stored in a pool-allocated env, or sitting in the arg stack stays valid across a collection — nothing is relocated, only swept. - Bump nursery (
gc_nursery_t) — a frame-less bump allocator in 32 KB blocks (gc_nursery.h:13) for boxed numeric temporaries:push_d/push_l/push_k(lambda-mem.cpp:491–563) allocate thedouble/int64/DateTimebacking thed2it/l2it/k2ittagged pointers. JSnumberboxing therefore lands here, not in the object zone (relevant to JS_15 — Performance & Optimization, which discusses avoiding the box entirely). - Module-lifetime pool (
js_input->pool, amempool) — objects that must live for the whole module and are not individually GC-traced.JsFunctionis the canonical case (see below).
Why JsFunction is pooled and GC-rooted. js_new_function/js_new_method_function/js_new_closure (js_runtime_function.cpp:151/178/197) pool_calloc the JsFunction struct (js_runtime_internal.hpp:70) rather than heap-allocate it, because functions are typically reachable only through pool-allocated closure-env arrays that a conservative stack scan cannot trace into. The struct itself is not swept; but the Item slots it points at (its env, its module_vars, its prototype, bound args) hold heap objects that the GC must keep alive — so those backing arrays are registered as GC root ranges. js_alloc_env (:222) is pool_calloc followed immediately by heap_register_gc_root_range (lambda-mem.cpp:287 → gc_register_root_range), and module-var arrays are rooted the same way (§8). js_new_function additionally caches func_ptr → JsFunction* (:159) so the same MIR function always yields the same wrapper (preserving .prototype identity). Closure-env structure is detailed in JS_05 — Functions & Closures.
7. The transient call-argument stack
Every JS call with ≥1 argument needs a contiguous Item[] for its arguments. Allocating that per call from the pool and registering a fresh permanent GC root range made call-heavy loops O(n²) — gc_register_root_range linearly scans existing ranges, and the ranges were never released (js_runtime_function.cpp:38). The fix is a single bump stack, registered with the GC exactly once.
js_args_stackis a fixed 256K-Item(2 MB) buffer (JS_ARGS_STACK_CAP,:59),calloc'd on first use and registered viaheap_register_gc_root_rangeonce (:72).- A call expression saves the bump top with
js_args_save(:86), reserves slots withjs_args_push(:65), the JIT fills them, the callee reads them, and the caller pops back withjs_args_restore(:92), which re-zeros the popped slots. - Invariant: slots in
[len, cap)are always zeroed, so the GC (which marks the whole[0, cap)range) never sees a stale pointer above the live region —0is a GC-safeItem(:48). The base never moves, because a partially-filled frame must stay GC-rooted in place while nested argument expressions push further frames (:52). - On pathological depth (
len + count > cap, which would C-stack-overflow first) it falls back to a standalonejs_alloc_envbuffer (:77). js_args_stack_reset(:102) drops the registration (re-acquired lazily on next push) on batch heap teardown, since the GC heap may be recreated between tests.
8. Module-variable storage
Top-level var/let/const/function bindings of a module are stored by index in a flat Item array, not in a map. js_module_vars[JS_MAX_MODULE_VARS] with JS_MAX_MODULE_VARS = 2048 (js_runtime_state.hpp:21,29) is the static backing store; js_set_module_var/js_get_module_var (js_runtime_state.cpp:124/130) bounds-check the index and read/write through the active pointer js_active_module_vars (hpp:30,88). The indirection lets nested require()/import() swap in a per-module array so an inner module cannot clobber an outer module's live slots: js_alloc_module_vars (cpp:159) pool_callocs a fresh 2048-slot array and registers it as a GC root range, and js_set_active_module_vars/js_get_active_module_vars (:170/166) swap the pointer (falling back to the static array when given NULL). Each JsFunction snapshots js_active_module_vars at creation (js_runtime_function.cpp:172) so a closure resolves globals against its defining module. The static array is lazily registered as a GC root range the first time the heap changes (js_ensure_module_vars_gc_rooted, js_runtime_state.cpp:6). Save/restore for re-entrant modules is js_save_module_vars/js_restore_module_vars (:145/152). The compiler's index-assignment side is in JS_01 — Compilation Pipeline and JS_04 — MIR Lowering & Code Generation.
9. JsRuntimeState capsule & batch reset
All mutable engine globals are gathered into one JsRuntimeState struct (js_runtime_state.hpp:23), instantiated once (js_runtime_state.cpp:3); legacy free-global names (js_strict_mode, js_current_this, js_exception_pending, js_module_vars, …) are #define aliases onto its fields (hpp:83–115), an explicit migration-away-from-scattered-globals device. The capsule holds the strict-mode flag, the active input, module-var table and count, the heap epoch, the pending-exception state (exception_pending/exception_value/exception_msg_buf), current_this/new_target/proxy_receiver, the super-this stacks, pending call-arg state, and assorted caches (cached_object_proto, regexp last-match, trace counters).
The batch reset path supports the test262 runner, which reuses one process across thousands of scripts. js_batch_reset (cpp:271) is the heavy crash-recovery reset: it bumps js_heap_epoch (invalidating epoch-cached objects), zeroes the module-var table, tears down the module registry and JS module cache, clears pending exceptions, resets transient call state (js_reset_transient_call_state, :768 — which also resets the arg stack) and heap-bound state, and then fans out to dozens of per-subsystem resets (Math/JSON/console/Reflect global objects, constructor prototypes, DOM, event loop, RegExp statics, and every Node-compat module). js_batch_reset_to(checkpoint) (:388) is the lighter preamble-mode path: it restores module vars to a checkpoint and clears test-local state but leaves the heap and cached builtins intact, so the harness need not re-initialize between tests. js_assert_batch_runtime_state_clear (:795) audits that a reset left no dangling this/exception/new-target/arg state, logging js-batch-state leaks. The batch/preamble mechanism itself is detailed in JS_16 — Testing & Conformance.
10. Reuse of Lambda subsystems
LambdaJS is an embedding, so much of the runtime is borrowed wholesale:
- Name pool — property keys, identifiers and short interned strings go through
heap_create_name(lambda-mem.cpp:458), which interns intocontext->name_poolso the same name always returns the sameString*(pointer-identity comparison for keys). Symbol storage keys (__sym_N) and the engine-internal marker keys all live here. - Mempool —
js_input->pool(a Lambdamempool) backsJsFunction, closure envs, and per-module var arrays (§6, §8). - GC heap & nursery — shared
gc_heap_t/gc_nursery_t(§6); JS roots use the samegc_register_root/gc_register_root_rangeAPI as Lambda. - Input parsers —
JSON.parsedoes not have its own parser;js_json_parse(js_globals.cpp:12129) calls Lambda'sparse_json_to_item_strict(js_input, …)(:175), reusing the sharedlambda/input/JSON parser and building ordinary LambdaMap/Array/Itemvalues. - URL & other modules — the
URLconstructor and Nodeurl/querystring/buffer/etc. modules reuse Lambda's URL and I/O infrastructure (entry pointsjs_url_construct,js_url_parse,js_runtime.h:688–691; module surface injs_url_module.cpp). Details are in JS_14 — Node Compatibility and JS_13 — Web Platform: DOM, CSSOM, Events & Fetch.
Known Issues & Future Improvements
- Symbol/number share
LMD_TYPE_INT. A negative int beyond-JS_SYMBOL_BASEis a Symbol, forcingjs_make_numberto special-case the boundary (js_runtime_value.cpp:1079) and every numeric reader to stay clear of the range. A dedicatedLMD_TYPE_SYMBOL-style packed tag would remove the overlap, at the cost of a new enum slot. (HeapLMD_TYPE_SYMBOLexists but is used for Lambda symbols, not JS well-known symbols.) - No small-BigInt fast path. Every BigInt — including
0n/1nand loop counters — is a fullmpd_theap allocation (lambda-decimal.cpp:963,983). An inline-int56 representation for small magnitudes (à la V8's SMI-BigInt) would cut allocation pressure in BigInt-heavy code; today the type is always boxed. JsFunctionlifetime is "never freed". Functions arepool_calloc'd and outlive the module ("module-lifetime objects that must not be GC-collected",js_runtime_function.cpp:162); a long-lived process that compiles manyFunction/closures only reclaims them at pool teardown / batch reset, not by GC.- Module-var ceiling is a hard 2048.
JS_MAX_MODULE_VARS(js_runtime_state.hpp:21) is fixed;js_set_module_varsilently drops out-of-range indices (cpp:124). A module with >2048 top-level bindings would lose writes rather than grow. - Sentinel values still exist.
JS_DELETED_SENTINEL_VALno longer reuses the INT tag, but it remains a raw non-valueItemin dense arrays;ITEM_JS_TDZstill reuses the UNDEFINED tag. Code that scans dense array items must preserve hole checks. The deleted-sentinel cleanup boundary is tracked in detail in JS_06. - Batch reset is a long manual fan-out.
js_batch_reset(js_runtime_state.cpp:271) hand-enumerates ~30 per-subsystem reset calls; a new stateful module that forgets to register a reset leaks across test262 cases.js_assert_batch_runtime_state_clearcatches only the capsule fields, not module-private statics.
Appendix A — Source map
| File | Responsibility (this doc) |
|---|---|
lambda/lambda.h, lambda/lambda.hpp | Item union + bitfields, Container/Map, EnumTypeId, packing macros (i2it/d2it/s2it/…), sentinel macros. |
lambda/lambda-data.hpp | TypeMap/ShapeEntry/JsAccessorPair (shape owned by JS_06). |
lambda/js/js_runtime.h | ITEM_JS_UNDEFINED/ITEM_JS_TDZ, JS_SYMBOL_BASE, JS_DELETED_SENTINEL_VAL, JS_ITER_DONE_SENTINEL, arg-stack API. |
lambda/js/js_runtime_internal.hpp | js_is_symbol/js_is_bigint/js_key_is_symbol/js_symbol_to_key, JsFunction struct, make_js_undefined. |
lambda/js/js_runtime_value.cpp | js_typeof, js_make_number, js_to_string/js_to_boolean/js_to_numeric, BigInt arithmetic dispatch. |
lambda/js/js_coerce.{h,cpp} | js_to_primitive (ToPrimitive / OrdinaryToPrimitive). |
lambda/js/js_runtime_state.{hpp,cpp} | JsRuntimeState capsule, module-var storage, js_to_property_key, js_batch_reset[_to]. |
lambda/js/js_runtime_function.cpp | call-argument stack (js_args_push/save/restore), JsFunction allocation, js_alloc_env. |
lambda/lambda-mem.cpp | heap_calloc/heap_alloc/heap_calloc_class, nursery push_d/push_l/push_k, GC root registration, heap_create_name. |
lambda/lambda-decimal.cpp | BigInt encoding (bigint_push_result, bigint_from_int64). |
lib/gc/gc_heap.{c,h}, lib/gc/gc_nursery.{c,h} | dual-zone non-moving collector, bump nursery. |
Appendix B — Related documents
- JS_05 — Functions & Closures — closure-environment structure backed by
js_alloc_env. - JS_06 — Objects, Properties & Prototypes —
Map/TypeMap/ShapeEntryshape,MapKind, property attributes, deleted-slot mechanics,__sym_Nproperty keys. - JS_01 — Compilation Pipeline / JS_04 — MIR Lowering & Code Generation — module-var index assignment and JIT boxing.
- JS_08 — Iterators & Generators —
JS_ITER_DONE_SENTINEL. - JS_13 — Web Platform: DOM, CSSOM, Events & Fetch / JS_14 — Node Compatibility — reused URL / module infrastructure.
- JS_15 — Performance & Optimization — float boxing avoidance, nursery pressure, shape caching.
- JS_16 — Testing & Conformance — batch/preamble reset in the test262 runner.