Prototype Chain & OOP

Reviewed & published by Brayan K

The prototype chain is the series of linked objects JavaScript walks up when looking for a property or method, inheriting from each object's prototype until it finds the property or reaches the end of the chain.

Part of the free JavaScript course at LearnCodingFast — hands-on lessons with examples you run in your browser, plus practice exercises and a quick quiz.

What You'll Learn in This Lesson

💡 Running Code Locally: While this online editor runs real JavaScript, some advanced examples (like fetch to external APIs) may have limitations. For the best experience:

The prototype system is the hidden engine behind JavaScript's object model. Unlike classical languages such as Java, C#, or C++, JavaScript doesn't have traditional "classes" under the hood — even ES6 classes are syntactic sugar layered on top of its prototype chain. Understanding this chain is essential if you want to build high-performance data models, reusable components, advanced game logic, or frameworks.

This is exactly where senior-level JavaScript engineers separate from beginners: they don't treat OOP as a set of keywords, but as mastery of how memory, delegation, inheritance, method lookup, and object links actually work in the engine.

🔥 The Real Backbone: [[Prototype]] and Delegation

Every JavaScript object carries an internal link to another object called its [[Prototype]]. When you access a property on an object and it doesn't exist, the engine looks up the chain until it finds the property or hits null. This is called delegation, and it creates the illusion of inheritance.

const parent = {
  greet() { console.log("Hello from parent"); }
};

const child = Object.create(parent);
child.name = "Brayan";

child.greet();  // delegated to parent.greet()

Worked example: the whole chain, printed

Everything else in this lesson rests on one picture: data lives on the instance, behaviour lives on the prototype, and lookups walk upwards until they find a match or hit null. This program prints that picture instead of describing it. Run it, then read the comments against the output line by line.

// ---- Data on the instance, behaviour on the prototype -----------------
function Player(name, level) {   // a constructor function: 'new' calls this
  this.name = name;              // OWN data - one copy per player
  this.level = level;
}

Player.prototype.attack = function () {   // SHARED behaviour - one copy, ever
  return this.name + ' attacks at level ' + this.level;
};

const p1 = new Player('Boopie', 12);
const p2 = new Player('Ada', 3);

console.log(p1.attack());
console.log(p2.attack());

// Both players reach the SAME function object. That is the memory saving.
console.log('same function object? ' + (p1.attack === p2.attack));

// Where does each property actually live?
console.log('p1 owns "name"?   ' + p1.hasOwnProperty('name'));    // true
console.log('p1 owns "attack"? ' + p1.hasOwnProperty('attack'));  // false: inherited

// ---- Walk the chain by hand, exactly as the engine does --------------
function walk(obj) {
  const steps = [];
  let current = Object.getPrototypeOf(obj);   // step off the object itself
  while (current !== null) {                  // ...until the chain ends
    steps.push(current.constructor.name + '.prototype');
    current = Object.getPrototypeOf(current);
  }
  return steps.join(' -> ') + ' -> null';
}
console.log('lookup path: p1 -> ' + walk(p1));

// ---- Shadowing: an own property HIDES the inherited one --------------
p1.attack = function () { return this.name + ' uses a special move!'; };
console.log(p1.attack());        // the own copy wins - found first
console.log(p2.attack());        // p2 is untouched: the prototype never changed
delete p1.attack;                // remove the shadow...
console.log(p1.attack());        // ...and the prototype version is visible again

// ✅ Expected output:
// Boopie attacks at level 12
// Ada attacks at level 3
// same function object? true
// p1 owns "name"?   true
// p1 owns "attack"? false
// lookup path: p1 -> Player.prototype -> Object.prototype -> null
// Boopie uses a special move!
// Ada attacks at level 3
// Boopie attacks at level 12
//
// The last three lines are the whole idea in miniature: assigning to
// p1.attack did not change Player.prototype, it just put something in front
// of it. Delete the own property and the inherited one is still there,
// untouched. Nothing was ever copied.

🎯 Your turn: put the method in the right place

Two blanks, one idea: per-monster values belong on this, and the method they all share belongs on Monster.prototype. The last three lines of output are your proof that you got it right.

// 🎯 YOUR TURN — fill in the blanks marked with ___

