← Articles

Prototypes, Not Classes: What's Actually Happening in JavaScript

Ashish Lekhyani,

Let’s be straight about it, JavaScript is a prototype based language, not a class based one. And since you’re here, you probably already know this much. You’ve written class Animal {} a hundred times, and it looks like a class, works like a class, and everyone calls it a class. But it’s not completely true.

Think of any well known actor who’s spent years playing a character. Audiences know the character’s name, quote the lines, recognize the character’s face. But behind every scene, it was the actor doing the actual work, performing as the character, delivering the line, making the choices in the moment. The character gets the fame. The actor does the job.

That’s classes and prototypes. The class is the character, the name everyone knows, the thing you write, the thing that gets addressed. The prototype is the actor, the one actually doing the work underneath, whether or not the audience ever learns their name.

And there was a time when the actor had no character. That’s JavaScript before ES6, when prototypes were the only way to do inheritance, no class syntax. Then in 2015, JavaScript wrote a character to play for them. class, not a new actor, not a new set of skills, just a name with a costume that made them easier to work alongside languages like Java and C++.

The illusion, proven in one line

Here’s the entire argument, compressed into a few lines of code:

class Wolf {}

console.log(typeof Wolf); // "function"

Not "class". Not some special new type. "function". JavaScript doesn’t consider it a fundamentally new thing, it’s a function underneath, dressed up in different syntax. That’s the mask coming off.

So if class is really just a function with different syntax dressed on top, what’s actually happening when you use one? To understand that, we need to go back to how JavaScript worked before class existed, because that mechanism never went away. It’s still there, exactly as it was before 2015.

What a prototype actually is

Every object in JavaScript has an internal link to another object, its prototype. When you try to access a property or method on an object and it’s not found directly on that object, JavaScript walks up to the prototype of that object and looks there. If it’s not there either, it keeps walking up, until it either finds the property or reaches the very end of the chain: null.

This is the prototype chain, and it’s the mechanism behind everything you’ve ever called “inheritance” in JavaScript.

const animal = {
  eats: true,
};

const kangaroo = {
  jumps: true,
};

kangaroo.__proto__ = animal; // illustrating the link

console.log(kangaroo.jumps); // true, own property
console.log(kangaroo.eats);  // true, found via the prototype

kangaroo doesn’t have eats written on it anywhere. JavaScript checked kangaroo first, didn’t find it, walked up to animal, found it there, and handed it back. That’s basically the mechanism. The rest is mostly details built around it.

Why this exists at all

Before this syntax mattered, JavaScript needed a way to share things across many objects without physically copying those into every single one of them. Think about how many arrays exist in a single running program: thousands, sometimes millions. Each one does not carry its own private copy of .push, .map, .filter. There’s exactly one set of those methods, living on Array.prototype, and every array in existence just points to it. That’s not just implementation, it’s the reason the language works the way it does, memory wise and functionally.

prototype, __proto__, and [[Prototype]]: same idea, three names

This is where I need to be precise to make it clear.

[[Prototype]] is the real thing: an internal place, defined in the JavaScript spec itself, that exists on every object (and functions are objects too, so they have one as well). The double brackets in the spec mean “this is something tracked internally, not exposed to your code.”

__proto__ is just a doorway into [[Prototype]]. It’s a getter and setter: reading obj.__proto__ reads [[Prototype]], writing to it writes [[Prototype]]. It’s the old way, the modern way is Object.getPrototypeOf(obj) and Object.setPrototypeOf(obj, x).

.prototype is a different thing altogether. It’s a normal property sitting on the function, nothing internal, nothing hidden. JavaScript creates it automatically when you write function Wolf() {} or class Wolf {}. Arrow functions don’t get one, even though typeof still reports "function". Neither do bound functions, or most methods.

And it is not “what Wolf itself inherits from.” That’s still Wolf’s own [[Prototype]], same as any other object. .prototype is a template. When you later write new Wolf(), the new object gets linked to that template.

function Wolf() {}

console.log(Wolf.prototype);
// { constructor: Wolf }

That’s it, one property by default, constructor, pointing back to Wolf. Everything else you see on Wolf.prototype later is stuff you add yourself.

Here’s the table version, since this is worth seeing side by side:

