Part 7 · 6 chapters · ~45 min

Objects And Prototypes

The prototype chain is walked once and then guarded, property kinds decide what an access compiles to, and classes are the engine's preferred way to produce stable shapes. This part is the chain as the engine walks it and what breaks it, fields, constants, accessors and attributes, classes piece by piece, Proxy and Reflect as the metaobject protocol and its price, object creation patterns compared, and the tools.

52

The prototype chain as the engine walks it

the question

"A method five prototypes up: is that five hash lookups per call?"

Once, at most. The first lookup walks the chain, checking each object's map for the property (a descriptor array scan, not a hash, for fast-mode objects). The inline cache then records where it was found and guards the result with the receiver's map and a validity cell on the prototype chain. Every later call is two compares. The chain's depth is paid on a miss; its stability is what matters.

the walk
  1. Start at the receiver. Its map's descriptor array lists own properties. Not found: move to the map's prototype (the prototype is part of the map, so no load from the object).
  2. Each prototype is usually a fast-mode object with its own map and descriptors. Prototypes with many properties (Array.prototype, Object.prototype) are kept in a special fast mode optimised for lookup, with their own caching.
  3. Found: the IC records (receiver map, holder object, location or constant). Not found at null: the IC records a "nonexistent" handler so the next miss is fast too.
  4. Guards: prototype validity cells. Each prototype map carries a cell; modifying any object in a chain invalidates the cells below it. An IC check is: receiver map matches, and the cell is still valid.
what makes chains fast and what breaks them
  1. Define methods on prototypes at definition time (classes do this) and never touch them again. Every instance shares one map; every method call site is monomorphic; the optimiser inlines.
  2. Do not shadow conditionally. if (x) this.speak = custom on some instances gives those a different map; call sites go polymorphic.
  3. Do not modify prototypes after instances exist, especially built-ins. One Array.prototype.last = ... at load is fine (nothing is hot yet); the same thing later invalidates every array-method call site in the program.
  4. Never Object.setPrototypeOf a live object. The object gets a new map in a different tree; every IC that saw it misses; V8 also marks such objects for slower handling.
  5. Depth is fine. Ten levels of inheritance cost nothing per call once warm. The arguments against deep hierarchies are about design, not the engine.
run it
node --log-ic --logfile=- -e "class A{f(){}} class B extends A{} class C extends B{} const c=new C(); for(let i=0;i<3;i++) c.f()" | grep LoadIC. One transition to monomorphic, with "(prototype)" or a holder noted; then nothing, because the cache hits.
A PROPERTY LOOKUP, WALKING THE CHAIN
what o.method() costs at each depth
swipe the figure sideways, or tap expand for full screen
1/6
own properties
const d = new Dog("rex"); d.speak(). The engine starts at d: its map says d has own properties name only. speak is not there. The map knows this without a hash lookup: the descriptor array is small and the IC remembers "not here".
53

Property kinds: fields, constants, accessors, attributes

Every property is one of three kinds in the map's descriptor array, and each kind compiles to a different access sequence. Attributes (writable, enumerable, configurable) are stored alongside and mostly affect writes and reflection.

the kinds
  1. Data field: a value in a slot. Load from the recorded location with the recorded representation. The optimiser additionally tracks field constness: a field never written after initialisation is a "const field" and its value can be embedded in optimised code for a specific receiver map.
  2. Data constant (descriptor-level): historically a separate kind for functions on prototypes; now mostly expressed via const fields. Either way, a prototype method is a compile-time constant to the optimiser.
  3. Accessor pair: getter and setter functions (or an API accessor for host objects). The IC calls the getter. In optimised code, a monomorphic small getter is inlined; get x() { return this._x } costs the same as a field when warm. In the interpreter, it is a call.
