Understanding Prototypal Inheritance in JavaScript

Understanding Prototypal Inheritance in JavaScript

I bombed a whiteboard interview because I couldn't explain __proto__ vs .prototype. Went home and actually figured it out. Here's the prototype chain, Object.create, constructor functions, and why ES6 classes are just syntax over the same old mechanism.

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 anywhere

If it helps, picture it like this:

child  -->  parent  -->  grandparent  -->  Object.prototype  -->  null

Object.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.prototype is a plain object that becomes the __proto__ of anything created with new Person().
  • alice.__proto__ points to Person.prototype.
  • Person.__proto__ points to Function.prototype, because Person itself 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 use Object.getPrototypeOf(obj) instead of obj.__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:

  1. A fresh empty object is created.
  2. That object's [[Prototype]] is set to the constructor's .prototype.
  3. The constructor runs with this pointing at the new object.
  4. 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); // true

That 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);         // true

This 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); // true

Classes 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 globalThis

Shared 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 === true

Defend 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.

Written by Anurag Kumar

Full-stack developer passionate about Node.js and building fast, scalable web applications. Writing about what I learn every day.

Comments (0)

No comments yet. Be the first to share your thoughts!