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
- The [[Prototype]] link & delegation
- Constructor functions vs Classes
- How method lookup works internally
- Composition vs Inheritance
- Factory functions & Mixins
- Performance of prototype chains
💡 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:
- Download Node.js to run JavaScript on your computer
- Use your browser's Developer Console (Press F12) to test code snippets
- Create a .html file with <script> tags and open it in your browser
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:
- • Player.prototype is where shared methods live
- • Instances don't have .attack — they access it through the prototype chain
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:
- ✔ Methods are non-enumerable (cleaner iteration)
- ✔ Strict mode is automatically enabled
- ✔ Cannot be called without new
- ✔ Static methods attach to the constructor, not the prototype
- ✔ Private fields (#hp) live on the instance, not the prototype
class Monster {
#hp = 100;
static type = "Beast";
roar() { console.log("ROAR"); }
}
console.log(Monster.type); // staticPrivate 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- Does player have attack?
- If not → check player.__proto__ (same as Player.prototype)
- If not → check the next prototype
- Continue until null
Deep chains slow execution:
class A {}
class B extends A {}
class C extends B {}
class D extends C {}
new D().constructor.nameTry 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 deepfunction 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()); // 3Powerful, 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); // 20You 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")); // trueShadowing 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(); // worksThis 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 — inaccessibleThis is extremely useful for:
- Game currencies
- Credit balances
- Internal counters
- Preventing tampering
🔥 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 performanceUsed in: Redux, compilers, renderers, interpreters, AI tokenizers, and JS engines internally.
🔥 Factory Functions vs Classes vs Prototypes
Factory Functions
- Best for configuration-heavy objects
- Perfect for game items, user settings, custom AI nodes
- Easy to test
- No "this" confusion
- Natural closures for private data
Classes
- Best for entities with identity
- Good for UI components
- Good for data models
Prototypes
- Best for lightweight shared behaviour
- Perfect for performance-critical code
- Ideal for objects created in massive numbers (e.g., 10,000 NPCs)
🎮 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, CanSleepThis is how real game studios structure their engines.
🔥 Professional-Level OOP Architecture
Best Practices for Scalable Apps:
- ✔ Prefer composition for features
- ✔ Use classes only for true entities
- ✔ Keep prototypes shallow (2-3 levels max)
- ✔ Use factory functions for configurable objects
- ✔ Avoid complex inheritance hierarchies
- ✔ Avoid class methods that mutate global/shared state
- ✔ Keep prototypes clean and predictable
- ✔ Never shadow inherited properties accidentally
- ✔ Use static methods for utilities
- ✔ Use closures for private state when needed
⚡ Performance Tips
- • Keep prototype chains shallow
- • Use composition over deep inheritance
- • Define methods on prototypes, not in constructors
- • Use Object.create(null) for dictionaries
- • Freeze prototypes after definition
- • Modify prototypes after objects are created
- • Create deep inheritance hierarchies (>3 levels)
- • Define methods inside constructors
- • Modify built-in prototypes (except polyfills)
- • Accidentally shadow prototype properties
🎯 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
- ✓ JavaScript uses delegation, not classical inheritance
- ✓ ES6 classes are prototype sugar with extra features
- ✓ Avoid putting functions inside constructors
- ✓ Composition > inheritance for modern architecture
- ✓ Dynamic prototypes can be useful but risky
- ✓ Deep prototype chains hurt performance
- ✓ Use objects + capabilities for flexible behaviour
- ✓ Keep data on instances, behaviour on prototypes
- ✓ Watch for prototype pollution in user inputs
- ✓ Use closures for truly private data
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
- Previous: JavaScript Modules: ES6 Imports, Exports, Bundling
- Next: Functional Programming in JavaScript — Write composable, predictable code with pure functions and immutability
- Quick reference: JavaScript cheat sheet