Values and Their Representations
What the specification says a Number or String is, and what the engine actually allocates.
ECMA-262 specifies Number as IEEE-754 binary64 and String as a sequence of UTF-16 code units, and stops there. Engines layer tagged pointers, small-integer encodings, ropes, slices, one-byte strings, and elements-kind lattices on top. Performance and several correctness edge cases live in the gap between the two descriptions.
In this chapter
Number is exactly binary64, including its pathologies
The Number type is the set of 2⁶⁴ − 2⁵³ + 3 values of IEEE-754-2019 binary64: all finite doubles, +∞, −∞, and a single NaN. The specification collapses the 2⁵³ − 2 distinct NaN bit patterns into one value; an implementation may preserve payloads internally but must not make them observable through Number operations. The place where the bit pattern can surface is a Float64Array aliased by a BigUint64Array, and the specification explicitly grants implementation freedom there (an engine may canonicalise the NaN on read or write). V8 canonicalises on store into a double array in some paths but not all, which is precisely the kind of implementation-defined edge the spec leaves open.
const f64 = new Float64Array(1);
const u64 = new BigUint64Array(f64.buffer);
function bits(x) {
f64[0] = x;
const b = u64[0];
const sign = Number(b >> 63n);
const exp = Number((b >> 52n) & 0x7ffn);
const frac = b & ((1n << 52n) - 1n);
return { sign, biasedExp: exp, exp: exp - 1023, frac: frac.toString(2).padStart(52, "0") };
}
for (const x of [1, 0.1, 0.2, 0.1 + 0.2, 0.3, -0, 2 ** 53, 2 ** 53 + 1]) {
const { sign, exp, frac } = bits(x);
console.log(String(x).padEnd(22), "s=" + sign, "e=" + String(exp).padStart(5), "m=1." + frac.slice(0, 20) + "…");
}
// 0.1 + 0.2 !== 0.3 is not "floating point being imprecise"; it is two
// exactly-defined roundings that happen not to compose.
console.log("0.1+0.2 vs 0.3 differ by exactly one ULP:", (0.1 + 0.2) - 0.3 === 2 ** -54);
// Above 2^53 the gap between adjacent doubles is 2, so odd integers do not exist.
console.log("2**53 + 1 ===", 2 ** 53 + 1, "| Number.MAX_SAFE_INTEGER =", Number.MAX_SAFE_INTEGER);Two consequences follow directly. First, integer arithmetic is exact only up to 2⁵³; Number.isSafeInteger is the predicate for "this integer and its neighbours are all representable". Second, -0 is a distinct value that === cannot see. It arises from underflow (-1e-320 * 1e-10), from Math.round(-0.4), and from -1 * 0, and it is observable through 1 / x, Object.is, Math.sign, Math.atan2, and Number.prototype.toLocaleString. Serialisation drops it: JSON.stringify(-0) is "0" and String(-0) is "0".
Bitwise operators define a hidden 32-bit integer type
Every bitwise operator (| & ^ ~ << >>) applies ToInt32 to its operands: ToNumber, then truncation toward zero, then reduction modulo 2³², then reinterpretation as a signed 32-bit value. >>> uses ToUint32. This is why x | 0 truncates, why 2 ** 31 | 0 is negative, and why ~~x is a fast truncation idiom. It is also why the 31-/32-bit integer tier exists in every engine: the language guarantees that code written against these operators will only ever see int32 values, so the optimiser can keep such values unboxed in machine registers.
How engines represent Numbers: tagging, Smis, and HeapNumbers
A dynamically typed runtime needs each value to carry its own type. V8 uses pointer tagging: every value slot is a machine word whose low bit distinguishes a Smi (small integer, low bit 0, the integer stored in the upper bits) from a HeapObject pointer (low bit 1, the actual address is the word minus one). With pointer compression on 64-bit builds, a Smi is a 31-bit signed integer stored in a 32-bit slot; without it, Smis are 32-bit. Any Number outside the Smi range, and every non-integral Number, is a HeapNumber: a heap-allocated box holding the raw double. -0 is never a Smi (its bit pattern is not integer zero), so x = -0 allocates.
SpiderMonkey and JavaScriptCore chose a different encoding, NaN-boxing: doubles are stored directly in a 64-bit word and all other values are encoded in the 2⁵¹ unused NaN payload bit patterns (JSC's variant is sometimes called NuN-boxing because it offsets doubles to make pointers the zero-tagged case). The trade-off is symmetrical: tagging makes integers and pointers cheap and doubles expensive (a heap allocation); NaN-boxing makes doubles cheap and integer/pointer decoding slightly more expensive. Both are invisible to the specification and both leak through performance.
Elements kinds: the array representation lattice
The specification says nothing about how array elements are stored; it only defines the Array exotic [[DefineOwnProperty]]. V8 chooses a backing store per array and tracks its elements kind, which is part of the array's hidden class. The kinds form a lattice with one-way transitions:
PACKED_SMI_ELEMENTS ──► PACKED_DOUBLE_ELEMENTS ──► PACKED_ELEMENTS
│ │ │
▼ ▼ ▼
HOLEY_SMI_ELEMENTS ───► HOLEY_DOUBLE_ELEMENTS ───► HOLEY_ELEMENTS ──► DICTIONARY_ELEMENTS
Transitions go right (more general value type) and down (holey),
never back. Sparse writes far past length go to DICTIONARY_ELEMENTS.A PACKED_SMI_ELEMENTS array stores raw Smis contiguously; a PACKED_DOUBLE_ELEMENTS array stores raw unboxed doubles (so reading an element does not allocate); PACKED_ELEMENTS stores tagged pointers. "Holey" means at least one index between 0 and length - 1 has never been written; each read must then check for the hole marker and, on finding one, walk the prototype chain, because a missing own property is not the same as undefined. new Array(n) creates a holey array; Array.from({ length: n }) and [...] literals create packed ones. arr.length = 0 and delete arr[i] both produce holes. Pushing a double into a Smi array transitions the whole array (a copy). Pushing a string or object into a double array transitions again. Nothing transitions back, so a single stray undefined permanently generalises the storage.
// Same algorithm, three arrays that differ only in how they were built.
const N = 2_000_000;
function sum(a) { let s = 0; for (let i = 0; i < a.length; i++) s += a[i]; return s; }
function build(kind) {
let a;
if (kind === "packed smi") { a = []; for (let i = 0; i < N; i++) a.push(i); }
if (kind === "packed double") { a = []; for (let i = 0; i < N; i++) a.push(i + 0.5); }
if (kind === "holey") { a = new Array(N); for (let i = 0; i < N; i++) a[i] = i; }
if (kind === "generic") { a = []; for (let i = 0; i < N; i++) a.push(i); a.push({}); a.pop(); }
return a;
}
function bench(label, a) {
for (let w = 0; w < 5; w++) sum(a); // warm up so the JIT tier is comparable
const t0 = performance.now();
let r = 0;
for (let k = 0; k < 10; k++) r += sum(a);
console.log(label.padEnd(15), (performance.now() - t0).toFixed(1).padStart(7), "ms");
}
for (const kind of ["packed smi", "packed double", "holey", "generic"]) bench(kind, build(kind));
console.log("Expect: generic/holey slower than packed; the exact ratio depends on the engine.");In V8 you can see the kind directly with node --allow-natives-syntax -e '%DebugPrint([1,2,3])'. The measurement here is indirect because the sandbox cannot use natives syntax.
Strings: UTF-16 by specification, several shapes by implementation
A String value is a finite ordered sequence of 16-bit unsigned integers, and length counts those code units, not characters. Code points above U+FFFF are represented as surrogate pairs, and the specification permits lone surrogates, so not every String is valid Unicode. String iteration (for…of, spread, Array.from) is defined in terms of code points (CodePointAt), while indexing, length, charAt, slice, and regular expressions without the u/v flag operate on code units. String.prototype.isWellFormed and toWellFormed (ES2024) exist because the WebAssembly and TextEncoder boundaries must reject or repair lone surrogates.
const s = "a𝒳b"; // U+1D4B3 MATHEMATICAL SCRIPT CAPITAL X is outside the BMP
console.log("length (code units):", s.length, "| code points:", [...s].length);
console.log("code units:", [...s].map((c) => [...Array(c.length)].map((_, i) => c.charCodeAt(i).toString(16))).flat().join(" "));
console.log("charAt(1) is a lone high surrogate:", JSON.stringify(s.charAt(1)), s.charAt(1).isWellFormed());
console.log("slice can split a pair:", JSON.stringify(s.slice(0, 2)), "->", s.slice(0, 2).isWellFormed());
console.log("codePointAt(1) sees the pair:", s.codePointAt(1).toString(16), "| charCodeAt(1):", s.charCodeAt(1).toString(16));
console.log("regex . without u:", "𝒳".match(/^.$/) !== null, "| with u:", "𝒳".match(/^.$/u) !== null);
// Grapheme clusters are yet another level, outside ECMA-262 (Intl.Segmenter is ECMA-402).
const seg = new Intl.Segmenter("en", { granularity: "grapheme" });
console.log("graphemes in '👨👩👧':", [...seg.segment("👨👩👧")].length, "| code points:", [..."👨👩👧"].length, "| code units:", "👨👩👧".length);V8 does not have one string representation; it has a small taxonomy chosen per value and mutated in place as the string is used:
| Representation | Created by | Notes |
|---|---|---|
| SeqOneByteString | Literals and results whose code units all fit in Latin-1 | One byte per code unit; halves memory and speeds comparisons. A single non-Latin-1 character forces the whole string to two-byte. |
| SeqTwoByteString | Any string containing a code unit > 0xFF | Two bytes per code unit. |
| ConsString | a + b when the result is ≥ 13 code units | A rope node pointing to two children. Concatenation is O(1); the first indexed access (s[i], charCodeAt) triggers a full flatten into a sequential string. |
| SlicedString | substring/slice of a long string | A view (parent, offset, length). Keeps the entire parent alive: a 5-character slice of a 50 MB string pins 50 MB. |
| ThinString | Internalisation of an existing string | A forwarding pointer to the canonical internalised copy. |
| Internalized string | Identifiers, property keys, literals | Interned in a string table so property lookup can compare pointers, not contents. |
const N = 200_000;
let t0 = performance.now();
let s = "";
for (let i = 0; i < N; i++) s += "x"; // builds a deep ConsString tree
const tConcat = performance.now() - t0;
t0 = performance.now();
const c0 = s.charCodeAt(N >> 1); // first indexed access forces a flatten
const tFirstAccess = performance.now() - t0;
t0 = performance.now();
const c1 = s.charCodeAt(N >> 2); // now sequential: O(1)
const tSecondAccess = performance.now() - t0;
t0 = performance.now();
const j = new Array(N).fill("x").join(""); // one allocation, no rope
const tJoin = performance.now() - t0;
console.log("concat loop ", tConcat.toFixed(3), "ms");
console.log("first charCodeAt", tFirstAccess.toFixed(3), "ms (flatten)");
console.log("second charCodeAt", tSecondAccess.toFixed(3), "ms");
console.log("Array.join ", tJoin.toFixed(3), "ms");
void c0; void c1; void j;Naive += loops are not quadratic in V8 precisely because of ropes. The trade-off shows up as a latency spike at the first read.
BigInt and Symbol: two primitives with no implicit bridge
BigInt is arbitrary-precision integer arithmetic with two deliberate design decisions. First, there is no implicit conversion between Number and BigInt: 1n + 1 throws a TypeError, because any implicit rule would either lose precision (BigInt → Number) or be surprising (Number → BigInt for non-integers). Comparison operators are the exception; 1n < 2 and 1n == 1 are permitted because ordering and loose equality are mathematically well-defined across the two types. Second, BigInt division truncates toward zero (-7n / 2n is -3n), matching Number's Math.trunc rather than floor division. Engines store BigInts as sign-plus-magnitude arrays of 64-bit digits on the heap; a BigInt is never a Smi, so 0n allocates where 0 does not.
Symbols are guaranteed-unique property keys. Each call to Symbol() produces a fresh value that is not equal to any other and cannot be forged from a string. They are the only primitive with identity. Symbol.for(key) consults a realm-independent global registry, which is why registered symbols survive across iframes and vm contexts while unregistered ones do not. Symbol-keyed properties are skipped by for…in, Object.keys, and JSON.stringify but returned by Object.getOwnPropertySymbols and Reflect.ownKeys; they are not hidden, only omitted from the string-oriented enumeration APIs. Symbols cannot be implicitly converted to strings ("" + Symbol() throws) because silently stringifying a symbol would defeat its purpose as a non-colliding key.
console.log("2**64 exactly:", 2n ** 64n);
console.log("Number loses it:", 2 ** 64, "->", BigInt(2 ** 64) === 2n ** 64n);
console.log("truncating division:", -7n / 2n, "| Number analogue:", Math.trunc(-7 / 2));
console.log("comparison across types is allowed:", 9007199254740993n > 9007199254740992, 1n == 1, 1n === 1);
try { 1n + 1; } catch (e) { console.log("mixing throws:", e.constructor.name, "-", e.message); }
console.log("asIntN wraps like a fixed-width register:", BigInt.asIntN(8, 255n), BigInt.asUintN(8, -1n));
console.log("BigInt in JSON:", (() => { try { return JSON.stringify(1n); } catch (e) { return e.constructor.name; } })());Where these ideas come from
The mechanisms in this chapter, traced to the paper, standard, or system that introduced them, and how each reached JavaScript.
| Mechanism | Origin | Path into JavaScript |
|---|---|---|
| Floating-point arithmetic | William Kahan and the IEEE 754 committee, IEEE Standard for Binary Floating-Point Arithmetic (1985) | Number is binary64 by reference; signed zero, NaN, gradual underflow, and round-to-nearest-even are this standard's decisions. |
| Tagged pointers and small integers | Guy L. Steele Jr., Data Representations in PDP-10 MacLISP (1977) | Lisp systems used low pointer bits to distinguish fixnums from pointers; V8's Smi tag is the same trick. |
| NaN-boxing | David Gudeman; later the JavaScriptCore and SpiderMonkey teams, Representing Type Information in Dynamically Typed Languages (1993) | Encoding pointers and integers in the payload bits of NaN, so doubles need no allocation, is the representation SpiderMonkey and JSC chose over V8's tagging. |
| Ropes for strings | Hans-Juergen Boehm, Russ Atkinson & Michael Plass, Ropes: An Alternative to Strings (1995) | V8's ConsString is a rope node; flattening on first indexed access is Boehm et al.'s lazy strategy. |
| UTF-16 and surrogate pairs | Unicode Consortium, The Unicode Standard, Version 2.0 (1996) | JavaScript fixed 16-bit code units in 1995 when Unicode was 16-bit; surrogates were added a year later, which is why length counts code units. |