Mert Özen Further With Every Line
Frontend JavaScript Advanced — #01

The JavaScript Prototype Chain: How Objects Inherit from Each Other

Mert Özen Nov 3, 2026 9 min 7 views
The JavaScript Prototype Chain: How Objects Inherit from Each Other

We untangle the [[Prototype]] link, property lookup along the chain, __proto__ versus Object.getPrototypeOf, constructor functions, Object.create and the prototype machinery underneath class, step by step in the console.

Version note: The code in this post targets ES2026 and current browsers (Chrome, Edge, Firefox, Safari).

Predict First

We've covered a lot of ground in the Basics and Intermediate series: classes, closures and this, promises, async/await, fetch, the DOM, events and modules. In the Advanced series we go behind the curtain. We'll talk about how objects are linked to each other, the real order in which the event loop runs things, memory, performance, workers and Web Components. The first stop is the heart of JavaScript's object model: the prototype.

As usual, let's start with a question. What does this code print to the console?

const animal = { eats: true };
const rabbit = Object.create(animal);
rabbit.jumps = true;

console.log(rabbit.eats);
console.log(Object.keys(rabbit));
console.log("eats" in rabbit);

Make a guess before you keep reading. We never wrote eats on rabbit, yet all three lines will return something. We'll see which one returns what, and why, once we've walked through the chain.

In JavaScript every object has a hidden internal slot, written [[Prototype]] in the specification. It points either to another object or to null. The object it points to is called that object's prototype. The prototype can have a prototype of its own, so objects link up into a chain.

Think of it like a family. When you look for a recipe, you check your own notebook first. If it isn't there, you ask your mother, and if she doesn't know, your grandmother. If there's nobody left to ask at the end of the chain, the answer is "we don't know." In JavaScript, that "we don't know" is undefined.

You use this chain every day without noticing. Paste this into the console and try it:

const user = { name: "Ada" };

console.log(user.toString()); // "[object Object]"
console.log(Object.getPrototypeOf(user) === Object.prototype); // true
console.log(Object.getPrototypeOf(Object.prototype)); // null

We never added a toString method to user. It comes from the next link up the chain, Object.prototype. The prototype of Object.prototype is null, and that's where the chain ends. For arrays the chain is one link longer: [] links to Array.prototype, which links to Object.prototype. Methods like map, push and includes all live on Array.prototype; every array uses them through the chain instead of carrying its own copy.

__proto__ or Object.getPrototypeOf?

In older code and forum answers you'll often see the prototype reached through obj.__proto__. It works, but MDN marks this accessor as deprecated. In fact, __proto__ isn't even an own property of the object; it's a getter/setter pair defined on Object.prototype. Browsers added it on their own years ago, and it was later standardized for backward compatibility.

These are the tools to use today:

const base = { greet() { return "hi"; } };
const obj = {};

Object.setPrototypeOf(obj, base);
console.log(Object.getPrototypeOf(obj) === base); // true
console.log(obj.greet()); // "hi"

const dict = Object.create(null);
console.log(dict.__proto__); // undefined
console.log(Object.getPrototypeOf(dict)); // null

The last two lines show why you shouldn't rely on __proto__. An object whose prototype is null doesn't inherit from Object.prototype, so it doesn't have the __proto__ accessor either. Object.getPrototypeOf gives the right answer in every case.

One more warning: changing the prototype of an existing object with Object.setPrototypeOf undoes the optimizations the engine made for that object, and it's slow. Decide the prototype when you create the object; that's what Object.create is for. Writing { __proto__: base } inside an object literal is separate syntax: it's valid and not deprecated.

Lookup Along the Chain: What the Engine Does

When you read a property, the engine first looks at the object's own properties. If it can't find it there, it moves to [[Prototype]] and searches there. It keeps climbing until it finds the property or reaches null. If it reaches null, the result is undefined.

