JavaScript Prototypes and the Prototype Chain Explained (with `class` Decoded)

Link copied
JavaScript Prototypes and the Prototype Chain Explained (with `class` Decoded)

JavaScript Prototypes and the Prototype Chain Explained (with `class` Decoded)

JavaScript is a prototype-based language wearing a class-based costume. The class keyword added in ES2015 is syntactic sugar over a much older mechanism: prototypal inheritance. If you only know class, parts of the language will surprise you. If you know prototypes, class becomes obvious and tools like Object.create, Object.getPrototypeOf, and Reflect stop feeling esoteric.

This is Part 12 of the JavaScript Fundamentals series. We unpack what a prototype actually is, how method lookup works, and how class desugars.

Every object has a prototype #

Every JavaScript object has an internal slot called [[Prototype]] that points to another object (or null). When you look up a property and the object does not have it, the engine walks up the prototype to look there. That's the prototype chain.

const animal = { eats: true };
const rabbit = { jumps: true };
Object.setPrototypeOf(rabbit, animal);

rabbit.jumps; // true — own property
rabbit.eats;  // true — inherited from animal

The lookup walks:

  1. Does rabbit have eats? No.
  2. What's rabbit's [[Prototype]]? It's animal.
  3. Does animal have eats? Yes. Return it.

If the chain ended with null and the property wasn't found, you get undefined.

Three ways to set the prototype #

// 1. Object literal — prototype is Object.prototype by default
const a = { x: 1 };
Object.getPrototypeOf(a); // Object.prototype

// 2. Object.create — pick the prototype explicitly
const b = Object.create(a);
Object.getPrototypeOf(b); // a

// 3. Constructor + new — prototype is constructor.prototype
function Cat() {}
const kitty = new Cat();
Object.getPrototypeOf(kitty); // Cat.prototype

Do NOT use __proto__ (the legacy access pattern). Use Object.getPrototypeOf / Object.setPrototypeOf or the constructor form.

Mutating an existing object's prototype with setPrototypeOf is technically allowed but very slow — V8 deoptimizes everything that touches that object. Pick the prototype at creation time and leave it.

The function/prototype duality #

Functions in JavaScript have a .prototype property — an object that becomes the [[Prototype]] of every instance created with new:

function Dog(name) { this.name = name; }
Dog.prototype.bark = function () { return `${this.name} says woof`; };

const rex = new Dog('Rex');
rex.bark(); // 'Rex says woof'

Object.getPrototypeOf(rex) === Dog.prototype; // true

What happens during new Dog('Rex') (from Part 5):

  1. New empty object created.
  2. New object's [[Prototype]] set to Dog.prototype.
  3. Dog runs with this bound to the new object.
  4. If Dog does not return an object, the new object is returned.

The bark method lives ONCE on Dog.prototype. Every instance shares it. This is why prototype-based inheritance is memory-efficient compared to copying methods onto each instance.

The prototype chain in action #

function Animal() {}
Animal.prototype.eat = function () { return 'eating'; };

function Dog(name) { this.name = name; }
Object.setPrototypeOf(Dog.prototype, Animal.prototype);  // Dog inherits from Animal
Dog.prototype.bark = function () { return 'woof'; };

const rex = new Dog('Rex');
rex.bark(); // 'woof'   — found on Dog.prototype
rex.eat();  // 'eating' — found on Animal.prototype (up the chain)
rex.toString(); // '[object Object]' — found on Object.prototype (top of chain)

The chain for rex:

rex ──▶ Dog.prototype ──▶ Animal.prototype ──▶ Object.prototype ──▶ null

Every non-null prototype chain eventually ends at Object.prototype, which holds .toString, .hasOwnProperty, etc.

class decoded #

What ES2015 class syntax actually compiles to:

class Animal {
  constructor(name) { this.name = name; }
  eat() { return `${this.name} is eating`; }
}

class Dog extends Animal {
  bark() { return `${this.name} woofs`; }
}

const rex = new Dog('Rex');
rex.bark(); // 'Rex woofs'
rex.eat();  // 'Rex is eating'

Is almost exactly equivalent to:

function Animal(name) { this.name = name; }
Animal.prototype.eat = function () { return `${this.name} is eating`; };

function Dog(name) { Animal.call(this, name); }
Object.setPrototypeOf(Dog.prototype, Animal.prototype);
Object.setPrototypeOf(Dog, Animal);  // ← static inheritance
Dog.prototype.bark = function () { return `${this.name} woofs`; };

