Learn JS Series (#36) - Object.create and Pure Prototypal Inheritance

Words
2699
Reading
12 min
Listen
Play
5h

Learn JS Series (#36) - Object.create and Pure Prototypal Inheritance

js-banner.png

What will I learn

  • You will learn to build inheritance directly with Object.create, no classes involved;
  • how to chain prototypes so behaviour is shared across many objects at once;
  • how to add an initializer method that sets up an object's own data;
  • how to override an inherited method and still reach back to the original;
  • why understanding this "pure prototypal" style makes the class keyword feel obvious later.

Requirements

  • A working modern computer running macOS, Windows or Ubuntu;
  • An installed Node.js (20+) distribution, or just a modern browser console;
  • Episodes 1-35 read, especially the prototype (ep34) and the prototype chain (ep35).

Difficulty

  • Intermediate

Curriculum (of the Learn JS Series):

Learn JS Series (#36) - Object.create and Pure Prototypal Inheritance

For two episodes now we have been observing the prototype system: episode 34 revealed that hidden link from one object to another, and episode 35 followed the whole chain up to Object.prototype and finally null, watching a property lookup climb it link by link. Today we stop watching and start building. We are going to construct inheritance ourselves, by hand, wiring one object to another with nothing but Object.create -- no constructors, no new, and above all no class keyword in sight.

I insist on this order for a reason. In two episodes you will meet class, and it will be tempting to treat it as some brand new machinery bolted onto the language. It is not. It is a thin layer of sugar over exactly the prototype links you are about to tie by hand. So if we build the raw thing first, the sugar will hold no mystery at all -- you will look at a class and see straight through it to the objects underneath. Having said that, let's earn it properly. ;-)

Solutions to Episode 35 Exercises

Exercise 1 - a three-level chain:

const base = { fromBase: "base" };
const middle = Object.create(base); middle.fromMiddle = "middle";
const leaf = Object.create(middle); leaf.fromLeaf = "leaf";
console.log(leaf.fromLeaf, leaf.fromMiddle, leaf.fromBase, leaf.nope);
// "leaf" "middle" "base" undefined

The insight: each lookup climbs only as far as it must -- fromLeaf is found at level 0, fromBase travels two links, and nope climbs to the top and returns undefined rather than throwing.

Exercise 2 - a plain object's chain:

const obj = { x: 1 };
console.log(Object.getPrototypeOf(obj) === Object.prototype);  // true
console.log(Object.getPrototypeOf(Object.prototype) === null); // true
console.log(obj.toString()); // "[object Object]" - inherited from Object.prototype

The insight: the chain is obj -> Object.prototype -> null, and toString lives up on Object.prototype, which is exactly why every plain object seems to "just have" it.

Exercise 3 - bare versus normal object:

const normal = { a: 1 };
const bare = Object.create(null);
console.log(normal.hasOwnProperty("a")); // true
// bare.hasOwnProperty("a"); // TypeError: bare.hasOwnProperty is not a function

The insight: bare has no chain at all, so it never inherited hasOwnProperty; that same emptiness is what makes it safe as a dictionary -- no inherited keys can collide with your data.

Right, solutions behind us. Now we use the prototype system deliberately, as a tool, to assemble inheritance the pure JavaScript way.

Object.create in full

We have leaned on Object.create a handful of times already, but always in passing. Let's finally understand it properly, because it is the single most honest window into how inheritance actually works in this language. Object.create(proto) makes a brand new object whose prototype is proto. That is its entire job: create an object that is already linked to a prototype you choose. No constructor runs, no new is involved -- just one fresh object, pre-wired to an ancestor:

const animal = {
  eat()   { return `${this.name} is eating`; },
  sleep() { return `${this.name} is sleeping`; },
};

const rabbit = Object.create(animal); // rabbit's prototype IS animal
rabbit.name = "Bugs";

console.log(rabbit.eat());                              // "Bugs is eating"
console.log(rabbit.sleep());                            // "Bugs is sleeping"
console.log(Object.getPrototypeOf(rabbit) === animal);  // true
console.log(Object.hasOwn(rabbit, "eat"));              // false - inherited, not own

Look closely at what each object owns. rabbit has exactly one own property, name. It does not have its own copy of eat or sleep -- those are found on animal through the prototype link, and Object.hasOwn confirms eat is not rabbit's own. When you call rabbit.eat(), the engine fails to find eat on rabbit, hops one link up to animal, finds it there, and runs it with this bound to rabbit (the object before the dot, exactly as episode 22 taught us). That is why this.name reads "Bugs" and not some property of animal. The behaviour lives in one shared place; the data lives per object. This is inheritance stripped down to its barest, most inspectable form.

An init method for setup

Setting properties one at a time after creation (rabbit.name = ...) is tedious and easy to get wrong -- forget a line and your object is half-built. A common and very idiomatic pattern is to put an initializer method on the prototype, conventionally named init, whose job is to create the object's own data and then return the object so calls can be chained:

const animal = {
  init(name, sound) {
    this.name = name;   // creates an OWN property on the new object
    this.sound = sound; // ditto
    return this;        // return the object so we can chain
  },
  speak() {
    return `${this.name} says ${this.sound}`;
  },
};

const cat = Object.create(animal).init("Whiskers", "meow");
console.log(cat.speak());                 // "Whiskers says meow"
console.log(Object.hasOwn(cat, "name"));  // true - name is cat's OWN property
console.log(Object.hasOwn(cat, "speak")); // false - speak is inherited

Read that one line Object.create(animal).init("Whiskers", "meow") slowly, because it is the whole episode in miniature. Object.create(animal) makes a new object linked to animal. That new object immediately inherits init, so .init("Whiskers", "meow") runs -- with this bound to the new object -- and stamps name and sound onto it as own properties. Because init returns this, the result of the whole expression is that fully set-up object, which we store in cat.

Notice the clean split of duties: creation (linking to a prototype) and initialization (setting up own data) are two separate steps here, happening one after the other. Keep that split in mind, because it is precisely the two jobs that the new operator (next episode) and the class constructor (episode 38) will bundle together into one call for you. Seeing them apart, done by hand, is what will make those feel obvious rather than magical.

Multi-level inheritance

Here is where the pure-object approach shows its quiet power. Because a prototype is just an object, and objects can themselves be created from other objects, you can stack as many levels of inheritance as you like with no special syntax. A specific prototype inherits from a general one, and your instances inherit from the specific one:

const animal = {
  init(name) { this.name = name; return this; },
  describe() { return `${this.name} is a ${this.type}`; },
};

// dog is an animal, with extra behaviour of its own:
const dog = Object.create(animal);
dog.type = "dog";
dog.fetch = function () { return `${this.name} fetches the ball`; };

const rex = Object.create(dog).init("Rex");
console.log(rex.describe()); // "Rex is a dog" - describe from animal, type from dog
console.log(rex.fetch());    // "Rex fetches the ball" - fetch from dog
console.log(rex.name);       // "Rex" - rex's own property

The chain here is rex -> dog -> animal -> Object.prototype -> null, and every level pulls its weight. A lookup for fetch finds it one hop up on dog; a lookup for describe has to climb two hops to animal; type sits on dog; and name is rex's very own, set by init. The engine assembles the finished behaviour of rex by walking that chain, exactly as we studied last episode -- except now we built the chain on purpose, layer by layer.

There is a subtle and important detail in describe. It reads both this.name and this.type, yet those two properties live on completely different objects -- name on rex, type on dog. It works because this is always the object the method was called on (rex), not the object the method lives on (animal). So every this.something inside an inherited method triggers a fresh lookup starting from rex and climbing. This is the same layered assembly that class inheritance (episode 39) performs; classes will just give it a friendlier face.

Overriding, and calling the original

A more specific object can override an inherited method simply by defining its own property with the same name. This is shadowing, which we met in episode 34 -- the own property is found first at level 0, so the inherited one is never reached. But quite often you do not want to fully replace the parent's method; you want to extend it -- do what the parent did, and then a bit more. For that you call the prototype's version explicitly, keeping this pointed at the current object:

const base = {
  greet() { return "hello"; },
};

const formal = Object.create(base);
formal.greet = function () {
  const original = base.greet.call(this); // run the inherited version, this = formal
  return `${original}, pleased to meet you`;
};

console.log(base.greet());   // "hello"
console.log(formal.greet()); // "hello, pleased to meet you"

formal.greet shadows base.greet, but inside it we reach back to the original with base.greet.call(this). That .call(this) is the crucial bit (episode 23's explicit this-binding at work): it runs base's greet but with this still set to formal, so if greet had read this.name it would read formal's data, not base's. This "override, but call the parent first" pattern is exactly what the super keyword will do for you inside classes in episode 39. Here you get to see the honest mechanism it hides: manually find the prototype's method and invoke it with the correct this. Once you have written it by hand, super will never be a black box.

One word of caution, so you do not learn a bad habit. Writing base.greet.call(this) hard-codes the parent object by name, which is fine for a two-level example but brittle in a deep chain. The robust, general way to reach "the method one level up from where I am" is via the prototype, using Object.getPrototypeOf, which we will lean on when we generalise this. For now, naming the parent directly keeps the idea crisp.

The second argument: property descriptors

Object.create has a lesser-known second parameter: a map of property descriptors (straight from episode 32) that defines own properties at creation time, with full control over their flags. It is verbose, and you will not reach for it every day, but it beautifully demonstrates that Object.create can both link a prototype and define own properties in a single call:

const proto = { greet() { return `hi, ${this.name}`; } };

const user = Object.create(proto, {
  name: { value: "scipio", enumerable: true,  writable: true },
  id:   { value: 42,       enumerable: false, writable: false }, // hidden, locked own prop
});

console.log(user.greet());       // "hi, scipio"
console.log(Object.keys(user));  // ["name"] - id is non-enumerable, so it hides
console.log(user.id);            // 42 - still readable, just not enumerable
user.id = 99;                    // silently ignored (writable:false; throws in strict mode)
console.log(user.id);            // 42 - unchanged

Remember from episode 32 that a descriptor left to its defaults has every flag false, so id here is non-enumerable and non-writable -- it does not show up in Object.keys, and assignments to it are quietly dropped (or throw, under "use strict"). In everyday code you will almost always set own data with an init method or, later, a constructor, in stead of this descriptor form. But it is worth seeing once, because it makes concrete that "linking a prototype" and "defining own properties" are two independent jobs that Object.create happens to be able to do together.

A quick look sideways: how other languages spell "share behaviour"

A good few of you came into this series from the Learn Python Series (and some of you know Go or Rust), so the contrast is genuinely clarifying -- it shows that JavaScript's approach is one answer among several to a very universal need: let many values share one implementation without copying it.

In Python, you reach for a class, and the shared behaviour lives on that class. Structurally an instance-then-class lookup is the same "check here, not found, climb up" dance JavaScript does, but Python bakes the hierarchy into a formal class system rather than a chain of plain objects:

class Animal:
    def __init__(self, name):
        self.name = name           # per-instance data
    def describe(self):
        return f"{self.name} is a {self.type}"

class Dog(Animal):                 # Dog inherits from Animal
    type = "dog"

rex = Dog("Rex")
print(rex.describe())              # "Rex is a dog"

Notice how Python's __init__ is doing precisely the job of our init, and class Dog(Animal) is doing the job of our Object.create(animal) -- setting up the inheritance link. Same two ideas, different clothing. The philosophical difference is that Python gives you a privileged class/instance distinction computed up front (its MRO, method resolution order), whereas JavaScript hands you the raw, live chain of plain objects, which you can even re-wire at runtime.

Go and Rust take an entirely different road: neither has an inheritance chain at all. Go composes behaviour with struct embedding and interfaces; Rust resolves methods through traits and their implementations at compile time. So the same need gets three or four different answers, and JavaScript's answer -- the one you are wielding today -- is the inspectable, hand-buildable chain of objects. Understanding that it is a choice, not a quirk, makes the whole object model feel coherent rather than weird.

Why learn this before classes

You may be itching to reach the class keyword, and it is genuinely close now. But building inheritance by hand with Object.create first is a deliberate choice, and a valuable one. When you write your first class in episode 38, you will know, not merely be told, that it is not introducing some separate class-based type system underneath. It is setting up exactly the prototype links you just tied by hand.

Every part of class will map onto something you now understand from first principles: the methods you write go on a prototype (like animal's eat and speak); the constructor is an init that runs to set up own data and is bundled with creation; extends chains one prototype to another (like dog -> animal); and super calls the parent's method with the right this (like our base.greet.call(this)). That is the whole of class, and you have already built each piece with your own hands. That gap -- between using classes and understanding them -- is exactly why we took the long way round. ;-)

Try it yourself

  1. Create a prototype shape with an init(name) method (sets this.name, returns this) and an area() method that returns 0. Create a circle from it with Object.create, give circle its own area() that computes from a radius, then make an instance and set its radius. Confirm the circle's own area() runs while name still comes from shape's init.
  2. Build a two-level chain: a person with a greet() returning a plain greeting, and an employee created from person that overrides greet() but calls person.greet.call(this) inside and appends a job title. Make an employee instance and show that both parts of the greeting appear.
  3. Use Object.create with its second (descriptors) argument to make an object linked to a prototype, giving it one enumerable own property and one non-enumerable own property. Print Object.keys, print both property values directly, and explain in one sentence how this single call did two separate jobs.

So what did we actually cover?

  • Object.create(proto) makes a new object whose prototype is proto -- the most direct, honest way to set up prototypal inheritance, with no new and no class.
  • An init method on the prototype sets up an object's own data and returns this, letting you create-then-init in one chained line -- the very two jobs that new and class will later bundle for you.
  • Multi-level inheritance is natural: a specific prototype inherits from a general one, instances inherit from the specific one, and the chain assembles the finished behaviour top to bottom.
  • Override a method by shadowing it with an own property; extend the original by calling Parent.method.call(this), which is exactly what super will do.
  • Object.create's optional second argument defines own properties with full descriptor control at creation time, proving prototype-linking and property-defining are separate jobs.
  • Doing all of this by hand first means class will hold no mystery -- it is sugar over precisely these prototype links.

Next episode we take the older, function-based path to the very same result: constructor functions and the new operator, taking new apart step by step to see each thing it quietly does for you.

See you in the next episode, and happy coding.

scipio@scipio

Learn JS Series (#36) - Object.create and Pure Prototypal Inheritan... | Ecency