Prototypes. Every JS developer has a vague sense of what they are. Most of us couldn't explain them clearly at a whiteboard. I know because I tried at an interview once and bombed it.
I said something about "objects pointing to other objects" and then trailed off when the interviewer asked me to explain the difference between __proto__ and .prototype. I knew they were different. I couldn't articulate why. That interview haunted me enough that I went home and actually figured it out. This post is the result.
How Prototypal Inheritance Actually Works
Java and C++ have classical inheritance: you define a class, you extend it, instances are created from the blueprint. JavaScript does something different. There are no real classes (even the class keyword is a disguise -- but we'll get to that). Instead, objects inherit directly from other objects.
When you try to read a property on an object and it's not there, JavaScript doesn't give up. It follows a hidden link to another object -- the prototype -- and looks there. If it's not on the prototype either, it follows that object's prototype link. And so on, until it either finds the property or hits null at the end of the chain.
const animal = {
isAlive: true,
speak() {
return `${this.name} makes a sound.`;
}
};
const dog = Object.create(animal);
dog.name = 'Rex';
dog.bark = function () {
return `${this.name} barks!`;
};
console.log(dog.speak()); // "Rex makes a sound."
console.log(dog.isAlive); // true
console.log(dog.bark()); // "Rex barks!"dog doesn't have speak or isAlive. It doesn't matter. JavaScript walks up to animal and finds them there. That's the whole idea.
The Chain, Visualized
Every object has a hidden internal slot called [[Prototype]]. (This is not the .prototype property on functions -- I'll clear that up in the next section.) When a property lookup fails on an object, the engine follows [[Prototype]] to the next object.
const grandparent = { family: 'Smith' };
const parent = Object.create(grandparent);
parent.job = 'Engineer';
const child = Object.create(parent);
child.name = 'Alice';
console.log(child.name); // "Alice" -- found on child
console.log(child.job); // "Engineer" -- found on parent
console.log(child.family); // "Smith" -- found on grandparent
console.log(child.age); // undefined -- not found anywhereIf it helps, picture it like this:
child --> parent --> grandparent --> Object.prototype --> nullObject.prototype is the top of almost every chain. It's where toString(), hasOwnProperty(), and valueOf() live. After that, the chain ends at null.
Properties defined higher up are shared by everything below them. That's both the point and the trap -- mutate a shared property on the prototype and you affect every object that inherits from it. But I'm getting ahead of myself.
__proto__ vs .prototype (the Thing That Got Me in the Interview)
This is where everyone gets confused, and honestly the naming is terrible. Two different things with almost the same name. Let me just lay it out plainly.
__proto__ is the actual link from any object to its prototype. Every object has one. You can also access it with Object.getPrototypeOf(), which is the way you should actually do it in real code because __proto__ is a legacy accessor that stuck around for compatibility reasons.
.prototype is a property that exists only on functions. It's the object that will become the __proto__ of any instance created when you call that function with new.
Read that again. .prototype on a function is not the function's own prototype. It's the prototype that gets assigned to instances the function creates.
function Person(name) {
this.name = name;
}
Person.prototype.greet = function () {
return `Hi, I'm ${this.name}`;
};
const alice = new Person('Alice');
// alice.__proto__ === Person.prototype --> true
// Person.__proto__ === Function.prototype --> true
console.log(Object.getPrototypeOf(alice) === Person.prototype); // true
console.log(alice.greet()); // "Hi, I'm Alice"So:
Person.prototypeis a plain object that becomes the__proto__of anything created withnew Person().alice.__proto__points toPerson.prototype.Person.__proto__points toFunction.prototype, becausePersonitself is a function.
This is what I couldn't explain at the whiteboard. Now I can draw it with my eyes closed. Sometimes the only way to learn something is to be embarrassed first.
Always useObject.getPrototypeOf(obj)instead ofobj.__proto__. The__proto__accessor is a leftover from old browsers.Object.getPrototypeOf()is the standard way.
Constructor Functions and What new Actually Does
Before ES6 gave us the class keyword, constructor functions were how you made objects with shared behavior. A constructor function is just a regular function that you're supposed to call with new.
When new runs, four things happen:
- A fresh empty object is created.
- That object's
[[Prototype]]is set to the constructor's.prototype. - The constructor runs with
thispointing at the new object. - If the constructor doesn't explicitly return an object, the new object is returned automatically.
function Vehicle(type, speed) {
this.type = type;
this.speed = speed;
}
Vehicle.prototype.describe = function () {
return `A ${this.type} going ${this.speed} km/h`;
};
Vehicle.prototype.accelerate = function (amount) {
this.speed += amount;
return this.speed;
};
const car = new Vehicle('car', 60);
const bike = new Vehicle('bike', 20);
console.log(car.describe()); // "A car going 60 km/h"
console.log(bike.describe()); // "A bike going 20 km/h"
console.log(car.accelerate(40)); // 100
// Both share the exact same function reference
console.log(car.describe === bike.describe); // trueThat last line is the important bit. Methods on the prototype are shared across all instances. There's one copy of describe in memory, not one per instance. Instance data like type and speed lives on each object individually.
You can also chain constructors for inheritance, though the syntax is... not pretty:
function ElectricVehicle(type, speed, battery) {
Vehicle.call(this, type, speed); // Call parent constructor
this.battery = battery;
}
// Wire up the prototype chain
ElectricVehicle.prototype = Object.create(Vehicle.prototype);
ElectricVehicle.prototype.constructor = ElectricVehicle;
ElectricVehicle.prototype.charge = function () {
this.battery = 100;
return 'Fully charged!';
};
const tesla = new ElectricVehicle('car', 0, 85);
console.log(tesla.describe()); // "A car going 0 km/h"
console.log(tesla.charge()); // "Fully charged!"
console.log(tesla instanceof ElectricVehicle); // true
console.log(tesla instanceof Vehicle); // trueThis exact pattern -- .call() for the parent constructor, Object.create() for the prototype chain -- is what ES6 classes automate. Which is why I don't miss writing it by hand.
Object.create: The Most Direct Way
Object.create() is prototypal inheritance in its purest form. You give it an object, it gives you a new object whose prototype is the one you provided. No constructors, no new, no ceremony.
const baseLogger = {
log(message) {
console.log(`[${this.level}] ${message}`);
},
setLevel(level) {
this.level = level;
}
};
const appLogger = Object.create(baseLogger);
appLogger.level = 'INFO';
appLogger.log('Application started'); // "[INFO] Application started"
const debugLogger = Object.create(baseLogger);
debugLogger.level = 'DEBUG';
debugLogger.log('Variable x = 42'); // "[DEBUG] Variable x = 42"There's also a second argument for property descriptors, if you want fine-grained control:
const config = Object.create(null, {
host: { value: 'localhost', writable: false, enumerable: true },
port: { value: 3000, writable: true, enumerable: true }
});
console.log(config.host); // "localhost"
console.log(config.toString); // undefined -- no prototype at all!That Object.create(null) trick creates an object with absolutely no prototype. No toString, no hasOwnProperty, nothing. It's a truly blank slate. I use this in Node.js whenever I need a plain lookup map and don't want inherited properties sneaking in.
ES6 Classes: Same Prototypes, Nicer Clothes
ES6 gave us class syntax and a lot of people breathed a sigh of relief. But here's what you need to know: JavaScript classes are not classes. Not in the way Java or Python uses the word. They are syntactic sugar over the exact same prototype mechanism we've been talking about.
class Animal {
constructor(name, sound) {
this.name = name;
this.sound = sound;
}
speak() {
return `${this.name} says ${this.sound}`;
}
static classify(animal) {
return `${animal.name} is an Animal`;
}
}
class Dog extends Animal {
constructor(name) {
super(name, 'Woof');
this.tricks = [];
}
learnTrick(trick) {
this.tricks.push(trick);
}
showTricks() {
return this.tricks.length
? `${this.name} can: ${this.tricks.join(', ')}`
: `${this.name} knows no tricks yet.`;
}
}
const rex = new Dog('Rex');
rex.learnTrick('sit');
rex.learnTrick('shake');
console.log(rex.speak()); // "Rex says Woof"
console.log(rex.showTricks()); // "Rex can: sit, shake"Under the hood? Same old prototypes. speak() lives on Animal.prototype. extends sets up the prototype chain. super() calls the parent constructor. You can prove it:
console.log(typeof Animal); // "function"
console.log(Object.getPrototypeOf(rex) === Dog.prototype); // true
console.log(Object.getPrototypeOf(Dog.prototype) === Animal.prototype); // true
console.log(rex instanceof Dog); // true
console.log(rex instanceof Animal); // trueClasses do add some guard rails -- they're always in strict mode, they throw if you forget new, and the constructor isn't enumerable. But the prototype mechanics are identical to the old way.
The reason I think it's worth understanding the prototype layer is that the class syntax hides the mechanism. And when something weird happens -- and it will -- you'll want to know what's actually going on beneath the surface.
The Gotchas That Bit Me (and Might Bite You)
Let me run through the mistakes I've made or seen others make, because some of these are genuinely subtle.
Forgetting new: If you call a constructor function without new, this points to the global object (or undefined in strict mode). Your properties leak everywhere. Classes fix this by throwing an error, which is honestly one of their best features.
function Car(model) {
this.model = model;
}
const myCar = Car('Tesla'); // oops -- no 'new'
// myCar is undefined, and model is now on globalThisShared mutable state on prototypes: This one is sneaky. If you put an array or object on the prototype, every instance shares the same reference. Mutate it through one instance and you've changed it for all of them.
function Team(name) {
this.name = name;
}
Team.prototype.members = []; // Shared array -- danger
const a = new Team('Alpha');
const b = new Team('Beta');
a.members.push('Alice');
console.log(b.members); // ["Alice"] -- wait, what?
// Fix: put reference types in the constructor
function BetterTeam(name) {
this.name = name;
this.members = []; // each instance gets its own
}I've seen this bug in production code. It took us a while to figure out why one user's data was showing up in another user's session. Don't be us.
Prototype pollution: If you're building a Node.js API and you blindly merge user-submitted JSON into objects, an attacker can set __proto__ properties and modify Object.prototype for your entire application.
// Never do this with unvalidated input
function merge(target, source) {
for (const key in source) {
target[key] = source[key];
}
}
// Attacker sends: { "__proto__": { "isAdmin": true } }
// Now every object in your app has isAdmin === trueDefend against this by validating property names, using Object.create(null) for lookup objects, and preferring Map over plain objects for dynamic keys.
Does any of this matter now that we have classes? Honestly, maybe not day-to-day. But it explains why weird things happen. And when you're staring at a bug where instanceof returns false even though the object clearly has the right methods, or when a property you didn't set is mysteriously showing up in a for...in loop, the prototype chain is almost always the answer.
One more practical note: if you're using Object.keys() or the spread operator, those only copy own properties — they don't touch anything from the prototype chain. That's usually what you want. But if you're iterating with for...in, it walks up the chain and includes inherited enumerable properties. Guard against that with hasOwnProperty or just use Object.keys() instead.
const base = { inherited: true };
const obj = Object.create(base);
obj.own = true;
console.log(Object.keys(obj)); // ["own"]
for (const key in obj) {
console.log(key); // "own", then "inherited"
}
// Filter to own properties only
for (const key in obj) {
if (obj.hasOwnProperty(key)) {
console.log(key); // "own" only
}
}I went from bombing that interview question to actually enjoying conversations about prototypes. The mental model isn't complicated once it clicks — objects link to other objects, lookups walk the chain, and classes are a layer of syntax on top of the same mechanism. That understanding has saved me debugging time more than once.
Comments (0)
No comments yet. Be the first to share your thoughts!