Skip to main content
JavaScript · lesson 13 of 24
JavaScript intermediate Lesson 13 of 24

Prototypes and Prototype Chain in JavaScript

Understand how JavaScript's prototype chain works, prototype-based inheritance, and how ES6 classes relate to prototypes under the hood.

By SkillByExample · Updated

Every JavaScript object has a hidden link to another object, its prototype. When a property isn’t found on the object, the lookup continues along that link. Classes, instanceof, inherited methods — all of it is this one mechanism. Every example is a complete file run with node.

The Prototype Chain

Object.create(proto) makes a new object whose prototype is proto.

const animal = {
  breathe() { return `${this.name} is breathing`; },
};

const dog = Object.create(animal);
dog.name = "Rex";

console.log(dog.breathe());
console.log(Object.hasOwn(dog, "name"), Object.hasOwn(dog, "breathe"));
console.log(Object.getPrototypeOf(dog) === animal);
console.log(Object.getPrototypeOf(animal) === Object.prototype);
console.log(Object.getPrototypeOf(Object.prototype));
console.log(dog);

animal.sleep = function () { return `${this.name} is asleep`; };
console.log(dog.sleep());
$ node chain.js
Rex is breathing
true false
true
true
null
{ name: 'Rex' }
Rex is asleep

breathe isn’t on dog — it’s found one step up, on animal, but this is still dog, so it prints “Rex”. The chain is dog → animal → Object.prototype → null. console.log(dog) only shows own properties. And because the link is live, a method added to animal after dog was created is immediately available on dog — nothing was copied.

Shadowing

Assigning a property always writes to the object itself, never to its prototype. The own property then hides (“shadows”) the inherited one.

const defaults = { theme: "light", tags: [] };
const userA = Object.create(defaults);
const userB = Object.create(defaults);

userA.theme = "dark";
console.log(userA.theme, userB.theme, defaults.theme);

userA.tags.push("admin");
console.log(userB.tags, defaults.tags);

delete userA.theme;
console.log(userA.theme);
$ node shadow.js
dark light light
[ 'admin' ] [ 'admin' ]
light

userA.theme = "dark" created an own property on userA; userB still sees the prototype’s "light". But userA.tags.push(...) doesn’t assign tags — it reads the inherited array and mutates it, so every object sharing the prototype sees "admin". Deleting the own theme uncovers the inherited value again. Keep mutable state (arrays, objects) off prototypes.

prototype vs proto

A function’s prototype property is the object that new installs as the prototype of the instances it creates. The prototype link on an object is read with Object.getPrototypeOf (the legacy accessor is __proto__).

function Person(name) {
  this.name = name;
}
Person.prototype.greet = function () { return `Hi, I'm ${this.name}`; };

const alice = new Person("Alice");
console.log(alice.greet());
console.log(Object.getPrototypeOf(alice) === Person.prototype);
console.log(alice.__proto__ === Person.prototype);
console.log(alice.prototype, typeof Person.prototype);
console.log(Person.prototype.constructor === Person, alice.constructor.name);
$ node proto.js
Hi, I'm Alice
true
true
undefined object
true Person

Instances don’t have a prototype property (undefined) — only functions do. alice.constructor isn’t on alice either: it’s inherited from Person.prototype, which points back at Person. Use Object.getPrototypeOf / Object.create / Object.setPrototypeOf rather than __proto__.

Constructor-Function Inheritance

Before class, inheritance meant wiring prototypes by hand. You’ll still meet this in older code.

function Animal(name) { this.name = name; }
Animal.prototype.speak = function () { return `${this.name} makes a sound`; };

function Dog(name, breed) {
  Animal.call(this, name);
  this.breed = breed;
}
Dog.prototype = Object.create(Animal.prototype);
Dog.prototype.bark = function () { return `${this.name} barks!`; };

const rex = new Dog("Rex", "Labrador");
console.log(rex.speak(), "|", rex.bark());
console.log(rex instanceof Dog, rex instanceof Animal);
console.log("constructor:", rex.constructor.name);

Dog.prototype.constructor = Dog;
console.log("constructor after fix:", rex.constructor.name);
$ node ctor.js
Rex makes a sound | Rex barks!
true true
constructor: Animal
constructor after fix: Dog

Animal.call(this, name) runs the parent constructor on the new object (what super(name) does); Object.create(Animal.prototype) links Dog.prototype to Animal.prototype (what extends does). Replacing Dog.prototype wholesale also threw away its constructor property, so rex.constructor found Animal’s one up the chain — which is why old code always restores it by hand.

