How it works
The spec expresses lookup as recursion. For an ordinary object, [[Get]] calls OrdinaryGet(obj, key, receiver):
- Ask
obj.[[GetOwnProperty]](key).
- If there is no own property, get
parent = obj.[[GetPrototypeOf]](). If parent is null, return undefined; otherwise return parent.[[Get]](key, receiver).
- If it is a data property, return its value. If it is an accessor, call its getter with
receiver as this.
receiver is the object the lookup started from, and it is passed down the chain unchanged. That is why a getter or method found on a prototype sees the original object as this.
[[Set]] follows the same shape. OrdinarySetWithOwnDescriptor finds the first object on the chain that has the key, then decides based on what it found. If no object has it, the key is treated as a plain writable data property:
- Not found anywhere, or found as a writable data property: the write goes to the receiver, not to the object where the property was found. If the receiver has no own property of that name, it creates one (
CreateDataProperty); if it has one, it updates it.
- Found as a non-writable data property:
[[Set]] returns false. Strict-mode code turns false into a TypeError.
- Found as an accessor: the setter is called with the receiver as
this. With no setter, [[Set]] returns false.
Two details: the check for a non-writable property happens along the whole chain, not only on the object being written to, and the "write goes to the receiver" rule is why inheriting a writable property never modifies the prototype.
Edge cases
Writes treat the chain differently from reads, and defineProperty does not follow the write rules at all:
'use strict';
const proto = Object.freeze({ limit: 10 });
const child = Object.create(proto);
try {
child.limit = 20; // blocked by the inherited non-writable property
} catch (error) {
console.log(error.constructor.name); // → TypeError
}
Object.defineProperty(child, 'limit', { value: 20, enumerable: true });
console.log(child.limit, proto.limit); // → 20 10
Assignment is blocked by the inherited non-writable property; defineProperty only looks at child itself, so it can create the shadowing property anyway. This is also the standard workaround when a frozen prototype blocks a normal assignment.
Other edge cases:
- Primitives.
'abc'.length or (5).toFixed(1) works because property access on a primitive applies ToObject, which makes a temporary wrapper whose chain leads to String.prototype or Number.prototype.
- Cycles.
OrdinarySetPrototypeOf walks the new prototype's chain and returns false if it meets the object being modified; Object.setPrototypeOf turns that into a TypeError. The walk stops at any non-ordinary object (such as a Proxy), so a cycle through one is not detected.
- Non-extensible objects.
Object.preventExtensions, seal and freeze also fix [[Prototype]]: OrdinarySetPrototypeOf returns false if the object is non-extensible and the prototype would change.
Object.prototype is an immutable prototype exotic object: its [[Prototype]] can never be changed from null.
Real-world scenario
Class methods are the common case. class User { greet() {} } puts greet on User.prototype once. A million User instances share one function through the chain instead of owning a million copies. The same mechanism makes Array.prototype.map available on every array, and it is what a code-review comment about "mutating shared state through a prototype" is warning about:
function Cart() {}
Cart.prototype.items = []; // shared by every Cart
const a = new Cart();
const b = new Cart();
a.items.push('book');
console.log(b.items); // → [ 'book' ]
The fix is to create the array per instance (this.items = [] in the constructor, or a class field), so each cart owns its array.
Trade-offs
Delegation keeps memory low and lets you patch behaviour for every object at once, but it makes behaviour depend on the chain at the time of the access. A property can come from several objects away, so reading a single file no longer tells you where a value originates. Deep chains also cost a little per miss, since each level is another check. Engines hide most of this with hidden classes and inline caches, which work best when objects keep a stable shape and prototype; changing a prototype after creation can invalidate those caches (see the __proto__ question).
Copying (spreading or Object.assign) takes a snapshot instead: no sharing surprises, but no updates either, and no instanceof/method reuse. Choose delegation when behaviour should be shared and live; choose copying when each object must be independent.