QuestionWolf.__proto__ (→ [[Prototype]])Wolf.prototype
What it isWhat Wolf itself inherits fromThe template future instances of Wolf inherit from
Points toFunction.prototypeAn ordinary object, { constructor: Wolf } by default
Exists onEvery object, including functionsConstructor functions and classes, not arrow functions

This is where the two usually get connected, when you use new.

function Wolf(name) { this.name = name; }
const rex = new Wolf("Rex");

rex.__proto__ === Wolf.prototype; // true

rex’s [[Prototype]] now points at Wolf.prototype. Two separate concepts, one connecting event.

What new actually does

When you write new Wolf("Rex"), JavaScript runs through four steps, every time:

  1. Create a brand-new, empty object.
  2. Set that new object’s [[Prototype]] to Wolf.prototype.
  3. Call Wolf with this bound to the new object.
  4. Return the new object, unless Wolf explicitly returns some other object itself.

You can write this out by hand, and it behaves identically:

function myNew(Constructor, ...args) {
  // Create a new, empty object, and link its [[Prototype]]
  // to Constructor.prototype — this replicates step 1 and 2 of `new`
  const obj = Object.create(Constructor.prototype);

  // Run the constructor function, forcing `this` inside it
  // to be our new object — this replicates step 3 of `new`
  const result = Constructor.apply(obj, args);

  // If the constructor explicitly returned its own object, use that.
  // Otherwise, fall back to the object we built above — this is step 4
  return result instanceof Object ? result : obj;
}

Worth knowing: any function can be used this way. JavaScript doesn’t have a separate “constructor” type, “constructor” describes how a function is used, not what it fundamentally is.

function Wolf(name) { this.name = name; }
Wolf("Rex");      // without new: this becomes the global object, or throws a TypeError
new Wolf("Rex");  // used as a constructor, actually builds an object

Same function, two different outcomes, depending entirely on whether new shows up. Capitalizing Wolf is just a convention telling other developers “use new with me”, the language itself doesn’t enforce it for regular functions. class does enforce it, and that’s one of the real, non-cosmetic things it adds:

class Wolf {}
Wolf(); // TypeError: Class constructor Wolf cannot be invoked without 'new'

Desugaring class: what it actually compiles down to

Here’s the full side-by-side, since this is the centerpiece of the whole argument:

// What you write
class Animal {
  constructor(name) { this.name = name; }
  speak() { console.log(`${this.name} makes a noise`); }
  static create(name) { return new Animal(name); }
}

// What it actually is, underneath
function Animal(name) { this.name = name; }
Animal.prototype.speak = function () {
  console.log(`${this.name} makes a noise`);
};
Animal.create = function (name) { return new Animal(name); };

Notice where static is stored, directly on Animal itself, not on Animal.prototype. That’s because, static methods aren’t meant to be inherited by instances, only used by the class itself (or by a subclass built on top of it), so they live on the constructor function, not the shared instance template.

A few things class genuinely adds, beyond just being a nicer way to write the same thing:

  • Calling a class without new throws an error, as shown above. Calling a constructor function without new is also unsafe. In older, non-strict scripts, this quietly becomes the global object (window in a browser). In modern code — modules, or anything with "use strict" — assigning this.name throws a TypeError. Either way, you don’t get the object you meant to build.
  • Class methods are non-enumerable by default. Methods you attach the old way (Animal.prototype.speak = ...) are enumerable by default, meaning they’d show up in a for...in loop over an instance unless you deliberately hide them.
  • Classes live in the ‘temporal dead zone’: you can’t use them before the line they’re defined on, no hoisting, unlike function declarations.

So class isn’t purely cosmetic, it adds real guardrails. But underneath, it’s the same mechanism doing the same work, through the same chain.

Multi-level inheritance: the old way and the class way

// The old way

function Animal(name) {
  this.name = name;
}
Animal.prototype.eat = function () {
  console.log(`${this.name} eats`);
};

function Wolf(name) {					// borrow the constructor
  Animal.call(this, name);
}
Wolf.prototype = Object.create(Animal.prototype);	// link the prototypes
Wolf.prototype.constructor = Wolf;			// repair the constructor reference
Wolf.prototype.howl = function () {
  console.log(`${this.name} howls`);
};

Three things are happening here, and each one maps to something extends keyword later automated.

Animal.call(this, name): this is constructor stealing. It runs Animal’s setup logic, but forces this inside Animal to be the new Wolf being built, instead of a separate Animal object. Without it, you’d have to duplicate this.name = name inside Wolf yourself.