attributes and the operations that change them
  1. Object.defineProperty on a new key: an add transition with the given attributes (the transition is keyed by name and attributes, so {writable: false} and a normal add lead to different maps). On an existing key with different attributes: a reconfigure transition. Converting data to accessor or back: another transition.
  2. Object.freeze, seal, preventExtensions: transitions to maps with the corresponding integrity level. Reads unchanged; stores take a slow path; new properties are refused. Frozen arrays get a frozen elements kind. Freezing is cheap and makes maps maximally stable; the optimiser likes frozen objects.
  3. Non-enumerable properties (class methods are) are skipped by for...in, Object.keys, spread, JSON.stringify. The enumeration cache per map stores the enumerable keys in order, so for...in over the same shape is fast after the first time.
  4. Non-configurable properties cannot be deleted or reconfigured; they also impose Proxy invariants.
reflection costs
  1. Object.keys, values, entries: use the map's enumeration cache; linear in property count; allocate the result array. Fine outside hot loops.
  2. hasOwnProperty, in: IC-cached like loads (there is a HasIC). Object.hasOwn is the same without the prototype-method lookup.
  3. Object.getOwnPropertyDescriptor: builds a fresh descriptor object each call; a lookup plus an allocation.
  4. Object.getOwnPropertyNames, Reflect.ownKeys: walk the descriptors; include non-enumerables and (ownKeys) symbols.
  5. Dictionary-mode objects: all of the above become hash-table walks; for...in must sort integer keys and preserve insertion order, which is slower there.
PROPERTY KINDS IN THE MAP
data fields, constants, accessors, and what each costs to read
swipe the figure sideways, or tap expand for full screen
1/6
data field
Data field: the common case. The map records in-object offset or properties-array index, and a representation (Smi, Double, HeapObject, Tagged). The IC handler is a load from that location.
54

Classes, as the engine sees them

A class is syntax over a constructor function, a prototype object with methods, and some rules about super and fields. The engine has special support for a few pieces (the DefineClass builtin, home objects for super, private names), but the objects it produces are the same maps and prototypes as the pre-class pattern, built in one step.