function Monster(name, hp) {
  // 1) Per-monster DATA goes on the instance
  this.name = name;
  ___;                       // 👉 replace ___ with  this.hp = hp
}

// 2) The SHARED method goes on the prototype, NOT inside the constructor
Monster.___.roar = function () {          // 👉 replace ___ with  prototype
  return this.name + ' roars! (' + this.hp + ' hp left)';
};

const a = new Monster('Grendel', 80);
const b = new Monster('Fenrir', 120);

console.log(a.roar());
console.log(b.roar());
console.log('one shared method? ' + (a.roar === b.roar));
console.log('a owns "hp"?   ' + a.hasOwnProperty('hp'));
console.log('a owns "roar"? ' + a.hasOwnProperty('roar'));

// ✅ Expected output once both blanks are filled:
// Grendel roars! (80 hp left)
// Fenrir roars! (120 hp left)
// one shared method? true
// a owns "hp"?   true
// a owns "roar"? false
//
// If you see 'one shared method? false' you defined roar() inside the
// constructor: every monster then gets its own private copy of the same
// function. With ten thousand monsters that is ten thousand functions.
// ❌ TypeError: Cannot set properties of undefined means blank 2 is not
// 'prototype'.

Unlike classical inheritance where objects copy methods, JavaScript objects share them via delegation. This reduces memory usage and increases performance — which is why frameworks like React, Vue internals, Node streams, game engines, and custom interpreters often rely heavily on prototype structures.

🔥 Constructor Functions: The Original "Class" Pattern

Before ES6 introduced the class keyword, constructor functions were the core of JS OOP. They still work exactly the same under the hood:

A common beginner mistake is adding methods inside the constructor:

function Player(name) {
  this.name = name;
  this.attack = () => {} // ❌ BAD — creates a new function PER INSTANCE
}

// This destroys the main benefit of prototypes: shared behaviour and memory efficiency

🔥 ES6 Classes Are Just Syntax Sugar

class Player {
  constructor(name, level) {
    this.name = name;
    this.level = level;
  }

  attack() { /* shared */ }
}

This is identical to constructor+prototype under the hood. But ES6 classes introduce several advanced behaviours:

class Monster {
  #hp = 100;
  static type = "Beast";

  roar() { console.log("ROAR"); }
}

console.log(Monster.type); // static

Private fields are not part of the prototype chain. They live directly inside each instance, making them secure and non-exposed.

🔥 Deep Prototype Chain Resolution & Performance

function Player(name) { this.name = name; }
Player.prototype.attack = function () {
  console.log(this.name + ' attacks');
};
const player = new Player('Boopie');

// The line everyone writes:
player.attack();

// What the engine had to do to resolve that one dot:
console.log('does player own "attack"?      ' + player.hasOwnProperty('attack'));
console.log('does Player.prototype own it?  ' + Player.prototype.hasOwnProperty('attack'));

// ✅ Expected output:
// Boopie attacks
// does player own "attack"?      false
// does Player.prototype own it?  true

Deep chains slow execution:

class A {}
class B extends A {}
class C extends B {}
class D extends C {}

new D().constructor.name

Try not to exceed 2–3 prototype levels for performance-critical systems such as games, UI frameworks, custom renderers, physics engines, audio engines, animation systems, and interpreters.

🔥 Composition Over Inheritance (Modern Best Practice)

In advanced engineering, the best pattern is "objects with capabilities," not long class trees.

class Animal {}
class Bird extends Animal {}
class Eagle extends Bird {}
class GoldenEagle extends Eagle {} // too deep
function canFly(obj) {
  obj.fly = () => console.log("Flying...");
}

function canHunt(obj) {
  obj.hunt = () => console.log("Hunting...");
}

const eagle = {};
canFly(eagle);
canHunt(eagle);

Composed objects scale 10× better in fast-growing systems.

🔥 Prototype Manipulation & Live Extension

You can dynamically add behaviour to prototypes — used heavily in polyfills & frameworks:

Array.prototype.last = function() {
  return this[this.length - 1];
};

console.log([1,2,3].last()); // 3

Powerful, but dangerous. If you add something wrong, every array in your entire project breaks. Modern best practice: Never modify built-ins except for polyfills.

🔥 Advanced Pattern: Linked Prototypes for Shared Config

Great for games, AI models, or user-settings architecture:

