Prototypes in Depth
JavaScript has no classes at its core — only objects linked to other objects. That link is the prototype, and every property lookup that fails on an object walks up this chain until it finds a match or hits null. Understanding the difference between an instance’s hidden [[Prototype]] and a constructor’s .prototype property is the single most clarifying insight in the language, because class syntax is just sugar over this same machinery.
The prototype chain
Every object has an internal slot called [[Prototype]], a reference to another object (or null). When you read a property, the engine checks the object itself, then its [[Prototype]], then that object’s [[Prototype]], and so on. This linked sequence is the prototype chain.
const animal = { eats: true };
const rabbit = { jumps: true };
Object.setPrototypeOf(rabbit, animal); // rabbit's [[Prototype]] is now animal
console.log(rabbit.jumps); // own property
console.log(rabbit.eats); // inherited via the chain
console.log(rabbit.flies); // not found anywhere
Output:
true
true
undefined
Writes, by contrast, almost always create an own property on the target object — they do not modify objects up the chain (the exception being inherited setters).
__proto__ vs prototype
These two names confuse nearly everyone, but the rule is simple: they refer to different things on different kinds of objects.
| Term | Lives on | What it is |
|---|---|---|
[[Prototype]] | every object | the internal slot — the actual link |
__proto__ | every object | a legacy accessor for [[Prototype]] |
Constructor.prototype | functions | the object that becomes an instance’s [[Prototype]] |
Object.getPrototypeOf(obj) | the standard API | the modern way to read the link |
Only functions carry a .prototype property. It exists so that when the function is called with new, the engine knows which object to wire up as the new instance’s prototype.
function Dog(name) {
this.name = name;
}
Dog.prototype.bark = function () {
return `${this.name} says woof`;
};
const d = new Dog("Rex");
console.log(Object.getPrototypeOf(d) === Dog.prototype); // the link
console.log(d.bark());
Output:
true
Rex says woof
Prefer
Object.getPrototypeOf/Object.setPrototypeOfover__proto__. The__proto__accessor is standardized only as a legacy annex and is slower and less predictable than the explicit methods.
Visually, the relationship looks like this:
d ───────────────► Dog.prototype ──────────► Object.prototype ──► null
(instance) { bark, constructor } { toString, ... }
{ name: "Rex" } ▲
│
Dog (function) ──.prototype──┘
What new actually does
The new operator is not magic. Calling new Fn(args) performs four observable steps:
- Create a fresh empty object.
- Set that object’s
[[Prototype]]toFn.prototype. - Run
Fnwiththisbound to the new object. - Return the new object — unless the function explicitly returns its own (non-primitive) object.
You can reproduce it by hand to prove there’s nothing hidden:
function construct(Fn, ...args) {
const obj = Object.create(Fn.prototype); // steps 1 & 2
const result = Fn.apply(obj, args); // step 3
return typeof result === "object" && result !== null ? result : obj; // step 4
}
function Point(x, y) {
this.x = x;
this.y = y;
}
const p = construct(Point, 3, 4);
console.log(p instanceof Point, p.x, p.y);
Output:
true 3 4
Note step 4: instanceof works because it checks whether Point.prototype appears anywhere in p’s chain — not how p was built.
How classes desugar
The class keyword is syntactic sugar over constructor functions and prototype objects. Methods declared in a class body land on Class.prototype; static members land on the constructor itself; and extends sets up two chains at once.
class Animal {
constructor(name) { this.name = name; }
speak() { return `${this.name} makes a sound`; }
}
class Cat extends Animal {
speak() { return `${this.name} meows`; }
}
const c = new Cat("Milo");
console.log(c.speak());
console.log(Object.getPrototypeOf(Cat.prototype) === Animal.prototype); // instance chain
console.log(Object.getPrototypeOf(Cat) === Animal); // static chain
Output:
Milo meows
true
true
The near-equivalent pre-class form makes the wiring explicit:
function Animal(name) { this.name = name; }
Animal.prototype.speak = function () { return `${this.name} makes a sound`; };
function Cat(name) { Animal.call(this, name); } // super() in the constructor
Cat.prototype = Object.create(Animal.prototype); // instance chain
Cat.prototype.constructor = Cat; // restore constructor pointer
Cat.prototype.speak = function () { return `${this.name} meows`; };
The one thing classes add that the old pattern can’t fully replicate is super, plus non-enumerable methods and the inability to call a class without new.
Modifying built-in prototypes
Because the chain ultimately reaches Object.prototype, Array.prototype, and friends, you can add methods to them — and you almost never should. Adding an enumerable property to Object.prototype leaks into every for...in loop in the program; adding to Array.prototype risks colliding with future standard methods (the infamous Array.prototype.flatten / SmooshGate incident).
// Don't ship this.
Array.prototype.last = function () { return this[this.length - 1]; };
console.log([1, 2, 3].last()); // works, but pollutes every array
Monkey-patching built-ins creates global, version-fragile coupling. Use standalone helper functions or, if you must extend,
class MyArray extends Array {}for an isolated subclass.
Performance
Modern engines (V8, SpiderMonkey, JavaScriptCore) optimize property access through hidden classes / shapes and inline caches. The practical rules:
- Keep object shapes stable — assign all properties in the constructor in the same order, rather than adding them piecemeal later.
- Shorter prototype chains resolve faster; deeply nested inheritance costs lookup time on cache misses.
- Never mutate
[[Prototype]]after creation withObject.setPrototypeOfon hot objects — it deoptimizes every inline cache that touched the object. Choose the prototype at creation viaObject.createornew.
Best Practices
- Read links with
Object.getPrototypeOfand create linked objects withObject.create; treat__proto__as read-only legacy. - Define shared methods on the prototype (or in a class body), and instance-specific data on the instance — don’t put methods inside the constructor body.
- Use
class/extendsfor inheritance; it’s clearer, gives yousuper, and produces non-enumerable methods automatically. - Never add enumerable properties to
Object.prototype, and avoid patching any built-in prototype in shared code. - Set an object’s prototype at creation time, never reassign it later, to keep engine optimizations intact.
- Use
Object.hasOwn(obj, key)(orObject.prototype.hasOwnProperty.call) to distinguish own properties from inherited ones.