Now we can go back to the opening question. rabbit.eats returns true, because eats, which isn't on rabbit, is found one link up, on animal. Object.keys(rabbit) returns only ["jumps"]: Object.keys never looks at the chain, it lists only the object's own enumerable properties. "eats" in rabbit is true again, because the in operator scans the whole chain. If you want to ask "does this property really belong to this object?", use Object.hasOwn, which arrived in ES2022.

Reads climb the chain; writes don't. Try this in the console too:

const bird = {
  flies: true,
  move() {
    return `${this.name} is flying`;
  }
};
const sparrow = Object.create(bird);
sparrow.name = "Tweety";

console.log(sparrow.move()); // "Tweety is flying"

sparrow.flies = false;
console.log(sparrow.flies, bird.flies); // false true
console.log(Object.hasOwn(sparrow, "flies")); // true

The move method lives on bird, but the this inside it points to sparrow. The rule we saw earlier holds here too: this doesn't care where the method was found, only what's to the left of the dot in the call. That's how thousands of objects can share one method while each works with its own data.

The line sparrow.flies = false didn't touch bird. The assignment created a new own flies on sparrow, and the value on the prototype is now hidden behind it. This is called shadowing. There are two exceptions: a setter with the same name somewhere up the chain, or a non-writable property. We'll see both in the next post, together with property descriptors.

A side note: a for...in loop also visits enumerable properties up the chain. That's one of the reasons we prefer Object.keys or Object.entries when iterating over objects.

Constructor Functions and the prototype Property

Before class syntax arrived, constructor functions were the standard way to create objects. Even if you never write one yourself, a lot of the code you'll read still looks like this:

function User(name) {
  this.name = name;
}

User.prototype.greet = function () {
  return `Hi, I'm ${this.name}`;
};

const linus = new User("Linus");
const grace = new User("Grace");

console.log(linus.greet()); // "Hi, I'm Linus"
console.log(linus.greet === grace.greet); // true
console.log(Object.getPrototypeOf(linus) === User.prototype); // true

When you write new User("Linus"), the engine does four things. It creates an empty object. It links that object's [[Prototype]] to User.prototype. It runs the User function with this pointing to the new object. And unless the function itself returns some other object, it returns the new one.

The naming is confusing here, so pay attention: User.prototype is not the prototype of the User function. It's the object that will become the prototype of everything created with new User(). The function's own prototype is Function.prototype. Regular functions are born with this prototype property, and it contains a constructor pointing back to the function, so User.prototype.constructor === User. Arrow functions have no prototype, which is why they can't be called with new.

If we had defined greet inside the constructor as this.greet = function () {}, every object would carry its own copy. Because we put it on the prototype, both objects share the same function; linus.greet === grace.greet returning true proves it.

Object.create: Picking the Prototype Yourself

Object.create(proto) produces a brand-new, empty object whose prototype is proto. No constructor, no new. It's the most explicit way to build a chain:

const vehicle = {
  describe() {
    return `${this.brand}, ${this.wheels} wheels`;
  }
};

const car = Object.create(vehicle);
car.brand = "Togg";
car.wheels = 4;

const bike = Object.create(vehicle, {
  brand: { value: "Bisan", enumerable: true },
  wheels: { value: 2, enumerable: true }
});

console.log(car.describe()); // "Togg, 4 wheels"
console.log(bike.describe()); // "Bisan, 2 wheels"

The second argument lets you define properties with descriptors. We'll unpack exactly what settings like enumerable do in the next post. There's also Object.create(null): a perfectly clean object that inherits from nothing. It used to be the go-to dictionary for storing user-supplied keys; today the Map we saw earlier is usually the better choice for that job.

What a Class Really Does

The class syntax we saw earlier didn't bring a new object model; the same prototype mechanism is running underneath. Check it yourself:

class Account {
  constructor(owner) {
    this.owner = owner;
  }
  info() {
    return `Account owner: ${this.owner}`;
  }
}

class SavingsAccount extends Account {
  rate() {
    return 0.4;
  }
}

const acc = new SavingsAccount("Ada");