const baseStats = { hp: 100, stamina: 50 };
const warriorStats = Object.create(baseStats);
warriorStats.strength = 20;

console.log(warriorStats.hp); // inherited
console.log(warriorStats.strength); // 20

You only override what's different. This reduces memory footprint for thousands of game entities.

🔥 Prototype Shadowing & Hidden Mistakes

One of the most confusing problems is property shadowing — when a child object defines a property with the same name as its prototype.

const base = { score: 100 };

const player = Object.create(base);
player.score = 300;  // shadows base.score

console.log(player.score);   // 300
console.log(base.score);     // 100

// Debugging shadowing
console.log(player.hasOwnProperty("score")); // true

Shadowing can break logic in shared stats, shared configuration, shared caches, and inheritance-based method overrides.

🔥 Advanced OOP Pattern: Mixins

Mixins let you add capabilities without deep inheritance. Modern, safe implementation:

const CanRun = Base => class extends Base {
  run() { console.log("Running at speed 10"); }
};

const CanJump = Base => class extends Base {
  jump() { console.log("Jump!"); }
};

class Character {}
class Player extends CanJump(CanRun(Character)) {}

const p = new Player();
p.run(); // works
p.jump(); // works

This works perfectly for game abilities, AI behaviours, utilities, and UI widgets.

🔥 Private Data Using Closures

Closures give stronger security than ES6 #private fields:

function createWallet() {
  let balance = 0; // truly private

  return {
    deposit(amount) { balance += amount; },
    getBalance() { return balance; }
  };
}

const wallet = createWallet();
wallet.deposit(100);
console.log(wallet.getBalance()); // 100
console.log(wallet.balance); // undefined — inaccessible

This is extremely useful for:

🔥 Prototype Pollution — Security Risk

If you're building large systems, watch for prototype pollution:

// Untrusted JSON, e.g. straight from a request body
const evil = JSON.parse('{ "__proto__": { "isAdmin": true } }');

// Step 1: parsing on its own is SAFE. JSON.parse stores "__proto__" as an
// ordinary own key; it does not run the setter that would change the chain.
console.log('after JSON.parse:            {}.isAdmin = ' + {}.isAdmin);
console.log('is __proto__ an own key?     ' +
  Object.prototype.hasOwnProperty.call(evil, '__proto__'));

// Step 2: the danger is a naive deep merge. target[key] = ... DOES run the
// setter, and "__proto__" is the one key name that rewrites the prototype.
function unsafeMerge(target, source) {
  for (const key in source) {
    if (source[key] && typeof source[key] === 'object') {
      if (!target[key]) target[key] = {};
      unsafeMerge(target[key], source[key]);
    } else {
      target[key] = source[key];
    }
  }
  return target;
}

unsafeMerge({}, evil);          // merging into an empty object is enough
console.log('after the naive merge:       {}.isAdmin = ' + {}.isAdmin);
console.log('every object in the program now claims admin rights.');

// Step 3: the fix - refuse the three dangerous key names
function safeMerge(target, source) {
  for (const key in source) {
    if (key === '__proto__' || key === 'constructor' || key === 'prototype') {
      console.log('blocked dangerous key: ' + key);
      continue;
    }
    target[key] = source[key];
  }
  return target;
}

delete Object.prototype.isAdmin;   // undo the damage before continuing
console.log('after cleanup:               {}.isAdmin = ' + {}.isAdmin);
safeMerge({}, evil);
console.log('after the safe merge:        {}.isAdmin = ' + {}.isAdmin);

// ✅ Expected output:
// after JSON.parse:            {}.isAdmin = undefined
// is __proto__ an own key?     true
// after the naive merge:       {}.isAdmin = true
// every object in the program now claims admin rights.
// after cleanup:               {}.isAdmin = undefined
// blocked dangerous key: __proto__
// after the safe merge:        {}.isAdmin = undefined
//
// The first line is the part people get wrong: JSON.parse by itself is not
// the vulnerability. The vulnerability is any code that later ASSIGNS a
// key it did not choose — deep merges, query-string parsers, config
// loaders. Safer still: build the target with Object.create(null), which
// has no prototype to poison in the first place.

This vulnerability can break your entire system.

🔥 Detecting Prototype Issues & Debugging

function Player(name) { this.name = name; }
Player.prototype.attack = function () {};
const player = new Player('Boopie');