code
class Animal {
  #sound = 'generic'                 // private field: a private symbol key; brand-checked; as fast as a field when warm
  static count = 0                   // on the constructor function object, not on instances
  name                               // public field: defined per instance in constructor order (shape matters: same for all)
  constructor(name) { this.name = name; Animal.count++ }
  speak() { return `${this.name}: ${this.#sound}` }   // on Animal.prototype, non-enumerable, one function object
  get loud() { return this.speak().toUpperCase() }    // accessor on the prototype
}
class Dog extends Animal {
  constructor(name) { super(name) }  // super: the parent constructor is called with the same `this` (derived: `this` exists after super returns)
  speak() { return super.speak() + '!' }   // super.speak: looked up on Dog.prototype's prototype (HomeObject), not on this
}
// desugars (roughly) to: function Animal(name){…}; Animal.prototype.speak = …; Object.defineProperty(Animal.prototype, 'loud', {get});
// Dog.prototype = Object.create(Animal.prototype); Object.setPrototypeOf(Dog, Animal)   // static inheritance via the constructor's chain
// instances of Dog: map with name (and #sound) in-object; prototype chain: Dog.prototype → Animal.prototype → Object.prototype
what each piece is
  1. The constructor is a function object with a prototype property; new allocates an object with that prototype (via the constructor's initial map, cached on the function) and calls it. The initial map is the root of the transition tree for instances.
  2. Methods are closures installed on the prototype as non-enumerable data properties at class definition time, all at once by DefineClass. One function object per method, shared by all instances.
  3. Fields (name, #sound) are initialised per instance by a synthesised initialiser function that runs at the start of the constructor (after super in derived classes). They are property additions in declaration order, so every instance walks the same transitions. Field initialisers are the modern place to establish shape.
  4. Private fields are keyed by a private symbol unique to the class evaluation. Stored like normal properties; access does a brand check (does this object have the private name) which is an IC like any other. Not reflectable; not a Proxy trap target.
  5. Static members are properties of the constructor function. extends sets the constructor's own prototype to the parent constructor, so statics inherit through a second chain.
  6. super.method() uses the method's HomeObject (the prototype it was defined on) to find the parent: a load from HomeObject.[[Prototype]], cached like any prototype lookup. super(...) in a derived constructor calls the parent with the same new.target and binds this afterwards; accessing this before it throws (a TDZ check on the this binding).
  7. Accessors are defined with defineProperty semantics on the prototype; static blocks run once at definition.
what this means for performance
  1. Classes produce the best possible shapes if every field is declared (as a field or in the constructor) and nothing is added later.
  2. A class hierarchy is one transition tree per constructor, with each class's initial map descending from Object. Instances of Dog and Cat with identical fields still have different maps (different prototypes), so a function handling both is polymorphic. That is usually fine (two maps); with ten subclasses it is megamorphic. Composition over a wide inheritance fan-out keeps call sites monomorphic.
  3. Getter-heavy classes are fine in optimised code and a bit slower in the interpreter. Hot getters that do work should be methods so the cost is visible at the call site.
55

Proxy and Reflect: the metaobject protocol and its price

Every operation on an object (get, set, has, delete, enumerate, define, prototype access, call, construct) is an internal method. Reflect exposes each one directly; Proxy lets you replace each one with a trap. Together they are the language's metaobject protocol, and they are the one place where the engine's fast paths are deliberately switched off.

code
// the metaobject protocol: every language-level operation is one of these internal methods, each reachable via Reflect
Reflect.get(o, k, receiver)            // o.k           → [[Get]]
Reflect.set(o, k, v, receiver)         // o.k = v       → [[Set]]
Reflect.has(o, k)                      // k in o        → [[HasProperty]]
Reflect.deleteProperty(o, k)           // delete o.k    → [[Delete]]
Reflect.ownKeys(o)                     // Object.keys + symbols + non-enumerables → [[OwnPropertyKeys]]
Reflect.getOwnPropertyDescriptor(o, k) // → [[GetOwnProperty]]
Reflect.defineProperty(o, k, desc)     // → [[DefineOwnProperty]]; returns false instead of throwing
Reflect.getPrototypeOf(o) / setPrototypeOf(o, p)
Reflect.apply(fn, thisArg, args)       // fn.apply, without trusting fn.apply to be the real one
Reflect.construct(Fn, args, newTarget) // new Fn(...args), with new.target control

// a Proxy trap exists for each: get, set, has, deleteProperty, ownKeys, getOwnPropertyDescriptor, defineProperty,
// getPrototypeOf, setPrototypeOf, isExtensible, preventExtensions, apply (callable targets), construct
// the pairing is exact: the default behaviour of every trap is the matching Reflect method
how a proxy executes
  1. A JSProxy is its own instance type. It has no map describing properties; it has a target and a handler. ICs, on seeing a proxy receiver, install a generic handler that calls into the runtime every time.
  2. Each operation looks up the trap on the handler (a normal property lookup; the handler object has a map and the lookup is cached), calls it if present, else forwards to the target with the matching Reflect operation.
  3. Invariant checks after the trap: the result must be consistent with non-configurable properties of the target (a proxy cannot report a non-configurable property as absent, or report a different value for a non-writable non-configurable one). These checks read the target's descriptor, another lookup.
  4. Cost: tens to a hundred nanoseconds per operation versus one or two instructions. Nothing is cached across calls; nothing is inlined. A proxy in a hot loop is a 50 to 100× slowdown on those accesses.
what proxies are for, and the discipline
  1. Reactivity: track which properties a computation read; trigger re-runs on write. Vue 3's reactive, MobX observables, Solid's stores, Immer's drafts. The accesses are few relative to the work they drive, and frameworks unwrap (toRaw, unref) before heavy computation.
  2. Validation and logging in development: wrap in dev, pass through in prod.
  3. Virtual objects: remote objects, lazy-loaded records, default values. Cost is dwarfed by the I/O behind them.
  4. Membranes and sandboxes: security boundaries where every access must be mediated. Cost is the point.
  5. Not for: data in hot loops, large arrays accessed by index, anything the profiler shows. A proxied array of 100,000 elements iterated per frame is a bug.
  6. The discipline: proxies at the edges, raw objects in the middle. Shallow wrapping (wrap on access, not whole trees up front). Revocable proxies (Proxy.revocable) when the wrapped thing has a lifetime.
run it
Time a loop summing o.x a million times on a plain object, then on new Proxy(o, {}) with no traps (the forwarding path alone), then with a get trap. The three numbers, in a function called many times so the optimiser sees it, are the price list.
WHAT A PROXY COSTS
a property read through a trap, versus a plain read
swipe the figure sideways, or tap expand for full screen
1/6
JSProxy
const p = new Proxy(target, { get(t, k, r) { log(k); return Reflect.get(t, k, r) } }). p is a JSProxy: a distinct instance type with a target and a handler. It has no map in the normal sense; property lookup on it never reaches a descriptor array.
56

Object creation patterns compared

PatternWhat the engine doesShape outcomeCost per object
{x: 1, y: 2} literalCreateObjectLiteral from a boilerplate with the final map; copies constant fieldsOne map per literal site (shared with identical literals via the boilerplate's map)Lowest: one allocation, fields copied
new Point(1, 2) with field assignments in the constructorAllocate with the constructor's initial map; each this.x = is a transition (found in the tree after the first instance)One map for all instances, if the constructor is deterministicLow: allocation plus N cached transitions; slack tracking fixes the size
Class with field declarationsSame as above via the synthesised initialiser; declaration order fixedBest: shape fixed by syntaxLow
Object.create(proto) then assignmentsAn empty object with that prototype, then transitionsOne map per prototype per pathLow; but the empty-object start means more transitions than a literal
Object.assign({}, a, b)Enumerate a's and b's keys; add each; transitions per keyDepends on a's and b's shapes; consistent inputs give consistent outputMedium: enumeration plus N adds
{...a, extra: 1} spreadCloneObject IC: caches the source map → result map; copies fields in one step when the IC hitsConsistent for a consistent sourceLow when warm; faster than Object.assign
JSON.parse(text)A dedicated parser; infers maps and reuses them for repeated shapes in the same parseConsistent within one parse for repeated objects; different parses of the same shape share via the transition treeLow per object; the fastest way to build many objects from data
structuredClone(o)Serialise and deserialise through the structured clone algorithmFresh maps rebuilt by insertion order; prototypes lost (plain objects)High: a full walk and rebuild; use for cross-realm or deep copies, not in loops
Factory with conditional propertiesDifferent branches add different propertiesSeveral maps: polymorphic consumersLow per object; high downstream
Object used as a map (o[key] = v with dynamic keys)Transitions per new key until the limit, then dictionary modeUnique and slowHigh; use Map
Object.freeze(literal)Literal creation plus a freeze transitionA frozen map; stable foreverLow; one extra transition
the pointer
Part 8 is functions and closures: what a function object holds, what a closure captures, how this is bound in every call form, and what bind, call and apply cost. Methods on prototypes are closures over nothing; the ones that capture are the ones to look at.
57

Reading objects: tools

ToolShowsUse it for
%DebugPrint(o)Map, descriptors with kinds and attributes, prototype (recursively), dictionary mode, elementsThe whole truth about one object
Object.getOwnPropertyDescriptors(o)Every own property's kind and attributes, from JavaScriptChecking what a library's objects look like without natives syntax
Object.getPrototypeOf in a loopThe chainSeeing how deep it goes and what is on each level
--log-ic with "(prototype)" and holder infoWhere each property was found and whether the site is mono/poly/megaPrototype method call sites that went polymorphic
--trace-deopt reason "prototype chain changed" / "wrong map" on method callsChain invalidationFinding the code that modified a prototype late
--trace-maps + map-processorMap creation with stacks; the transition treeWhich construction site produced the extra maps
DevTools Memory snapshot → a class name → instance count and the map system object countsHow many maps exist for one classShape explosion from inconsistent construction
DevTools Performance → a hot frame → "(get x)" entriesGetters appearing as separate frames in the interpreter; absent when inlinedWhether a getter is a cost at the current tier
A microbenchmark of proxied versus raw accessThe proxy price on your machineDeciding where a proxy boundary may sit
run it
node --allow-natives-syntax -e "class A{constructor(){this.x=1}} const a=new A(); const b=new A(); b.y=2; %DebugPrint(a); %DebugPrint(b)". Two maps with a back pointer from b's to a's; a function that receives both is polymorphic from then on.