Skip to content
JavaScript js advanced 5 min read

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.

TermLives onWhat it is
[[Prototype]]every objectthe internal slot — the actual link
__proto__every objecta legacy accessor for [[Prototype]]
Constructor.prototypefunctionsthe object that becomes an instance’s [[Prototype]]
Object.getPrototypeOf(obj)the standard APIthe 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.setPrototypeOf over __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:

  1. Create a fresh empty object.
  2. Set that object’s [[Prototype]] to Fn.prototype.
  3. Run Fn with this bound to the new object.
  4. 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 with Object.setPrototypeOf on hot objects — it deoptimizes every inline cache that touched the object. Choose the prototype at creation via Object.create or new.

Best Practices

  • Read links with Object.getPrototypeOf and create linked objects with Object.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/extends for inheritance; it’s clearer, gives you super, 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) (or Object.prototype.hasOwnProperty.call) to distinguish own properties from inherited ones.
Last updated June 1, 2026
Was this helpful?