console.log('prototype of player is Player.prototype? ' +
  (Object.getPrototypeOf(player) === Player.prototype));
console.log('player owns "attack"? ' + player.hasOwnProperty('attack'));

// Walk any object's chain and name each link
function logChain(obj, label) {
  console.log(label + ':');
  let current = obj;
  let step = 0;
  while (current !== null) {
    const name = step === 0
      ? 'the object itself'
      : (current.constructor ? current.constructor.name + '.prototype' : '(null-prototype object)');
    console.log('  step ' + step + ': ' + name);
    current = Object.getPrototypeOf(current);   // one link up
    step++;
  }
  console.log('  step ' + step + ': null  (end of the chain)');
}

logChain(player, 'player');
logChain(new Date(), 'new Date()');
logChain([1, 2, 3], '[1, 2, 3]');

// ✅ Expected output:
// prototype of player is Player.prototype? true
// player owns "attack"? false
// player:
//   step 0: the object itself
//   step 1: Player.prototype
//   step 2: Object.prototype
//   step 3: null  (end of the chain)
// new Date():
//   step 0: the object itself
//   step 1: Date.prototype
//   step 2: Object.prototype
//   step 3: null  (end of the chain)
// [1, 2, 3]:
//   step 0: the object itself
//   step 1: Array.prototype
//   step 2: Object.prototype
//   step 3: null  (end of the chain)
//
// Every built-in works the same way. An array's methods are not on the
// array — they are one link up, on Array.prototype, shared by every array
// in the program.

🔥 Understanding Object.create(null)

When building engines or caches, use:

const map = Object.create(null);
// No prototype
// No collisions with .toString, .constructor, .hasOwnProperty
// Pure dictionary performance

Used in: Redux, compilers, renderers, interpreters, AI tokenizers, and JS engines internally.

🔥 Factory Functions vs Classes vs Prototypes

Factory Functions

Classes

Prototypes

🎮 Real Example: Designing a Game Entity System

Weak design (beginners do this):

class Worker {
  constructor() {
    this.energy = 100;
    this.move = function() { ... };  // BAD — duplicate method per instance
  }
}

Better design using prototypes:

function Worker() {
  this.energy = 100;
}

Worker.prototype.move = function() {
  // shared behaviour
};

Best design using composition:

const Movable = Base => class extends Base {
  move() { /* movement logic */ }
};

class Worker extends Movable(Object) {
  constructor() {
    super();
    this.energy = 100;
  }
}

// Now you can mix ANY behaviour:
// CanCarry, CanBuild, CanTrade, CanSleep

This is how real game studios structure their engines.

🔥 Professional-Level OOP Architecture

Best Practices for Scalable Apps:

⚡ Performance Tips

🎯 Mini-Challenge: Shared Stats for 10,000 Units

This is the pattern from the "Linked Prototypes" section, done properly. A strategy game spawns thousands of units. Almost all of them use the same base stats; a handful differ. Copying the full stat block into every unit wastes memory and makes a global balance change impossible. Delegation solves both at once.

Write the two functions from the outline. The driving code and the expected output are given, including the payoff at the end: change one number on baseStats and every unit that never overrode it changes with it.

const baseStats = { hp: 100, stamina: 50, speed: 10 };

// 🎯 MINI-CHALLENGE
// 1. makeUnit(name, overrides) should:
//      - create an object whose prototype IS baseStats  (Object.create)
//      - set .name on it
//      - copy every key of 'overrides' onto it  (Object.assign works)
//      - return it
function makeUnit(name, overrides) {
  // your code here
}

// 2. describe(unit) should return one line, exactly like:
//      'Scout: hp=100 stamina=50 speed=25 (own props: 2)'
//    Use Object.keys(unit).length for the own-property count — inherited
//    stats are NOT own properties, which is the whole point.
function describe(unit) {
  // your code here
}

const scout = makeUnit('Scout', { speed: 25 });
const tank  = makeUnit('Tank',  { hp: 300, speed: 4 });
const grunt = makeUnit('Grunt', {});

console.log(describe(scout));
console.log(describe(tank));
console.log(describe(grunt));
console.log('all three share one stats object? ' +
  (Object.getPrototypeOf(scout) === baseStats && Object.getPrototypeOf(grunt) === baseStats));