Wolf.prototype = Object.create(Animal.prototype): this is the actual inheritance wiring. Object.create(Animal.prototype) builds a brand-new, empty object whose [[Prototype]] points at Animal.prototype, and that becomes Wolf.prototype. The chain now reads: Wolf.prototype → Animal.prototype → Object.prototype → null. This is not the same as writing Wolf.prototype = Animal.prototype directly. That would make them the literal same object in memory, meaning anything you later add to Wolf.prototype would leak straight onto Animal.prototype too, and every Animal would suddenly know how to howl.

Wolf.prototype.constructor = Wolf: a repair job. The line above replaced Wolf.prototype with a brand-new object that has no constructor property of its own, so a lookup for Wolf.prototype.constructor walks the chain and incorrectly lands on Animal. This line manually points it back to Wolf.

Here’s the same thing, written with class:

class Animal {
  constructor(name) { this.name = name; }
  eat() { console.log(`${this.name} eats`); }
}

class Wolf extends Animal {
  howl() { console.log(`${this.name} howls`); }
}

const rex = new Wolf("Rex");
rex.eat();  // "Rex eats", inherited
rex.howl(); // "Rex howls", its own

extends does all three of those manual steps invisibly. That’s genuinely useful, but it’s worth knowing what it’s hiding, because when something breaks, JavaScript won’t tell you it’s because of what extends did behind the scenes.

The bug this actually causes

Here’s a version of a mistake I ran into while learning about prototypes myself. It took me two hours to figure out, and it’s easy to make without ever touching class:

function Wolf() {}
Wolf.prototype = { howl() { console.log("awoo"); } }; // replaced the whole object

const rex = new Wolf();

console.log(rex.constructor === Wolf);    // false!
console.log(rex.constructor === Object); // true, not what you'd expect

Overwriting Wolf.prototype entirely, instead of adding to the existing one, throws away the auto-generated { constructor: Wolf } object and replaces it with a fresh object that has no constructor of its own. rex instanceof Wolf still returns true, because instanceof walks the [[Prototype]] chain, not the constructor property. What breaks is anything that reads .constructor: debugging output, some serializers, checks like obj.constructor === Wolf. Nothing throws. Nothing warns you. It just silently reports the wrong constructor, possibly several files away from where the actual mistake was made.

The fix is either to add to the existing prototype object instead of replacing it (Wolf.prototype.howl = ...), or, if you do replace it, to manually restore constructor the same way the manual inheritance pattern does.

The mixin pattern: a workaround built entirely on this mechanism

JavaScript only allows a single parent per object through the chain: no multiple inheritance. To get around that, a common pattern is to copy methods from several sources directly onto one prototype:

const swimmer = {
  swim() { console.log(`${this.name} swims`); }
};
const flyer = {
  fly() { console.log(`${this.name} flies`); }
};

class Duck {
  constructor(name) { this.name = name; }
}
Object.assign(Duck.prototype, swimmer, flyer);

const donald = new Duck("Donald");
donald.swim(); // "Donald swims"
donald.fly();  // "Donald flies"

This works precisely because Duck.prototype is just an ordinary object: you can attach whatever you want to it, from wherever you want, at any point.

When the mechanism turns dangerous: prototype pollution

Since nearly every plain object in a running program eventually chains up to Object.prototype, mutating it affects everything:

const obj = {};
Object.prototype.isAdmin = true; // never do this

console.log(obj.isAdmin); // true, leaked onto every plain object in the program
console.log({}.isAdmin);  // true

This is a real, named security vulnerability, called prototype pollution, and it has shown up as actual CVEs (publicly tracked, official records of known security flaws) in widely used libraries. It’s usually exploited through merge or clone functions that don’t check their inputs, letting an attacker sneak in a key like __proto__.isAdmin and gain elevated permissions across an entire application. This isn’t a trivia footnote, it’s proof that the mechanism underneath class has consequences well past syntax preference.

Where this leaves you

class didn’t replace anything. It didn’t introduce a new inheritance model, a new type system, or anything new. It gave an old mechanism a more familiar face, one that looks like Java, like C++ and Python.

It’s worth knowing, the next time a constructor reference points somewhere strange, or a merge function starts behaving like it’s reading your mind, that there’s no class actually running any of it. There’s a chain of objects, quietly checking each other, one link at a time, exactly the way it always has been.