Learn JS Series (#34) - The Prototype: JavaScript's Actual Inheritance Mechanism
Learn JS Series (#34) - The Prototype: JavaScript's Actual Inheritance Mechanism
What will I learn
- You will learn what a prototype is: a hidden link from one object to another;
- how JavaScript uses that link to share properties and methods between objects;
- the difference between an object's own properties and inherited ones;
- how to read and set an object's prototype (and why the modern ways are preferred);
- why prototypes are the foundation under everything, including the
classsyntax you will meet soon.
Requirements
- A working modern computer running macOS, Windows or Ubuntu;
- An installed Node.js (20+) distribution, or just a modern browser console;
- Episodes 1-33 read, especially objects and property descriptors.
Difficulty
- Intermediate
Curriculum (of the Learn JS Series):
- Learn JS Series (#1) - What Is JavaScript, Why It Runs Everywhere, and How to Run It
- Learn JS Series (#2) - Variables and Bindings
- Learn JS Series (#3) - The Primitive Types: number, string, boolean, null, undefined, symbol, bigint
- Learn JS Series (#4) - Operators and Expressions: Arithmetic, Comparison, Logical, and Short-Circuiting
- Learn JS Series (#5) - Strings: Template Literals, Unicode, and the Methods You Actually Use
- Learn JS Series (#6) - Numbers: IEEE 754, Why 0.1 + 0.2 Is Not 0.3, and How to Cope
- Learn JS Series (#7) - Control Flow: if/else, switch, and the Ternary Expression
- Learn JS Series (#8) - Loops: for, while, for...of, for...in, and When to Use Which
- Learn JS Series (#9) - Functions: Declarations, Parameters, Return Values, and Hoisting
- Learn JS Series (#10) - Scope and the Temporal Dead Zone: How JavaScript Finds Your Variables
- Learn JS Series (#11) - Arrays: The Workhorse Data Structure and Its Core Methods
- Learn JS Series (#12) - Objects: Key-Value Data, Dot vs Bracket Access, and Nesting
- Learn JS Series (#13) - Truthiness, Equality, and Coercion: == vs === Done Properly
- Learn JS Series (#14) - Mini Project: A Command-Line Tip Calculator
- Learn JS Series (#15) - First-Class Functions: Passing, Returning, and Storing Functions
- Learn JS Series (#16) - Arrow Functions vs function: Syntax, this, and When Each Wins
- Learn JS Series (#17) - Closures: The Single Most Important Idea in JavaScript
- Learn JS Series (#18) - Higher-Order Functions: Functions That Take or Return Functions
- Learn JS Series (#19) - Callbacks and the Callback Pattern (Before We Reach Promises)
- Learn JS Series (#20) - Default, Rest, and Spread: Flexible Function Signatures
- Learn JS Series (#21) - Destructuring Parameters: Named Arguments the JS Way
- Learn JS Series (#22) - The this Keyword: Five Rules That Explain Every Case
- Learn JS Series (#23) - call, apply, and bind: Controlling this Explicitly
- Learn JS Series (#24) - Recursion: Base Cases, the Call Stack, and Stack Overflows
- Learn JS Series (#25) - IIFEs and the Module Pattern (the Pre-2015 Way to Get Privacy)
- Learn JS Series (#26) - Currying and Partial Application
- Learn JS Series (#27) - Function Composition: Building Pipelines from Small Functions
- Learn JS Series (#28) - Pure Functions and Side Effects: The Foundation of Predictable Code
- Learn JS Series (#29) - Memoization: Trading Memory for Speed with Closures
- Learn JS Series (#30) - Mini Project: A Small Functional Utility Library
- Learn JS Series (#31) - Object Literals, Shorthand, and Computed Property Names
- Learn JS Series (#32) - Property Descriptors: writable, enumerable, configurable
- Learn JS Series (#33) - Getters and Setters: Computed Properties That Look Like Data
- Learn JS Series (#34) - The Prototype: JavaScript's Actual Inheritance Mechanism (this post)
Learn JS Series (#34) - The Prototype: JavaScript's Actual Inheritance Mechanism
At the close of last episode I promised you that everything we had done with objects so far was quietly sitting on top of a deeper mechanism, and that today we would finally look underneath. Here we are. This is, I argue, the single most distinctive feature of JavaScript's object model -- the thing that makes it genuinely different from Python, Java, C++, or almost any other language you have used -- and it is also the feature that people misunderstand more than any other. My goal today is that you walk away not confused but comfortable, because once the core idea clicks it is honestly simpler than the class-based model most languages force on you.
So let's earn it properly, from the ground up, and by the end of the episode you will understand why arrays magically have a .map method, why class (which we reach in a few episodes) is described as "just syntax sugar", and how to inspect and control the whole machine yourself.
Solutions to Episode 33 Exercises
Exercise 1 - a circle with a computed area:
const circle = {
radius: 2,
get area() { return Math.PI * this.radius * this.radius; },
};
console.log(circle.area.toFixed(2)); // "12.57"
circle.radius = 3;
console.log(circle.area.toFixed(2)); // "28.27" - recomputed
The insight: the getter recomputes from the current radius every time you read area, so it can never drift out of sync with the underlying data.
Exercise 2 - a validating, transforming email setter:
const user = {
get email() { return this._email; },
set email(value) {
if (!value.includes("@")) throw new Error("invalid email");
this._email = value.toLowerCase();
},
};
user.email = "[email protected]";
console.log(user.email); // "[email protected]"
// user.email = "nope"; // throws: "invalid email"
The insight: the setter validates and normalizes before storing in the backing _email, so the object can never hold a malformed value.
Exercise 3 - two-way temperature:
const temperature = {
celsius: 0,
get fahrenheit() { return this.celsius * 9 / 5 + 32; },
set fahrenheit(f) { this.celsius = (f - 32) * 5 / 9; },
};
temperature.fahrenheit = 100;
console.log(temperature.celsius.toFixed(1)); // "37.8"
// a setter for x must never do this.x = ...; it would call itself forever
The insight: use a separate backing property; a setter that assigns to its own name recurses infinitely into a stack overflow.
Right, solutions behind us. Now for the real heart of Phase 3, and the feature that makes JavaScript's object model genuinely unusual.
The big idea: objects link to other objects
Most languages do inheritance through classes. A class is a kind of blueprint that describes a type, and objects are instances stamped out from that blueprint. If you learned Python, Java, or C++ first, that is the mental model burned into your head: define a class Dog, then create dog instances from it. The class is the thing; the objects are copies made in its image.
JavaScript's underlying mechanism is different, and once you see it, it is beautifully simple. Every object has a hidden internal link to another object, called its prototype. There is no separate blueprint sitting off to one side; there is just an object, linked to another object. When you try to read a property that an object does not have, JavaScript follows that link to the prototype and looks there instead. If the prototype does not have it either, it follows that object's prototype link, and so on, until it either finds the property or runs out of links.
That chain of links is how objects share behaviour. There are no classes at the bottom, only objects linked to objects. This style of inheritance even has a name -- prototypal (or delegation-based) inheritance, as opposed to the classical (blueprint-based) inheritance of most other languages. The class keyword you will meet in a few episodes is entirely built on top of this prototypal machine. So it makes far more sense to understand the real engine first, and only then meet the friendly steering wheel that JavaScript bolted on in 2015.
Seeing the prototype
Let's make it concrete, because abstract talk about "links" only gets you so far. When you look up a property, JavaScript checks the object itself first, then its prototype. So we can share a method across many objects by putting it on one common prototype:
// an object that will serve as a shared prototype
const animal = {
describe() {
return `I am a ${this.type} and I say ${this.sound}`;
},
};
// create an object whose prototype is 'animal'
const dog = Object.create(animal);
dog.type = "dog";
dog.sound = "woof";
console.log(dog.describe()); // "I am a dog and I say woof"
Object.create(animal) makes a brand new, empty object whose hidden prototype link points at animal. So dog has no describe method of its own -- only the type and sound we gave it directly. When we call dog.describe(), JavaScript does not find describe on dog, so it follows the prototype link to animal, finds describe there, and runs it.
Now here is the part that trips people up, so read it twice. Inside describe, this is still dog, NOT animal. Remember episode 22, rule 1: this is the object before the dot at the call site, and the call was dog.describe(). So this.type and this.sound read dog's own values. That is inheritance, JavaScript style: dog borrows the describe method from animal but runs it against its own data. The method lives in one place; the data lives per object.
The payoff is obvious the moment you add a second animal. Watch how both objects share the exact same function without us writing it twice:
const cat = Object.create(animal);
cat.type = "cat";
cat.sound = "meow";
console.log(cat.describe()); // "I am a cat and I say meow"
// both objects borrow the SAME function from the SAME prototype:
console.log(dog.describe === cat.describe); // true
dog.describe and cat.describe are literally the same function object in memory -- there is only one describe, living on animal, and both dog and cat delegate to it. This is the whole economy of the prototype model in a nutshell.
Own properties versus inherited properties
This split gives us two categories of property on any object: own properties (defined directly on the object itself) and inherited properties (found by following the prototype chain). Being able to tell them apart matters quite some in real code -- for serialization, for iteration, for defensive checks -- and JavaScript gives you the tools to do it precisely:
console.log(dog.type); // "dog" - own property
console.log(dog.describe); // [Function] - inherited from animal
console.log(Object.hasOwn(dog, "type")); // true - own
console.log(Object.hasOwn(dog, "describe")); // false - inherited, not own
console.log("describe" in dog); // true - 'in' checks the WHOLE chain
Object.hasOwn(obj, key) is the modern, safe check: it tells you whether a property lives directly on the object, ignoring anything inherited. The in operator, by contrast, returns true for inherited properties too -- it walks the entire chain. So "describe" in dog is true even though describe is not dog's own property, while Object.hasOwn(dog, "describe") is false. Two different questions, two different tools.
A quick historical note, because you will see the old form everywhere: before Object.hasOwn (which arrived in 2022) people wrote dog.hasOwnProperty("type"). That still works, but it can break if an object happens to have its own property literally called hasOwnProperty, or if its prototype is null. Object.hasOwn sidesteps both problems, so prefer it in new code. Same question, safer phrasing.
This own-versus-inherited distinction is also exactly why Object.keys (which lists only own enumerable properties, as we saw back in episode 32) does not show inherited methods:
console.log(Object.keys(dog)); // ["type", "sound"] - only OWN properties, not 'describe'
describe belongs to animal, not dog, so it does not appear. for...in, on the other hand, would walk up the chain and visit inherited enumerable properties too -- which is precisely why seasoned developers are wary of for...in on objects with interesting prototypes, and reach for Object.keys when they mean "just this object's own stuff".
Reading and setting a prototype
So how do you read or set an object's prototype directly? The clean, modern way is the pair Object.getPrototypeOf and Object.setPrototypeOf:
console.log(Object.getPrototypeOf(dog) === animal); // true - dog's prototype IS animal
const bird = {};
Object.setPrototypeOf(bird, animal); // link bird to animal after the fact
bird.type = "bird";
bird.sound = "tweet";
console.log(bird.describe()); // "I am a bird and I say tweet"
You will also encounter the older __proto__ property (that is two underscores on each side), which does the same job but is a legacy accessor:
console.log(dog.__proto__ === animal); // true - the legacy way to read the prototype
// prefer Object.getPrototypeOf / Object.setPrototypeOf in real code
__proto__ works, and you will meet it in tutorials and old codebases, but it is considered legacy and is a bit of a trap: it is not a normal data property, it is an inherited accessor (a getter/setter pair, exactly the kind we studied last episode) living on Object.prototype. That subtlety causes confusing bugs, so treat __proto__ as read-only-in-your-head and use the explicit Object.getPrototypeOf / Object.setPrototypeOf functions when you actually need to touch the link.
There is also an important performance caveat. Calling setPrototypeOf on an object that already exists is slow, and it forces the JavaScript engine to throw away optimizations it had built for that object (episode 106 explains the "hidden classes" machinery behind this). So in practice you almost never mutate a prototype after creation. Instead you set an object's prototype at the moment it is born, with Object.create (which is the whole subject of the next episode). Set-at-birth: fast. Re-parent-later: slow and best avoided.
Assignment always creates own properties
Here is a subtle but absolutely vital rule, and it catches beginners constantly. Reading a property walks up the prototype chain, but writing a property does NOT. Assignment always creates or updates an own property on the object itself. It never reaches up and modifies the prototype. Watch closely:
const base = { greeting: "hello" };
const obj = Object.create(base);
console.log(obj.greeting); // "hello" - inherited from base, obj has none of its own
obj.greeting = "hi"; // creates an OWN property on obj, shadowing base
console.log(obj.greeting); // "hi" - obj's own property now wins
console.log(base.greeting); // "hello" - base is completely untouched
Assigning obj.greeting = "hi" did not change base one bit. It created a new own property on obj that shadows the inherited one (the same shadowing idea as variable scope back in episode 10 -- the closest one wins). From then on, reading obj.greeting finds the own property first and stops; it never even looks at base.
This rule is what makes shared prototypes safe. A thousand objects can all delegate reads to one shared prototype, and when any single object writes to a property, it quietly gets its own private copy instead of corrupting the shared original. Reads look up the chain; writes stay strictly local. Burn that sentence into memory and half the "spooky action at a distance" confusion around prototypes simply evaporates.
Nota bene: there is one classic gotcha lurking here. If a shared prototype holds a mutable object (say an array) and you mutate it in place with obj.list.push(1) rather than assigning a new one, then you are reading list off the prototype and mutating that shared array -- so every object sees the change. The safe-shadowing rule only protects you on assignment, not on in-place mutation of an inherited object. Keep shared prototypes holding methods and primitives, and you will not get bitten.
A quick look sideways: how other languages do inheritance
Since quite some of you arrived here from the Learn Python Series (or know a bit of Go, Rust, or C++), the contrast is genuinely illuminating, so let me draw it plainly.
In Python, Java, or C++, you write a class and the language stamps out instances from it. The class is a distinct kind of entity, separate from the objects it produces:
class Animal:
def __init__(self, kind, sound):
self.kind = kind
self.sound = sound
def describe(self):
return f"I am a {self.kind} and I say {self.sound}"
dog = Animal("dog", "woof") # dog is an INSTANCE of the class Animal
print(dog.describe()) # "I am a dog and I say woof"
Here Animal is the blueprint and dog is an instance. But look closer: even Python resolves describe by looking it up on the class after not finding it on the instance -- a method lookup that walks from the instance to its class to the base classes (the famous MRO, method resolution order). So under the hood Python is also doing a chained lookup. The difference is philosophical and structural: Python bakes that chain into a formal class/instance distinction, whereas JavaScript exposes the raw chain of plain objects with no privileged "class" entity at all.
Go avoids inheritance almost entirely, preferring composition and interfaces -- a very different answer to the same question. Rust has no inheritance at all; it shares behaviour through traits. JavaScript's answer is the third road: keep it all as objects, and let one object delegate to another. Same universal need -- "let many objects share one implementation" -- solved three completely different ways. When you understand that prototypes are JavaScript's chosen answer, the language stops feeling weird and starts feeling coherent. ;-)
Why this matters: everything is prototypes
You might reasonably ask why JavaScript works this way at all. Two big reasons.
First, it is memory-efficient. A thousand dog objects can all share one single describe method living on a common prototype, instead of each carrying its own private copy of the function. One function, a thousand delegators. That is a real saving.
Second, it is dynamic and live. Add a method to a prototype at runtime, and every object already linked to that prototype instantly gains the new method -- no rebuild, no re-instantiation. The chain is consulted fresh on every lookup, so the objects see changes the moment you make them.
And critically -- this is the punchline of the whole episode -- prototypes are the foundation of everything in the language, not just objects you build by hand. Arrays inherit their methods from Array.prototype. Strings borrow theirs from String.prototype. Functions from Function.prototype. And the class syntax (episode 38) is pure sugar over exactly this prototype mechanism. Let me prove the first claim so it is not just a promise:
// proof that built-ins use prototypes:
const arr = [1, 2, 3];
console.log(Object.getPrototypeOf(arr) === Array.prototype); // true
console.log(arr.map === Array.prototype.map); // true - map lives on the prototype
console.log(Object.hasOwn(arr, "map")); // false - arr does NOT own map
Your array does not carry its own map, filter, or forEach. It inherits them from Array.prototype, using the exact same mechanism we built by hand with animal and dog. When you call arr.map(...), JavaScript fails to find map on arr, follows the prototype link to Array.prototype, finds map there, and runs it with this bound to your array. It is dog.describe() all over again -- just with the standard library playing the role of animal.
So when you finally meet class in a few episodes, it will hold no mystery whatsoever. A class is simply a tidy, familiar-looking way to set up these prototype links: the methods you write in a class body land on a shared prototype, and new creates objects whose hidden link points at it. That is why we learn the machinery first and the sugar second. Understand the engine, and the dashboard explains itself.
Try it yourself
- Create a prototype object
vehiclewith a methodstart()that returns`the ${this.kind} starts`. UseObject.createto make acarlinked to it, givecarakindof"car", and callcar.start(). Then confirm thatstartis inherited, not own, usingObject.hasOwn. - Create an object linked to
vehicle(from exercise 1) and give it astartproperty of its own that returns something different (shadowing the inherited one). Show that callingstart()now uses the object's own version, and thatvehicle's originalstartis completely unchanged. - Take any array and prove that its
filtermethod comes fromArray.prototype(not from the array itself) usingObject.hasOwnon the array and a===comparison toArray.prototype.filter. Then explain in one sentence why a million arrays all sharing onefilteris memory-efficient.
So what did we actually cover?
- Every object has a hidden link to another object, its prototype; reading a missing property follows that link, and keeps following until it finds the property or runs out of links.
- Objects share behaviour by inheriting methods from a common prototype; inside an inherited method,
thisis still the original object, so the shared method runs against per-object data. - Own properties live directly on the object; inherited ones are found up the chain.
Object.hasOwnchecks own-only; theinoperator checks the whole chain;Object.keyslists own enumerable properties only. - Read and set a prototype with
Object.getPrototypeOf/Object.setPrototypeOf(prefer these over the legacy__proto__), and avoid re-parenting an object after creation for performance reasons. - Reading walks the chain, but writing always creates an OWN property (shadowing the inherited one), so an object's writes never corrupt a shared prototype -- with the one caveat that in-place mutation of an inherited object is still shared.
- This is the foundation of the entire language: arrays inherit from
Array.prototype, strings fromString.prototype, andclassis sugar over prototypes.
Next episode we trace the full prototype chain end to end -- exactly how property lookup climbs from an object, up through each prototype in turn, all the way to Object.prototype and finally to null, where the search stops.