baseStats.hp = 150;                     // one balance change, applied globally
console.log('after buffing baseStats.hp to 150:');
console.log(describe(scout));
console.log(describe(tank));

// ✅ Expected output when your version is right:
// Scout: hp=100 stamina=50 speed=25 (own props: 2)
// Tank: hp=300 stamina=50 speed=4 (own props: 3)
// Grunt: hp=100 stamina=50 speed=10 (own props: 1)
// all three share one stats object? true
// after buffing baseStats.hp to 150:
// Scout: hp=150 stamina=50 speed=25 (own props: 2)
// Tank: hp=300 stamina=50 speed=4 (own props: 3)
//
// Read the last two lines carefully. The Scout picked up the buff because
// it never owned an 'hp' - it was reading through to baseStats all along.
// The Tank kept 300, because its own property shadows the base. That is
// delegation earning its keep: shared by default, overridden on purpose.
// Grunt showing 'own props: 4' means you copied the stats instead of
// inheriting them - check that Object.create(baseStats) is the starting
// point, not { ...baseStats }.

🎯 Key Takeaways

To master JavaScript's OOP at a senior-engineer level, you must understand not just what prototypes are, but how the engine uses them to resolve property lookups, share behavior, and structure memory internally. The prototype chain is at the core of every object you've ever created, from arrays and promises to DOM nodes, async functions, classes, and even built-in browser APIs. These patterns make code easier to maintain and help build scalable, high-performance applications.

Practice quiz

What is the internal link every object uses to look up missing properties called?

  • [[Parent]]
  • [[Class]]
  • [[Prototype]]
  • [[Scope]]

Answer: [[Prototype]]. Each object has an internal [[Prototype]] link; the engine walks it to resolve properties via delegation.

In the lesson, ES6 classes are described as...

  • Syntactic sugar over the prototype chain
  • A completely new object model
  • Faster than prototypes always
  • Unable to use inheritance

Answer: Syntactic sugar over the prototype chain. ES6 classes are syntax sugar layered on top of JavaScript's prototype system.

Why is adding a method inside the constructor (this.attack = () => {}) called a mistake?

  • It is a syntax error
  • Constructors cannot hold functions
  • It runs too slowly to call
  • It creates a new function per instance, wasting memory

Answer: It creates a new function per instance, wasting memory. Defining methods in the constructor creates a separate copy per instance instead of sharing one on the prototype.

For const child = Object.create(parent), where parent.greet exists, calling child.greet()...

  • Throws because child has no greet
  • Is delegated up to parent.greet()
  • Returns undefined
  • Copies greet onto child

Answer: Is delegated up to parent.greet(). child delegates the lookup up the prototype chain to parent.greet().

After player.score = 300 shadows base.score = 100 (player created via Object.create(base)), what is base.score?

  • 100
  • 300
  • 0
  • undefined

Answer: 100. Assigning to player.score shadows but does not change base.score, which stays 100.

What does new D().constructor.name return for class A{}, B extends A, C extends B, D extends C?

  • "A"
  • "Object"
  • "D"
  • "C"

Answer: "D". The constructor name of a D instance is "D".

The modern best practice the lesson favors over deep inheritance is...

  • Global variables
  • Composition (objects with capabilities)
  • Copying all methods
  • Avoiding functions

Answer: Composition (objects with capabilities). Composition (mixing in capabilities) scales better than long inheritance chains.

For performance-critical systems, the lesson says to keep prototype chains...

  • As deep as possible
  • Exactly 10 levels
  • Empty
  • Shallow, about 2-3 levels max

Answer: Shallow, about 2-3 levels max. Deep chains slow lookups; keep them to 2-3 levels for hot code.

What does Object.create(null) produce?

  • A frozen array
  • An object with no prototype (a pure dictionary)
  • A copy of Object.prototype
  • null

Answer: An object with no prototype (a pure dictionary). Object.create(null) makes an object with no prototype, avoiding inherited keys like toString.

Prototype pollution is a security risk that arises when you...

  • Use too many classes
  • Freeze a prototype
  • Let untrusted input write to __proto__
  • Call Object.create

Answer: Let untrusted input write to __proto__. Untrusted input reaching __proto__ can pollute the shared prototype and affect every object.

Continue this course