console.log(typeof Account); // "function"
console.log(Object.getPrototypeOf(acc) === SavingsAccount.prototype); // true
console.log(Object.getPrototypeOf(SavingsAccount.prototype) === Account.prototype); // true
console.log(Object.hasOwn(Account.prototype, "info")); // true
console.log(acc instanceof Account); // true

The chain looks like this: acc → SavingsAccount.prototype → Account.prototype → Object.prototype → null. When you call acc.info(), the engine doesn't find info on acc, doesn't find it on SavingsAccount.prototype, and finds it on Account.prototype. instanceof isn't magic either; it checks whether Account.prototype appears anywhere in the chain of acc.

Still, a class isn't exactly the same as a constructor function. Class methods are defined as non-enumerable, so they don't show up in for...in. A class body always runs in strict mode. Calling a class without new throws a TypeError. And extends also links the static side: Object.getPrototypeOf(SavingsAccount) === Account is true as well, which is how static methods get inherited.

Leave Built-in Prototypes Alone

Once you learn about the chain, an idea shows up right away: if I add a method to Array.prototype, every array can use it.

Array.prototype.last = function () {
  return this[this.length - 1];
};

console.log([1, 2, 3].last()); // 3

It works, but don't do it. This is called monkey patching, and it has caused problems that left a mark on the language. When Array.prototype.contains was about to be added, it clashed with a method of the same name added by the old MooTools library, so the name became includes. In 2018 the flatten proposal broke some sites because of the same library, and the method was renamed to flat; that episode is known as "SmooshGate." So if a method you add makes it into the standard one day, either your code breaks or the language has to change a name because of you.

On top of that, a property added this way is enumerable, so it suddenly appears in any code that uses for...in. Write your helper as a separate function or module. The only legitimate exception is a polyfill: adding a feature that's already defined in the standard to an older browser that doesn't support it yet, following the specification exactly. When you're done experimenting, type delete Array.prototype.last in the console to clean up.

Common Pitfalls

The first pitfall is putting a mutable value on the prototype. Sharing is great for methods and a disaster for data:

function Team() {}
Team.prototype.members = [];

const red = new Team();
const blue = new Team();
red.members.push("Linus");

console.log(blue.members); // ["Linus"]

red.members.push(...) isn't an assignment, it's a read. First members is read, the shared array is found up the chain, and then that single array is modified. Per-object data belongs on this, set in the constructor.

The second is putting an arrow function on the prototype. If you write User.prototype.greet = () => this.name, the arrow function has no this of its own, so it sees the this of the place where it was defined rather than the object. Use a regular function or a class method for prototype methods.

The third is replacing the prototype wholesale. Writing User.prototype = { greet() {} } loses the constructor property, and objects created earlier stay linked to the old prototype. If you want to add methods, add them one by one to the existing object.

The last one is a design trap: the longer the chain, the longer both the lookup and your reasoning. Engines cache property lookups very well, so performance is rarely the problem, but a five- or six-level inheritance tree is genuinely hard to read and change. We'll come back to this when we compare inheritance with composition.

A Small Experiment

★☆☆ Predict the output before running it, then try: const a = { x: 1 }; const b = Object.create(a); const c = Object.create(b); a.x = 5; console.log(c.x, Object.hasOwn(c, "x")); Then type c.x = 9 and check whether a.x changed.

★★☆ Write a chainOf(obj) function that returns an object's prototype chain as an array. Calling Object.getPrototypeOf until you get null is enough. Try it with [], {}, Object.create(null) and an instance of a class you wrote, and print the constructor.name of each link.

★★★ Rewrite the Account and SavingsAccount classes above without the class keyword, using constructor functions and Object.create. Make both acc instanceof Account and acc.constructor === SavingsAccount return true. Hint: for the second one you'll need to put constructor back by hand.

In this post we saw how the chain behaves when properties are read and written, but we mentioned in passing that properties themselves have hidden settings. In the next post we open those settings up: making a property read-only, hiding it from listings, making it impossible to delete, and how far Object.freeze can really freeze an object.