The differences:

  • class bodies are always strict mode.
  • Class methods are non-enumerable by default (prototypal versions enumerate by default).
  • Classes cannot be called without new (functions can).
  • super works in class methods; you'd have to call Animal.prototype.eat.call(this) manually otherwise.
  • Class fields (name = 'rex') are set in the constructor, not on the prototype.

Other than these, the prototype chain is identical. Tooling, debugging, and inspector behavior all look the same.

super — the prototype-walking shortcut #

class Dog extends Animal {
  eat() {
    return `${super.eat()} (carefully)`; // calls Animal.prototype.eat with this = self
  }
}

super.method() finds method by walking up one level on the prototype chain from the current class. The lookup is HOME-relative (the class where the method was defined), not THIS-relative — which is what makes super work correctly through deep inheritance.

Static members live on the constructor, not the prototype #

class Counter {
  static count = 0;
  static inc() { return ++Counter.count; }
}

Counter.inc();        // 1 — calling on the constructor
new Counter().inc;    // undefined — not on instances

The class itself is an object too — it has its own [[Prototype]] pointing at its parent class. Dog.prototype holds instance methods; Dog itself holds static methods.

Reading the prototype chain #

Useful introspection:

Object.getPrototypeOf(obj);          // direct prototype
obj.constructor.name;                // 'Dog' — the constructor's name
obj instanceof Dog;                  // walks the chain checking each [[Prototype]]
obj.hasOwnProperty('name');          // true only if 'name' is on `obj` itself, not inherited
Object.hasOwn(obj, 'name');          // ES2022 replacement — safer

Prefer Object.hasOwn because some objects don't inherit from Object.prototype (e.g., Object.create(null)) and don't have hasOwnProperty:

const bareMap = Object.create(null);
bareMap.x = 1;
bareMap.hasOwnProperty('x'); // TypeError
Object.hasOwn(bareMap, 'x'); // true ✓

Property lookup vs property assignment #

Reading walks the chain. Writing does not. Assignment creates an own property on the receiver, shadowing any inherited one:

const parent = { count: 0 };
const child  = Object.create(parent);
child.count = 10;            // creates OWN property on child
parent.count;                // still 0
child.count;                 // 10 — own wins
Object.getPrototypeOf(child).count; // 0 — parent unchanged

This is how methods on the prototype call instance properties via this — they read fields through the prototype chain to this.

Prototype chain performance #

V8 builds hidden classes (sometimes called shapes) for objects based on their property layout. Objects with the same shape share an optimized JIT path. Things that hurt:

  • Adding properties in different orders to objects you treat the same → different shapes.
  • Deleting properties → shape transition, often deoptimized.
  • Mutating prototypes after instance creation → invalidates inline caches.
  • Very deep chains (10+ levels) → slower lookups (mitigated by inline caches, but extremes hurt).

Keep prototype chains shallow. Avoid Object.setPrototypeOf on existing objects. Initialize all properties in the constructor in the same order.

Common gotchas #

Gotcha 1: Forgetting super(...) in derived constructors.

class A {}
class B extends A {
  constructor() { /* no super() */ this.x = 1; } // ReferenceError
}

Derived classes must call super() before using this.

Gotcha 2: Arrow functions on class cannot use super.

class A {
  doThing = () => super.doThing(); // SyntaxError
}

Arrow fields don't have a method home → no super.

Gotcha 3: Modifying built-in prototypes pollutes everyone.

Array.prototype.sum = function () { return this.reduce((a, b) => a + b); }; // ✗ leaks into every array everywhere

Never modify built-ins outside polyfills.

Gotcha 4: instanceof works across the entire chain, not just direct parents.

rex instanceof Dog;     // true
rex instanceof Animal;  // true — walks the chain
rex instanceof Object;  // true

Recap #

  • Every object has a [[Prototype]] pointing to another object or null.
  • The prototype chain is the path the engine walks during property lookup.
  • class is syntactic sugar over constructor function + prototype.
  • Instance methods live on Constructor.prototype; static methods on the constructor itself.
  • Assignment creates own properties; it does NOT modify the prototype (shadowing).
  • Use Object.create, Object.getPrototypeOf, Object.hasOwn — avoid __proto__ and hasOwnProperty.
  • Don't mutate prototypes after creation, don't modify built-ins, don't go too deep.

Next up: Inheritance Patterns — classes vs composition vs mixins, when each one is the right tool, and why "favor composition over inheritance" is gospel for a reason.

Up next in JavaScript

More from this topic

View all JavaScript articles →

Enjoyed this article?

Get new JavaScript tutorials delivered. No spam — just code-first articles when they ship.

Leave a Comment

Your email stays private. Required fields are marked *

Leave a Comment

Your email stays private. Required fields are marked *