PART 1: ARCHITECTURAL OVERVIEW
1.1 Primitive vs Reference Types
Primitives are immutable, fixed-size (or engine-optimized) values stored directly where the variable binding lives — conceptually the stack (execution context's variable environment). Objects are mutable, variable-size structures stored on the heap; the variable binding holds a pointer to that heap location.
This is NOT pass-by-reference in the C++/Pascal sense. JS is strictly call-by-value, but for objects, the value being copied is the reference itself — this hybrid model is called call-by-sharing.
function mutate(obj) { obj.x = 99; } // mutates shared heap object
function reassign(obj) { obj = { x: 0 }; } // rebinds LOCAL copy of pointer only
let o = { x: 1 };
mutate(o); console.log(o.x); // 99 — heap object was mutated
reassign(o); console.log(o.x); // still 99 — only the local pointer changed
STACK FRAME HEAP
┌─────────────┐
│ o ──────────┼───────────► ┌─────────┐
└─────────────┘ │ { x: 1 }│
└─────────┘
call mutate(o):
┌─────────────┐
│ obj ─────────┼───────────► (SAME heap object — pointer copied by value)
└─────────────┘ obj.x = 99 mutates it directly
call reassign(o):
┌─────────────┐
│ obj ─────────┼───────────► new { x: 0 } (local pointer redirected,
└─────────────┘ original o's pointer unchanged)
1.2 Dynamic & Weak Typing
JS resolves types at runtime, not compile time (dynamic typing), and permits implicit conversions between incompatible types (weak typing). Internally, V8 tags every value with a type identifier embedded in the low bits of the value's machine-word representation (see §Number internals below) — this tag is what typeof and internal abstract operations inspect to dispatch behavior.
1.3 Complete Type System — ASCII Architecture Diagram
┌────────────────────┐
│ JavaScript Value │
└──────────┬───────────┘
┌──────────────────────┴──────────────────────┐
▼ ▼
┌─────────────────┐ ┌──────────────────┐
│ PRIMITIVE │ │ OBJECT (ref) │
│ (stack, by value)│ │ (heap, by pointer)│
└────────┬─────────┘ └─────────┬─────────┘
┌─────┬───────┼───────┬──────┬────────┬────────┐ │
▼ ▼ ▼ ▼ ▼ ▼ ▼ ┌───────┴────────┐
undefined null boolean number bigint string symbol ▼ ▼
Plain Object Exotic Object
{}, Object() Array, Function,
Date, RegExp,
Map/Set/WeakMap,
ArrayBuffer,
TypedArrays, Error
PART 2: PRIMITIVE TYPES
2.1 undefined
Definition: The default value of any binding that has been declared but not assigned; also the implicit return value of functions with no return statement, and the value of missing object properties / array holes.
Memory: A single, unique, engine-internal sentinel value — no heap allocation. In V8 it's represented as a special tagged pointer (the "oddball" undefined).
Syntax & traps:
let x;
console.log(x); // undefined
function f() {}
console.log(f()); // undefined
let obj = {};
console.log(obj.missing); // undefined — property doesn't exist
let arr = [1, , 3]; // sparse array
console.log(arr[1]); // undefined (a "hole", not literally stored)
Temporal Dead Zone (TDZ): let/const bindings exist in scope from the start of the block but are uninitialized until their declaration line executes. Accessing them before that throws — this is different from undefined.
console.log(a); // ReferenceError: Cannot access 'a' before initialization
let a = 5;
Global undefined vs shadowing: undefined is a non-writable, non-configurable global property in strict mode (ES5+), but it is still just an identifier — it can theoretically be shadowed as a local variable name or function parameter in sloppy mode (not recommended, and disallowed for the global itself since ES5).
ASCII diagram — TDZ vs undefined:
Scope entry: [ a: <uninitialized> ] ← accessing throws ReferenceError
let a = 5; [ a: 5 ] ← now readable
(contrast with var, which is [ a: undefined ] from scope entry)
Pitfalls: typeof undeclaredVar returns "undefined" (no error) — but accessing undeclaredVar directly throws ReferenceError. typeof is the one safe way to probe for existence without a prior declaration.
2.2 null
Definition: Represents an intentional, explicit absence of any object value — assigned by the developer, never implicitly by the engine (contrast with undefined).
The typeof null === "object" bug: This is a legacy artifact from the original 1995 JS implementation. Values were represented as a 32-bit word: a small type tag (1–3 bits) plus a payload. Objects were tagged 000. null was represented as the all-zero machine word (essentially a null pointer, 0x00) — which incidentally also carries the 000 object tag pattern. typeof inspects this tag, sees 000, and reports "object". It's a bitmask collision, not a design decision, and it's permanently frozen into the spec for backward compatibility.
null's internal word: 0x00000000
Object tag pattern: 000 (lowest bits)
└── null's all-zero bits ACCIDENTALLY match this tag
Equality quirks:
null == undefined; // true — special-cased loose equality rule
null === undefined; // false — different types
null == 0; // false — null does NOT coerce to 0 in ==
null >= 0; // true — relational operators DO convert null to 0 first
This asymmetry (== treats null specially but relational operators convert it numerically) is a frequently-tested interview edge case.
2.3 Boolean
Definition: Logical binary type, true/false, used for control flow and boolean logic.
Truthy/Falsy table (complete, no omissions):
FALSY (exactly 8 values in JS):
false, 0, -0, 0n (BigInt zero), "", null, undefined, NaN
TRUTHY: literally everything else, including:
"0", "false", [], {}, function(){}, new Boolean(false)
new Boolean(false) is truthy because it's an object, not a primitive — objects are always truthy regardless of their wrapped content.
Short-circuiting (returns operand values, not necessarily booleans):
console.log(0 || "default"); // "default" (returns the value, not `true`)
console.log("" ?? "fallback"); // "fallback" — ?? only checks null/undefined, NOT falsy
console.log(0 ?? "fallback"); // 0 — 0 is not null/undefined, so ?? keeps it
console.log(user && user.name); // guards against accessing .name on null/undefined
Bitwise tricks: ~~x (double bitwise NOT) truncates toward zero like Math.trunc for 32-bit-safe integers — a legacy micro-optimization, now discouraged in favor of Math.trunc.
2.4 Number
Definition: IEEE 754 double-precision (64-bit) floating point — the single numeric type for both integers and decimals prior to BigInt.
64-bit breakdown:
63 62 ......... 52 51 ................................ 0
┌───┬──────────────┬──────────────────────────────────────┐
│ S │ Exponent(11) │ Mantissa (52 bits) │
└───┴──────────────┴──────────────────────────────────────┘
1 bit 11 bits 52 bits
Sign Biased exponent Fraction (significand)
value = (-1)^S × 1.Mantissa × 2^(Exponent - 1023)
The 52-bit mantissa is why Number.MAX_SAFE_INTEGER = 2^53 - 1 = 9007199254740991 — you get 53 bits of integer precision (52 stored + 1 implicit leading bit).
Number.MAX_SAFE_INTEGER // 9007199254740991
9007199254740992 === 9007199254740993 // true! precision lost beyond this point
0.1 + 0.2 // 0.30000000000000004 — classic float rounding error
+0 vs -0: Both compare equal with === but are distinguishable:
console.log(0 === -0); // true
console.log(Object.is(0, -0)); // false — Object.is is the precise identity check
console.log(1 / 0); // Infinity
console.log(1 / -0); // -Infinity ← reveals the sign bit difference
NaN mechanics: NaN is the IEEE 754 encoding where the exponent bits are all 1 and the mantissa is nonzero. Per spec, NaN !== NaN (it fails identity with itself by design, inherited from the IEEE standard so error-propagation is detectable).
NaN === NaN; // false
Object.is(NaN, NaN); // true — Object.is is the ONE correct way to test for NaN identity
Number.isNaN(NaN); // true — reliable, unlike global isNaN() which coerces first
isNaN("hello"); // true — global isNaN coerces "hello" → NaN first (a trap)
Number.isNaN("hello"); // false — no coercion, so correctly says "not NaN, just not a number"
V8 internals — Smi vs HeapNumber: V8 doesn't box every number as a full 64-bit float. Small integers that fit in 31 bits (on 32-bit systems) or similar ranges are stored as Smi (Small Integer) — packed directly into the pointer-sized word with a tag bit, avoiding heap allocation entirely. Numbers exceeding that range, or requiring float precision, are boxed as a HeapNumber — an actual heap-allocated 64-bit double.
Smi (tagged, no heap alloc): [ 31-bit integer value | tag=0 ]
HeapNumber (heap allocated): pointer ──► [ 64-bit IEEE 754 double ]
This is a major reason integer-heavy V8 code is fast — most everyday loop counters never touch the heap.
2.5 BigInt
Definition: Arbitrary-precision integer type (ES2020) for values exceeding Number's safe integer range, represented internally as a sign plus a digit array (not IEEE 754 at all).
const big = 9007199254740993n; // 'n' suffix required
const big2 = BigInt("9007199254740993"); // function form
console.log(big + 1n); // 9007199254740994n — exact, no precision loss
Why mixing throws: Number and BigInt have fundamentally incompatible internal representations (IEEE 754 float vs arbitrary-precision integer array); implicit coercion between them would silently lose precision in one direction, so the spec forbids it outright rather than guessing:
10n + 5; // TypeError: Cannot mix BigInt and other types
10n + BigInt(5); // 15n — must explicitly convert
10n == 10; // true — loose equality DOES allow comparison (not arithmetic)
10n === 10; // false — different types
JSON quirk: JSON.stringify throws on BigInt values — there is no BigInt representation in the JSON spec:
JSON.stringify({ a: 10n }); // TypeError: Do not know how to serialize a BigInt
// Workaround: convert to string first, or supply a custom replacer function
Limitations: No support for decimals, no implicit conversion to/from Number in arithmetic, and Math object methods (Math.sqrt, etc.) don't accept BigInt.
2.6 String
Definition: Immutable sequence of UTF-16 code units representing text.
UTF-16 code units vs code points: JS strings are indexed by 16-bit code units, not by actual Unicode "characters" (code points). Characters outside the Basic Multilingual Plane (like many emoji) require a surrogate pair — two 16-bit units representing one code point.
const s = "😀";
console.log(s.length); // 2 — two UTF-16 code units, not one character!
console.log([...s].length); // 1 — spread iterates by code point (correct)
console.log(s[0]); // an unpaired surrogate (broken glyph if rendered alone)
Immutability:
let s = "hello";
s[0] = "H"; // silently fails (no error in sloppy mode)
console.log(s); // "hello" — unchanged
s = "H" + s.slice(1); // must create a NEW string
V8 internals — string representations:
┌─────────────────────────────────────────────────────┐
│ SeqString (flat) — contiguous char buffer, │
│ used for short/simple strings │
│ ConsString (rope) — a lazy concatenation: │
│ ┌──────┐ ┌──────┐ │
│ │"foo" │ + │"bar" │ ← not merged until actually │
│ └──────┘ └──────┘ read (e.g. indexed access) │
│ SlicedString — a view into a substring of │
│ another string (no copy) │
└─────────────────────────────────────────────────────┘
Repeated += concatenation in a loop builds a deep ConsString tree; reading a character then forces "flattening" — a known performance trap in older engines (mitigated but still relevant for very hot loops).
String interning: String literals (and some computed strings) are deduplicated in a global string table — two identical literals often point to the exact same heap allocation, making === comparison on short strings effectively an O(1) pointer comparison rather than O(n) character comparison.
2.7 Symbol
Definition: A primitive representing a guaranteed-unique, immutable value, primarily used as non-colliding object property keys.
const s1 = Symbol("id");
const s2 = Symbol("id");
console.log(s1 === s2); // false — always unique, description is just for debugging
Global registry via Symbol.for: Unlike Symbol(), Symbol.for(key) looks up (or creates) a symbol in a global, runtime-wide registry, so calls with the same key return the identical symbol:
const a = Symbol.for("app.id");
const b = Symbol.for("app.id");
console.log(a === b); // true — shared via the global symbol registry
Well-Known Symbols — engine hook points that let objects customize built-in language behavior:
class Range {
constructor(a,b){ this.a=a; this.b=b; }
[Symbol.iterator]() { // powers for...of
let cur = this.a, end = this.b;
return { next: () => cur <= end ? {value: cur++, done:false} : {value:undefined, done:true} };
}
[Symbol.toPrimitive](hint) { // powers +obj, `${obj}`, obj + ""
if (hint === "number") return this.b - this.a;
return `Range(${this.a}-${this.b})`;
}
}
const r = new Range(1,3);
console.log([...r]); // [1,2,3]
console.log(+r); // 2 (uses "number" hint)
console.log(`${r}`); // "Range(1-3)" (uses "string"/"default" hint)
Metadata / "private" property use case: Symbol keys are excluded from Object.keys, JSON.stringify, and for...in — making them useful for attaching metadata that shouldn't clutter normal enumeration (though true privacy is now handled better by #privateFields).
PART 3: STRUCTURAL / REFERENCE TYPES
3.1 Object
Definition: An unordered (well, insertion-ordered for string keys) collection of key-value pairs, the base structural type from which all other reference types derive.
Property descriptors — every property has a hidden descriptor, not just a value:
Object.defineProperty(obj, "x", {
value: 1, writable: false, enumerable: false, configurable: false
});
Hidden classes / Shapes (V8 internals): V8 doesn't store objects as pure hash maps. Objects with the same property-addition order share a hidden class (Map/Shape), which records property-name-to-offset mappings. This lets V8 use fast, fixed in-memory slot offsets instead of hashing on every property access.
obj1 = {x:1, y:2} obj2 = {x:3, y:4}
│ │
└──────► SAME Hidden Class (Shape) ◄──────┘
{ x: offset 0, y: offset 1 }
obj3 = {y:1, x:2} ← different insertion order
│
└──────► DIFFERENT Hidden Class (transition creates a NEW shape,
de-optimizing polymorphic call sites)
Practical implication: always initialize object properties in the same order (ideally in the constructor) to keep V8 on the fast "monomorphic" path rather than falling into slower dictionary-mode lookups.
3.2 Array
Definition: An ordered, integer-indexed Object exotic subtype with a special length property that auto-updates.
Packed vs holey (V8 internals):
Packed (dense, fast): [0]=1 [1]=2 [2]=3 ← contiguous, C-array-like storage
Holey (sparse, slow): [0]=1 [2]=3 ← index 1 is a hole
internally falls back toward
dictionary-mode (hash map) storage
const arr = [1,2,3]; // PACKED_SMI_ELEMENTS — fastest
arr[10] = 99; // creates a hole → HOLEY_ELEMENTS, slower iteration
delete arr[0]; // ALSO creates a hole — prefer arr.splice() instead
Mixing types also demotes the internal representation: [1,2,3] (all Smi) → push a float → PACKED_DOUBLE_ELEMENTS → push a string/object → PACKED_ELEMENTS (generic, boxed). Each demotion is one-directional and never upgrades back.
3.3 Function
Definition: A first-class, callable object — meaning functions are ordinary objects (can hold properties) that additionally carry two internal methods.
Internal slots:
-
[[Call]]— invoked when the function is called normally:f() -
[[Construct]]— invoked when called withnew:new F(). Only present on non-arrow "constructible" functions — arrow functions and methods lack[[Construct]], which is whynew (() => {})throws.
Closures & scope chain:
function makeCounter() {
let count = 0; // captured in closure scope
return function() { return ++count; };
}
const counter = makeCounter();
console.log(counter()); // 1
console.log(counter()); // 2
Heap:
┌────────────────────┐
│ Closure Environment │ ← kept alive as long as returned function exists
│ count: 2 │ (garbage collector cannot reclaim it —
└──────────┬───────────┘ a common source of accidental memory retention)
│
returned function ──► references this environment record
3.4 Date, RegExp, Error
-
Date— internal state is a single 64-bit float: milliseconds since the Unix epoch (Jan 1, 1970 UTC). All getters (getFullYear(), etc.) derive from this one timestamp plus timezone offset calculation at read time. -
RegExp— internal state includes the pattern string, flags, and (for global/sticky regexes) alastIndexslot that persists mutable state across.exec()calls — a well-known gotcha when reusing a global regex object across iterations. -
Error— internal slots formessage,name, and (non-standard but universal)stack, captured synchronously at construction time via the engine's call-stack unwinding mechanism.
3.5 Keyed Collections: Map, Set, WeakMap, WeakSet
Map — key-value pairs where keys can be any value type (unlike plain objects, which coerce all keys to strings/symbols), with guaranteed insertion-order iteration and O(1) average lookup via internal hash table.
Set — same hash table mechanics as Map, storing unique values only.
WeakMap/WeakSet — keys must be objects (or, since ES2023, registered symbols) and are held with a weak reference — meaning the entry does not prevent garbage collection of the key. This is implemented via an ephemeron table: a special GC structure where an entry is only kept alive if the key is independently reachable elsewhere in the program.
Regular Map: Map ──(strong ref)──► key object ← key survives even if
nothing else references it
WeakMap: WeakMap ··(weak ref)··► key object ← if all OTHER references
to key vanish, GC reclaims
it AND the WeakMap entry
disappears automatically
Why keys must be objects: primitives are interned/duplicated by value (see string interning above), so there's no unique heap identity to weakly track — garbage collection couldn't meaningfully determine "this specific primitive is no longer reachable" the way it can for a unique object pointer. This also means WeakMap/WeakSet are not iterable — their contents can vanish at any GC cycle, so exposing a stable iteration order is impossible by design.
Use case: attaching metadata to objects (e.g., DOM nodes) without creating a memory leak — when the DOM node is removed and has no other references, both it and its WeakMap entry become collectible together.
3.6 Structured Binary Data: ArrayBuffer, SharedArrayBuffer, TypedArrays, DataView
ArrayBuffer — a fixed-length, raw binary buffer (just bytes, no interpretation). Cannot be read/written directly; you need a "view."
TypedArrays (Uint8Array, Int32Array, Float64Array, etc.) — typed, fixed-format views over an ArrayBuffer's bytes, interpreting them as a specific numeric type.
const buffer = new ArrayBuffer(8); // 8 raw bytes
const view32 = new Int32Array(buffer); // interprets as two 32-bit ints
const view8 = new Uint8Array(buffer); // interprets as eight 8-bit bytes — SAME memory
view32[0] = 1;
console.log(view8); // Uint8Array [1, 0, 0, 0, 0, 0, 0, 0] on little-endian systems
ArrayBuffer (8 bytes, shared underlying memory):
┌────┬────┬────┬────┬────┬────┬────┬────┐
│ b0 │ b1 │ b2 │ b3 │ b4 │ b5 │ b6 │ b7 │
└────┴────┴────┴────┴────┴────┴────┴────┘
▲ Int32Array view[0] spans b0-b3 ▲ view[1] spans b4-b7
▲ Uint8Array view spans EACH byte individually — same underlying bytes
DataView — like a TypedArray but lets you read/write mixed types at arbitrary byte offsets, and lets you explicitly control endianness (byte order):
const dv = new DataView(buffer);
dv.setInt32(0, 42, true); // true = little-endian
console.log(dv.getInt32(0, true)); // 42
SharedArrayBuffer + Atomics: Like ArrayBuffer but its memory can be shared across Web Workers (true shared memory, not message-passing copies). Because multiple threads can access it concurrently, race conditions are possible — the Atomics object provides indivisible read-modify-write operations (Atomics.add, Atomics.compareExchange, Atomics.wait/notify) to synchronize access safely without a full mutex library.
PART 4: TYPE CHECKING, COERCION & ENGINE MECHANICS
4.1 Determining Types Accurately
| Method | Reliable for | Fails on |
|---|---|---|
typeof x |
primitives, functions |
null → "object"; can't distinguish object subtypes |
x instanceof Ctor |
checking prototype chain membership | primitives; fails across JS realms/iframes (different global Array) |
Array.isArray(x) |
arrays specifically, cross-realm safe | nothing — this is the correct array check |
Object.prototype.toString.call(x) |
precise internal [[Class]] tag for any value |
verbose; still the most reliable universal method |
| Custom type guards | domain-specific validation | only as good as the logic you write |
Object.prototype.toString.call([]); // "[object Array]"
Object.prototype.toString.call(null); // "[object Null]" ← correctly distinguishes null!
Object.prototype.toString.call(new Map()); // "[object Map]"
Object.prototype.toString.call(async()=>{});// "[object AsyncFunction]"
4.2 Abstract Operations & Coercion
ToPrimitive(input, hint) — the gateway operation for all coercion; converts an object to a primitive before ToString/ToNumber can act on it. Hint is one of "number", "string", or "default".
ToPrimitive algorithm (simplified):
1. If input already a primitive → return as-is
2. If input has Symbol.toPrimitive → call it with the hint, return result
3. Else, pick method order based on hint:
"string" hint: try toString() first, then valueOf()
"number"/"default" hint: try valueOf() first, then toString()
4. First method returning a primitive wins
const obj = { valueOf: () => 10, toString: () => "ten" };
console.log(obj + 1); // 11 — "default" hint → valueOf() wins → 10 + 1
console.log(`${obj}`); // "ten" — "string" hint → toString() wins
console.log(Number(obj)); // 10 — "number" hint → valueOf() wins
ToString, ToNumber, ToBoolean — full coercion table:
| Input | ToString | ToNumber | ToBoolean |
|---|---|---|---|
undefined |
"undefined" |
NaN |
false |
null |
"null" |
0 |
false |
true |
"true" |
1 |
true |
false |
"false" |
0 |
false |
"" |
"" |
0 |
false |
"123" |
"123" |
123 |
true |
"abc" |
"abc" |
NaN |
true |
[] |
"" |
0 |
true |
[5] |
"5" |
5 |
true |
[1,2] |
"1,2" |
NaN |
true |
{} |
"[object Object]" |
NaN |
true |
console.log([] + []); // "" ← both arrays → "" via ToString, concatenated
console.log([] + {}); // "[object Object]"
console.log({} + []); // 0 or "[object Object]" depending on statement-vs-expression parsing context (classic gotcha)
console.log(+[]); // 0 ← unary + forces ToNumber
console.log(+"3.14"); // 3.14
4.3 Wrapper Objects: Boxing & Unboxing
const primStr = "hello"; // primitive
const objStr = new String("hello"); // wrapper OBJECT, not a primitive
typeof primStr; // "string"
typeof objStr; // "object"
primStr === objStr; // false — different types entirely
primStr == objStr; // true — == unboxes objStr via ToPrimitive first
"hello" === "hello"; // true — string interning, same primitive value
new String("hello") === new String("hello"); // false — two distinct heap objects
Automatic temporary boxing during method calls:
"hi".length; // engine internally: box "hi" → temp String object → read .length → discard wrapper
This temporary box is invisible and garbage-collected immediately — never assign custom properties expecting persistence (see §2.6's customProp example above; same mechanism).
Calling wrapper constructors WITHOUT new performs plain type coercion instead of object creation:
String(123); // "123" — primitive string, ToString coercion
Number("42"); // 42 — primitive number, ToNumber coercion
Boolean(0); // false — primitive boolean, ToBoolean coercion
new Number("42") === 42; // false — object wrapper, not primitive
Master Reference Table
| Type | Stack/Heap | Mutable |
typeof result |
|---|---|---|---|
undefined |
Stack (sentinel) | N/A | "undefined" |
null |
Stack (sentinel) | N/A |
"object" (bug) |
boolean |
Stack | No | "boolean" |
number |
Stack (Smi) / Heap (HeapNumber) | No | "number" |
bigint |
Heap (digit array) | No | "bigint" |
string |
Heap (interned/rope/flat) | No | "string" |
symbol |
Heap (unique) | No | "symbol" |
object (all subtypes) |
Heap, stack holds pointer | Yes |
"object" / "function"
|
Top comments (0)