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.
The prototype chain as the engine walks it
"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.
- 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).
- 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. - 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.
- 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.
- 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.
- Do not shadow conditionally.
if (x) this.speak = customon some instances gives those a different map; call sites go polymorphic. - 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. - Never
Object.setPrototypeOfa 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. - Depth is fine. Ten levels of inheritance cost nothing per call once warm. The arguments against deep hierarchies are about design, not the engine.
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.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.
- 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.
- 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.
- 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.
Object.definePropertyon 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.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.- 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, sofor...inover the same shape is fast after the first time. - Non-configurable properties cannot be deleted or reconfigured; they also impose Proxy invariants.
Object.keys,values,entries: use the map's enumeration cache; linear in property count; allocate the result array. Fine outside hot loops.hasOwnProperty,in: IC-cached like loads (there is a HasIC).Object.hasOwnis the same without the prototype-method lookup.Object.getOwnPropertyDescriptor: builds a fresh descriptor object each call; a lookup plus an allocation.Object.getOwnPropertyNames,Reflect.ownKeys: walk the descriptors; include non-enumerables and (ownKeys) symbols.- Dictionary-mode objects: all of the above become hash-table walks;
for...inmust sort integer keys and preserve insertion order, which is slower there.
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.
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- The constructor is a function object with a
prototypeproperty;newallocates 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. - 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. - Fields (
name,#sound) are initialised per instance by a synthesised initialiser function that runs at the start of the constructor (aftersuperin 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. - 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.
- Static members are properties of the constructor function.
extendssets the constructor's own prototype to the parent constructor, so statics inherit through a second chain. super.method()uses the method's HomeObject (the prototype it was defined on) to find the parent: a load fromHomeObject.[[Prototype]], cached like any prototype lookup.super(...)in a derived constructor calls the parent with the same new.target and bindsthisafterwards; accessingthisbefore it throws (a TDZ check on thethisbinding).- Accessors are defined with
definePropertysemantics on the prototype;staticblocks run once at definition.
- Classes produce the best possible shapes if every field is declared (as a field or in the constructor) and nothing is added later.
- 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.
- 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.
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.
// 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
- 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.
- 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.
- 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.
- 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.
- 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. - Validation and logging in development: wrap in dev, pass through in prod.
- Virtual objects: remote objects, lazy-loaded records, default values. Cost is dwarfed by the I/O behind them.
- Membranes and sandboxes: security boundaries where every access must be mediated. Cost is the point.
- 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.
- 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.
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.Object creation patterns compared
| Pattern | What the engine does | Shape outcome | Cost per object |
|---|---|---|---|
{x: 1, y: 2} literal | CreateObjectLiteral from a boilerplate with the final map; copies constant fields | One 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 constructor | Allocate 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 deterministic | Low: allocation plus N cached transitions; slack tracking fixes the size |
| Class with field declarations | Same as above via the synthesised initialiser; declaration order fixed | Best: shape fixed by syntax | Low |
Object.create(proto) then assignments | An empty object with that prototype, then transitions | One map per prototype per path | Low; 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 key | Depends on a's and b's shapes; consistent inputs give consistent output | Medium: enumeration plus N adds |
{...a, extra: 1} spread | CloneObject IC: caches the source map → result map; copies fields in one step when the IC hits | Consistent for a consistent source | Low when warm; faster than Object.assign |
JSON.parse(text) | A dedicated parser; infers maps and reuses them for repeated shapes in the same parse | Consistent within one parse for repeated objects; different parses of the same shape share via the transition tree | Low per object; the fastest way to build many objects from data |
structuredClone(o) | Serialise and deserialise through the structured clone algorithm | Fresh 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 properties | Different branches add different properties | Several maps: polymorphic consumers | Low per object; high downstream |
Object used as a map (o[key] = v with dynamic keys) | Transitions per new key until the limit, then dictionary mode | Unique and slow | High; use Map |
Object.freeze(literal) | Literal creation plus a freeze transition | A frozen map; stable forever | Low; one extra transition |
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.Reading objects: tools
| Tool | Shows | Use it for |
|---|---|---|
%DebugPrint(o) | Map, descriptors with kinds and attributes, prototype (recursively), dictionary mode, elements | The whole truth about one object |
Object.getOwnPropertyDescriptors(o) | Every own property's kind and attributes, from JavaScript | Checking what a library's objects look like without natives syntax |
Object.getPrototypeOf in a loop | The chain | Seeing how deep it goes and what is on each level |
--log-ic with "(prototype)" and holder info | Where each property was found and whether the site is mono/poly/mega | Prototype method call sites that went polymorphic |
--trace-deopt reason "prototype chain changed" / "wrong map" on method calls | Chain invalidation | Finding the code that modified a prototype late |
--trace-maps + map-processor | Map creation with stacks; the transition tree | Which construction site produced the extra maps |
DevTools Memory snapshot → a class name → instance count and the map system object counts | How many maps exist for one class | Shape explosion from inconsistent construction |
| DevTools Performance → a hot frame → "(get x)" entries | Getters appearing as separate frames in the interpreter; absent when inlined | Whether a getter is a cost at the current tier |
| A microbenchmark of proxied versus raw access | The proxy price on your machine | Deciding where a proxy boundary may sit |
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.