undefined inside the engine
Trace how V8 stores undefined as a read-only oddball, emits LdaUndefined bytecode, separates holes, and handles missing values.
- 01Identify the one undefined valueExplain why V8 represents every observable undefined as a tagged pointer to one read-only Oddball object.
- 02Read the bytecode pathsRecognize
LdaUndefined,LdaTheHole, TDZ checks, missing-argument fills, and shadowedundefinedbindings in V8 bytecode. - 03Separate holes from missing valuesDistinguish array holes, missing properties, missing arguments, undeclared names, and the global
undefinedproperty in real code.
One oddball, many sources
JavaScript has exactly one observable undefined value. In V8 12.4, every undefined your program can receive is a tagged pointer to one prebuilt read-only heap object: the undefined oddball. Returning it, storing it in a register, or comparing with it does not allocate a new value.
undefined inside V8 is the read-only Oddball object whose cached conversions are ToNumber → NaN, ToString → "undefined", and typeof → "undefined". Bytecode usually reaches it with LdaUndefined or through an already-filled slot.
const fromIdentifier = undefined;const fromVoid = void 0;function noReturn() {} console.log(fromIdentifier === fromVoid);console.log(noReturn());console.log(Object.is(fromIdentifier, undefined));Lines 1 and 2 both make the same value, so line 5 prints true. The function on line 3 falls off the end, so line 6 prints undefined. Line 7 confirms that the value identity is the same. If you need the beginner language tour first, read undefined; if the phrase “tagged pointer” is still new, review tagged values.
Imagine one shared blank note. When someone has nothing to add, they point to that note instead of making a new blank one.
- In real life: One blank note is kept
- In JavaScript: One read-only undefined oddball
- In real life: Everyone can point to that note
- In JavaScript: Every register or property can hold a pointer to it
- In real life: Pointing to it makes no new note
- In JavaScript: Producing undefined does not allocate
Where the analogy stops: A note is visible and physical. The oddball is an engine object behind tagged values, and other engines can represent undefined differently.
The local probes in this lesson run on Node 22.23.x with V8 12.4.254.21 and pointer compression off. Chrome uses a newer V8 build and commonly enables pointer compression and the V8 sandbox, so addresses and object sizes differ. The stable ideas here are the type names, bytecode mnemonics, and source-level invariants.
The undefined oddball
V8 calls null, undefined, true, and false “oddballs.” The primary source is oddball.h: it lists cached to_number_raw, to_string, to_number, type_of, and kind fields, with kUndefined = 4. Heap setup creates a separate undefined_map, marks that map undetectable, and initializes the oddball with NaN, "undefined", and "undefined".
%DebugPrint(undefined)JavaScript%DebugPrint(undefined);The test asserts the stable output: [Oddball] in ReadOnlySpace: #undefined, a map with type: ODDBALL_TYPE, stable_map, undetectable, non-extensible, and prototype: <null>. The same map concept appears for null inside the engine, which is why both values are “undetectable” internally.
// V8 12.4: src/objects/oddball.h// The Oddball describes objects null, undefined, true, and false.// [to_number_raw]: Cached raw to_number computed at startup.// [to_string]: Cached to_string computed at startup.// [to_number]: Cached to_number computed at startup.// [typeof]: Cached type_of computed at startup.static constexpr uint8_t kUndefined = 4; // V8 12.4: src/heap/setup-heap-internal.ccInitializePartialMap(undefined_map, meta_map, ODDBALL_TYPE, sizeof(Undefined));roots.undefined_map()->set_is_undetectable(true);Oddball::Initialize(isolate(), factory->undefined_value(), "undefined", factory->nan_value(), "undefined", Oddball::kUndefined);setup-heap-internal.cc allocates the maps and values during startup. The debug output proves this Node build places the value in ReadOnlySpace; V8's read-only serializer emits read-only pages and the read-only roots table into the snapshot image.
// JavaScriptCore runtime/JSCJSValue.h// False: 0x06// True: 0x07// Undefined: 0x0a// Null: 0x02static constexpr int32_t OtherTag = 0x2;static constexpr int32_t UndefinedTag = 0x8;static constexpr int32_t ValueUndefined = OtherTag | UndefinedTag; // SpiderMonkey js/public/Value.henum JSValueType : uint8_t { JSVAL_TYPE_UNDEFINED = 0x03, JSVAL_TYPE_NULL = 0x04,};JSVAL_TAG_UNDEFINED = JSVAL_TAG_MAX_DOUBLE | JSVAL_TYPE_UNDEFINED;JavaScriptCore's JSCJSValue.h documents Undefined: 0x0a and ValueUndefined = OtherTag | UndefinedTag. SpiderMonkey's Value.h gives JSVAL_TYPE_UNDEFINED = 0x03 and a JSVAL_TAG_UNDEFINED tag. Same JavaScript value; different engine layout.
| Thing | Representation | Verified detail |
|---|---|---|
V8 undefined | Read-only Oddball heap object | ODDBALL_TYPE, kind kUndefined = 4, cached NaN, "undefined", and typeof string. |
V8 the_hole_value | Read-only Hole object | Internal marker for array holes and TDZ-style sentinels; JavaScript never receives it directly. |
| JavaScriptCore | Immediate JSValue | ValueUndefined = OtherTag | UndefinedTag, documented as invalid pointer value 0x0a. |
| SpiderMonkey | Tagged JS::Value | JSVAL_TYPE_UNDEFINED = 0x03; PUNBOX64 combines it with JSVAL_TAG_MAX_DOUBLE. |
Where bytecode gets undefined
Ignition bytecode has a direct instruction for the common case: LdaUndefined. The child-process tests prove that return;, falling off the end of a function, return void 0, and return undefined all compile to LdaUndefined followed by Return when undefined resolves to the global value.
return; LdaUndefined, Returnfall off the end LdaUndefined, Returnreturn void 0 LdaUndefined, Returnreturn undefined LdaUndefined, Returnlet x; return x LdaUndefined, Star0, Returnx === undefined Ldar, TestUndefined, ReturnThe interesting exception is a shadowed binding. function f() { let undefined = 5; return undefined; } returns 5 because the local name is just a local variable. The bytecode uses LdaSmi, Star0, then later Ldar r0; it does not load the oddball.
TDZ checks use a different sentinel. For const r = y; let y = 1;, Node prints LdaTheHole, Star1, and ThrowReferenceErrorIfHole. The hole exists so the engine can distinguish “not initialized yet” from the real undefined value.
Registers and missing arguments
Before the first bytecode runs, the interpreter entry trampoline creates the function's register file. In V8 12.4, both x64 and arm64 source say: “push undefined as the initial value for all register file entries.” The accumulator is already loaded with the undefined root when dispatch starts.
// V8 12.4: src/builtins/x64/builtins-x64.cc// If ok, push undefined as the initial value for all register file entries.__ LoadRoot(kInterpreterAccumulatorRegister, RootIndex::kUndefinedValue);__ Push(kInterpreterAccumulatorRegister);// The accumulator is already loaded with undefined. // V8 12.4: src/builtins/arm64/builtins-arm64.cc// If ok, push undefined as the initial value for all register file entries.__ LoadRoot(kInterpreterAccumulatorRegister, RootIndex::kUndefinedValue);__ PushMultipleTimes(kInterpreterAccumulatorRegister, x11);// The accumulator is already loaded with undefined.Missing arguments are filled before the callee reads them. V8's arguments adaptor frame article explains that since V8 8.9 the old adaptor frame is gone; under-application assigns the remaining parameters undefined. The current x64 InvokePrologue still shows the concrete fill loop.
// V8 12.4: src/codegen/x64/macro-assembler-x64.cc// Underapplication. Move the arguments already in the stack, including the// receiver and the return address.// Fill remaining expected arguments with undefined values.LoadRoot(kScratchRegister, RootIndex::kUndefinedValue);movq(Operand(r8, expected_parameter_count, times_system_pointer_size, 0), kScratchRegister);Replay a call with fewer arguments than formal parameters. The values are real; the stack-frame drawing is a teaching model.
script
function makeBadge(name, role, city) { const stackSlots = [name, role, city]; return stackSlots.map(String).join(" | ");} The replay is a labelled teaching model of the call frame, but the values are recorded from the real function: name is "Ada", while role and city are the actual undefined values supplied for the missing formals.
The hole is not undefined
V8 12.4 has a separate Hole type, not an Oddball, for internal markers. The source initializes the_hole_value and several distinct holes: property_cell_hole_value, hash_table_hole_value, promise_hole_value, uninitialized_value, arguments_marker, optimized_out, stale_register, and more. JavaScript code never receives the marker directly.
// V8 12.4: src/objects/hole.hclass Hole : public HeapObject { // Holes store NaN at the HeapNumber value offset for optimized code. DECL_FIELD_OFFSET_TQ(RawNumericValue, HeapObject::kHeaderSize, "float64")}; // V8 12.4: src/heap/setup-heap-internal.ccHole::Initialize(isolate(), factory->the_hole_value(), factory->hole_nan_value());set_property_cell_hole_value(*factory->NewHole());set_hash_table_hole_value(*factory->NewHole());set_promise_hole_value(*factory->NewHole());set_uninitialized_value(*factory->NewHole());set_arguments_marker(*factory->NewHole());set_optimized_out(*factory->NewHole());set_stale_register(*factory->NewHole());let x; xvoid 0- second formal parameter when the call passed one argument
({}).missingafter the prototype walk fails- the middle slot in
[1, , 3] - an array element after
delete array[1] - a
letbinding before initialization - reading
doesNotExist [1, , 3][1]afterArray.prototype[1] = "surprise"
Sort each case by what exists before ordinary JavaScript observes a result.
An empty chair is ready to use. A reserved card is different: it asks for a rule before the chair is treated normally. Holes work like that card.
- In real life: An empty chair anyone can use
- In JavaScript: The observable undefined value
- In real life: A reserved card on a chair
- In JavaScript: The internal hole marker
- In real life: The card is handled before sitting
- In JavaScript: The engine turns a hole into lookup behavior
Where the analogy stops: People can see a reserved card. JavaScript cannot see the hole marker; only engine internals and diagnostics can name it.
Holey arrays and the no-elements protector
Sparse array syntax creates holes. In this Node/V8 build, [1, , 3] debug-prints as HOLEY_SMI_ELEMENTS with <the_hole_value>, while [1, undefined, 3] is PACKED_ELEMENTS because index 1 is a real element. The broader elements-kind story is in Elements kinds.
%DebugPrint for sparse versus explicit undefinedJavaScript%DebugPrint([1, , 3]);%DebugPrint([1, undefined, 3]);const sparse = [1, , 3];const explicit = [1, undefined, 3]; console.log(1 in sparse, 1 in explicit);console.log(sparse[1], explicit[1]);console.log(Object.keys(sparse).join(","));console.log(Object.keys(explicit).join(","));sparse.forEach((value, index) => console.log("sparse", index, String(value)));explicit.forEach((value, index) => console.log("explicit", index, String(value)));The first line of output is false true. Both reads on line 5 print undefined undefined, but the paths differ: the sparse read has no own element. Object.keys and forEach skip the hole and visit the explicit undefined element.
const sparse = [1, , 3];const hadOwn = Object.prototype.hasOwnProperty.call(Array.prototype, 1);const oldValue = Array.prototype[1]; try { Array.prototype[1] = "surprise"; console.log(sparse[1]);} finally { if (hadOwn) { Array.prototype[1] = oldValue; } else { delete Array.prototype[1]; }}This runnable example cleans up after itself. While Array.prototype[1] exists, the hole at index 1 can read the inherited "surprise". V8 protects fast paths with a NoElements protector so they can skip prototype-element walks until someone adds indexed elements to Array.prototype or Object.prototype.
console.log(%NoElementsProtector());const hadOwn = Object.prototype.hasOwnProperty.call(Array.prototype, 1);const oldValue = Array.prototype[1];try { Array.prototype[1] = "prototype element"; console.log(%NoElementsProtector());} finally { if (hadOwn) Array.prototype[1] = oldValue; else delete Array.prototype[1];}The native available in Node 22 is %NoElementsProtector(). It prints true, then false after the prototype write. The source name is verified in protectors.h, where NoElements maps to NoElementsProtector.
Replay a sparse-array read. JavaScript never sees the hole marker directly; it sees the result of the property lookup.
script
const explicit = [1, undefined, 3];console.log(1 in sparse);console.log(1 in explicit);console.log(sparse[1]);const hadOwn = Object.prototype.hasOwnProperty.call(Array.prototype, 1);const oldValue = Array.prototype[1];try { Array.prototype[1] = "surprise"; console.log(sparse[1]);} finally { if (hadOwn) Array.prototype[1] = oldValue; else delete Array.prototype[1];}Missing properties
A named property load first checks the receiver's own shape, then walks prototypes until it either finds a property or reaches null. If nothing is found, the JavaScript result is undefined. This is different from an undeclared identifier, which throws.
const parent = { inherited: "from prototype" };const receiver = Object.create(parent);receiver.present = 1; console.log(receiver.present);console.log(receiver.inherited);console.log(receiver.missing);console.log("missing" in receiver);// V8 12.4: src/ic/ic.ccHandle<Smi> smi_handler = LoadHandler::LoadNonExistent(isolate());handler = MaybeObjectHandle(LoadHandler::LoadFullChain( isolate(), lookup_start_object_map(), MaybeObjectHandle(isolate()->factory()->null_value()), smi_handler)); // V8 12.4: src/ic/accessor-assembler.cc// This is a handler for a load of a non-existent value.exit_point->Return(UndefinedConstant()); // V8 12.4: src/ic/handler-configuration.ccHandle<Object> validity_cell = Map::GetOrCreatePrototypeChainValidityCell( lookup_start_object_map, isolate);handler->set_validity_cell(*validity_cell);The V8 source names LoadHandler::LoadNonExistent, LoadFullChain, and a prototype-chain validity cell. That supports a modest claim: inline caches can cache a “nonexistent” result guarded by the receiver map and prototype-chain validity, then return the undefined constant. For broader IC mechanics, go to Inline caches and prototype internals.
The global undefined property
Modern ECMAScript makes globalThis.undefined non-writable, non-enumerable, and non-configurable. The descriptor is specified for the global value property in ECMA-262. The local test proves the descriptor and the assignment rules.
const descriptor = Object.getOwnPropertyDescriptor(globalThis, "undefined");console.log(descriptor.writable, descriptor.enumerable, descriptor.configurable); (function sloppyAssignment() { undefined = "changed"; console.log(undefined);})(); (function strictAssignment() { "use strict"; try { undefined = "changed"; } catch (error) { console.log(error.name); }})();The first log prints false false false. The sloppy assignment is ignored and prints undefined. The strict assignment throws and the catch block prints TypeError. This explains why void 0 is mostly a legacy/minifier idiom today. Terser documents an unsafe_undefined compression option for substituting void 0 in specific legacy-compatible situations, but V8's bytecode shows no speed win for unshadowed code.
Playground: pick a snippet and read the bytecode
Choose one of more than ten snippets. The left pane shows the source, and the right pane shows only the stable bytecode mnemonics captured from real Node output. Tests rerun every option with --print-bytecode --print-bytecode-filter.
function retVoid() { return; }retVoid();00 LdaUndefined01 ReturnA bare return loads the undefined oddball and returns it.
A bare return loads the undefined oddball and returns it.
Practical use
Most application code should not chase oddballs. Use this engine knowledge to avoid confusing holes with present values, to read profiles more accurately, and to keep code clear when undefined has a real meaning.
| Habit | Use it when | Engine reason |
|---|---|---|
| Prefer packed arrays | Use [value, undefined, value] when a present undefined element is intended. | A holey elements kind carries extra checks and prototype-observation rules. |
Avoid casual new Array(n) in hot paths | Fill deliberately or use Array.from when every index should exist. | Pre-sized arrays start with holes; measure before rewriting clear code. |
Do not redefine undefined | A local let undefined = ... is legal but confusing. | It disables the special global LdaUndefined shortcut for that binding. |
Use void 0 for legacy minifier patterns, not speed | Modern V8 emits the same LdaUndefined for unshadowed undefined and void 0. | Choose readability unless a build tool deliberately wants void 0. |
- Prefer packed arrays when every index should exist; use explicit
undefinedonly when a present element matters. - Do not pre-size with
new Array(n)in a hot path unless measurement says the holey shape is acceptable. - Do not create local bindings named
undefined; it is legal, but it fights readers and the special bytecode path. - Use
void 0for deliberate compatibility or generated code, not because modern V8 makes it faster.
Common misconceptions
- “Every empty-looking slot is undefined.” No. Array holes and TDZ sentinels are internal holes until a rule turns them into a result or a throw.
- “Reading a hole simply returns undefined.” Not directly. The prototype chain is consulted first, so inherited indexed properties can be observed.
- “
void 0is faster thanundefined.” Not for the unshadowed cases proved here; both compile toLdaUndefined. - “Missing property and undeclared variable are the same.” A missing property returns undefined; an unresolved identifier throws
ReferenceError. - “Node debug addresses are facts to memorize.” Addresses and sizes vary by build. Type names and bytecode mnemonics are the stable evidence.
| Case | What it is | What JS observes | Engine note |
|---|---|---|---|
undefined | The actual language value | typeof is "undefined"; strict equality can test it | V8 Oddball in ReadOnlySpace. |
| array hole | A missing element slot | Reads as undefined only after prototype lookup | V8 Hole, not an Oddball. |
| missing property | No own or inherited property found | Property load returns undefined | IC can cache a LoadNonExistent handler. |
| missing argument | No value was supplied for a formal parameter | Parameter is undefined inside the function | Invoke prologue fills missing slots with the undefined root. |
| undeclared name | No lexical or global binding exists | Reading throws ReferenceError | Not a value and not the undefined oddball. |
Practice exercises
6 EXERCISESWhich bytecode mnemonic loads the real undefined value for this function?
function f() { return undefined; }
f();The mnemonic is LdaUndefined: load the undefined root into the accumulator.
What does the first log, 1 in sparse, print?
const sparse = [1, , 3];
const explicit = [1, undefined, 3];
console.log(1 in sparse);
console.log(1 in explicit);The program prints false then true. The hole is absent; the explicit undefined element is present.
What does the program print?
function second(a, b) {
return b;
}
console.log(second("first"));The function prints undefined because the missing formal parameter is filled with the undefined value.
Is the global undefined property writable?
const descriptor = Object.getOwnPropertyDescriptor(globalThis, "undefined");
console.log(descriptor.writable);It prints false; the global property is not writable.
What does the sparse read print after the prototype write?
const sparse = [1, , 3];
const hadOwn = Object.prototype.hasOwnProperty.call(Array.prototype, 1);
const oldValue = Array.prototype[1];
try {
Array.prototype[1] = "surprise";
console.log(sparse[1]);
} finally {
if (hadOwn) {
Array.prototype[1] = oldValue;
} else {
delete Array.prototype[1];
}
}It prints surprise. The hole falls through to Array.prototype[1] before the finally block cleans up.
Which word describes arrays with every index present?
const packed = Array.from({ length: 3 }, (_, index) => index + 1);
const holey = new Array(3);
console.log(packed.length, 1 in packed);
console.log(holey.length, 1 in holey);Use packed arrays when every index should exist. The sample prints that index 1 exists in packed but not in holey.
Quiz: check your understanding
8 QUESTIONSQuestion 1 of 8What is V8's observable
undefinedvalue in this lesson?Choose an answer to see the explanation.
Question 2 of 8What does this program print?
Read the code, then predictconst a = undefined; const b = void 0; console.log(a === b);Choose an answer to see the explanation.
Question 3 of 8Which bytecode pair did Node 22 print for
return undefinedandreturn void 0?Choose an answer to see the explanation.
Question 4 of 8What do these checks print?
Read the code, then predictconst sparse = [1, , 3]; const explicit = [1, undefined, 3]; console.log(1 in sparse); console.log(1 in explicit);Choose an answer to see the explanation.
Question 5 of 8What does this TDZ bytecode prove?
Read the code, then predictJavaScriptfunction tdz() { const r = y; let y = 1; return r; }Choose an answer to see the explanation.
Question 6 of 8What does the missing-argument program print?
Read the code, then predictfunction second(a, b) { return b; } console.log(second("first"));Choose an answer to see the explanation.
Question 7 of 8What happens when a sloppy function assigns to global
undefined?Read the code, then predict(function sloppyAssignment() { undefined = "changed"; console.log(undefined); })();Choose an answer to see the explanation.
Question 8 of 8Which production habit follows from holes and elements kinds?
Choose an answer to see the explanation.
Key takeaways
- Observable
undefinedis one read-only V8 Oddball; producing it does not allocate. LdaUndefinedcovers common sources such asreturn;,void 0, and the unshadowed global identifier.- The hole is a separate internal marker used for sparse arrays and TDZ-style checks; JavaScript cannot observe it directly.
- Missing properties and missing arguments produce undefined through different engine paths.
- Prefer packed arrays and clear code; measure hot paths before changing shape for engine reasons.
One-line definition: undefined is V8's read-only undefined Oddball, while holes and missing lookups are engine paths that may eventually produce that value.
Up next: null inside the engine.