Classes Are the Same Mechanism

class syntax builds exactly the structure above.

class Animal {
  constructor(name) { this.name = name; }
  speak() { return `${this.name} makes a sound`; }
}
class Dog extends Animal {
  bark() { return `${this.name} barks!`; }
}

const rex = new Dog("Rex");
console.log(typeof Dog);
console.log(Object.getPrototypeOf(rex) === Dog.prototype);
console.log(Object.getPrototypeOf(Dog.prototype) === Animal.prototype);
console.log(Object.getOwnPropertyNames(Dog.prototype));
console.log(Object.getPrototypeOf(Dog) === Animal);

Animal.prototype.speak = function () { return `${this.name} was monkey-patched`; };
console.log(rex.speak());
$ node classes.js
function
true
true
[ 'constructor', 'bark' ]
true
Rex was monkey-patched

Methods land on Dog.prototype, which links to Animal.prototype. One extra link classes add: Dog itself inherits from Animal, so static methods are inherited too. And because class methods are ordinary prototype properties, replacing one at runtime changes every existing instance — which is how polyfills work, and why patching built-in prototypes is dangerous.

Prototype Methods vs Per-Instance Functions

A method on the prototype exists once. A function assigned in the constructor is created again for every instance.

class Counter {
  constructor() {
    this.count = 0;
    this.reset = function () { this.count = 0; };
  }
  increment() { this.count++; }
}

const a = new Counter();
const b = new Counter();
console.log(a.increment === b.increment, a.reset === b.reset);

const mb = () => process.memoryUsage().heapUsed / 1e6;
globalThis.gc();
const before = mb();
const many = Array.from({ length: 200_000 }, () => new Counter());
globalThis.gc();
console.log(`200k counters: ${(mb() - before).toFixed(1)} MB`);

class Lean { constructor() { this.count = 0; } reset() { this.count = 0; } increment() { this.count++; } }
many.length = 0;
globalThis.gc();
const before2 = mb();
const lean = Array.from({ length: 200_000 }, () => new Lean());
globalThis.gc();
console.log(`200k lean:     ${(mb() - before2).toFixed(1)} MB`);
$ node --expose-gc shared.js
true false
200k counters: 22.4 MB
200k lean:     8.0 MB

increment is one shared function; reset is 200,000 separate function objects, each also making the instance bigger. Measured here, that roughly doubles or triples the memory for the same data. Define methods in the class body (or on the prototype) unless each instance genuinely needs its own closure.

Prototype Pollution

If attacker-controlled keys reach a recursive merge, the key __proto__ walks from a plain object up to Object.prototype — and whatever is written there appears on every object in the program.

function deepMerge(target, source) {
  for (const key in source) {
    if (typeof source[key] === "object" && source[key] !== null) {
      target[key] ??= {};
      deepMerge(target[key], source[key]);
    } else {
      target[key] = source[key];
    }
  }
  return target;
}

const body = '{"theme": "dark", "__proto__": {"isAdmin": true}}';
const payload = JSON.parse(body);
console.log("own keys of payload:", Object.keys(payload));

const settings = deepMerge({}, payload);
const unrelated = {};
console.log("settings.theme:", settings.theme);
console.log("unrelated.isAdmin:", unrelated.isAdmin);
console.log("Object.hasOwn guard would pass:", Object.hasOwn(payload, "__proto__"));
$ node pollution.js
own keys of payload: [ 'theme', '__proto__' ]
settings.theme: dark
unrelated.isAdmin: true
Object.hasOwn guard would pass: true

JSON.parse creates __proto__ as an ordinary own key — so an Object.hasOwn check doesn’t stop it. The merge then evaluates target["__proto__"], which is Object.prototype, and recurses into it, setting isAdmin there. unrelated was never touched, yet it’s now “admin”: any if (user.isAdmin) check in the app is compromised. Real libraries (lodash, jQuery and others) have shipped this bug.

The fix is to refuse the dangerous keys, or to merge into objects that have no prototype:

const FORBIDDEN = new Set(["__proto__", "constructor", "prototype"]);

function safeMerge(target, source) {
  for (const key of Object.keys(source)) {
    if (FORBIDDEN.has(key)) continue;
    const value = source[key];
    if (typeof value === "object" && value !== null) {
      target[key] = safeMerge(Object.hasOwn(target, key) ? target[key] : {}, value);
    } else {
      target[key] = value;
    }
  }
  return target;
}

const payload = JSON.parse('{"theme": "dark", "__proto__": {"isAdmin": true}}');
console.log(safeMerge({}, payload), {}.isAdmin);

const dict = Object.create(null);
dict["__proto__"] = "just a key";
console.log(Object.keys(dict), dict.toString, Object.getPrototypeOf(dict));
$ node safe.js
{ theme: 'dark' } undefined
[ '__proto__' ] undefined null

safeMerge skips the three keys that can reach a prototype and only descends into objects it owns. Object.create(null) makes an object with no prototype, so __proto__ there is just a normal key and there’s nothing to pollute (the price: no inherited methods like toString). A Map is the other safe choice for keys that come from users. Node also offers --disable-proto=delete to remove the __proto__ accessor entirely.

Key Takeaways

  • Property lookup walks the prototype chain until it hits null; the chain is live.
  • Assignment creates or updates an own property — but mutating an inherited object changes it for everyone.
  • Functions have .prototype; objects have a prototype link (Object.getPrototypeOf).
  • class/extends builds the same chain you’d wire by hand.
  • Put methods on the prototype, not in the constructor.
  • Recursive merges of untrusted data must skip __proto__, constructor and prototype.

Practice

1. Print the full prototype chain of an array.
let obj = [1, 2, 3];
const chain = [];
while (obj !== null) {
  obj = Object.getPrototypeOf(obj);
  chain.push(obj === null ? "null" : obj.constructor.name + ".prototype");
}
console.log(chain.join(" -> "));
console.log(Object.hasOwn(Array.prototype, "map"), Object.hasOwn([1], "map"));
$ node p1.js
Array.prototype -> Object.prototype -> null
true false

map lives once on Array.prototype and every array finds it there.

2. Use Object.create with a second argument to define a read-only id.
"use strict";
const base = { describe() { return `item #${this.id}`; } };
const item = Object.create(base, { id: { value: 7, enumerable: true } });
console.log(item.describe(), Object.keys(item));
try { item.id = 8; } catch (err) { console.log(err.message); }
$ node p2.js
item #7 [ 'id' ]
Cannot assign to read only property 'id' of object '#<Object>'

Properties defined through a descriptor default to non-writable, so strict mode throws on assignment.

3. Show that instanceof just checks the prototype chain by rewiring an object's prototype.
class Cat {}
class Robot {}
const thing = new Cat();
console.log(thing instanceof Cat, thing instanceof Robot);
Object.setPrototypeOf(thing, Robot.prototype);
console.log(thing instanceof Cat, thing instanceof Robot);
$ node p3.js
true false
false true

instanceof asks “is Robot.prototype anywhere in this object’s chain?” — nothing about how the object was constructed. (Changing a live object’s prototype is slow in V8; this is for understanding, not for production.)

4. Count word frequencies in an object without the words constructor or toString breaking it.
const text = "constructor toString constructor valueOf";

const naive = {};
for (const w of text.split(" ")) naive[w] = (naive[w] || 0) + 1;
console.log(naive);

const safe = Object.create(null);
for (const w of text.split(" ")) safe[w] = (safe[w] || 0) + 1;
console.log(safe);
$ node p4.js
{
  constructor: 'function Object() { [native code] }11',
  toString: 'function toString() { [native code] }1',
  valueOf: 'function valueOf() { [native code] }1'
}
[Object: null prototype] { constructor: 2, toString: 1, valueOf: 1 }

On a normal object, naive["constructor"] starts as the inherited Object function, so + 1 produces a string of function source instead of a count. A prototype-less object starts truly empty.

Frequently Asked Questions

What is the difference between __proto__ and prototype?
__proto__ is a property on every object instance that points to its prototype (the object it inherits from). prototype is a property on constructor functions — it becomes the __proto__ of any object created with that constructor. Use Object.getPrototypeOf() instead of __proto__ in production code.
How is prototype-based inheritance different from class-based inheritance?
In classical inheritance, classes are blueprints copied to instances. In prototype-based inheritance, objects delegate property lookups to a shared prototype object at runtime. JavaScript classes (ES6+) are syntactic sugar over the same prototype mechanism.
What is prototype pollution and why is it dangerous?
Prototype pollution is when an attacker or bug causes Object.prototype to be mutated. Because every plain object inherits from Object.prototype, adding a property there affects all objects in the application, leading to unexpected behavior or security vulnerabilities.