Unlike class-based languages, JavaScript uses prototypes for inheritance. The class syntax abstracts most of the prototypal inheritance model away, but I believe it’s important to understand how JavaScript really works. Let’s learn how to implement a “subclass” in JavaScript without using the class syntax or the extends
keyword.
The base “class”
The base “class,” Hero
, represents a character in a role-playing game (RPG). Every instance of the Hero
class has two of its own properties (as distinct from inherited properties): .name
and .level
.
function Hero(name, level) {
this.name = name;
this.level = level;
}
We can use the new
operator to invoke the Hero
function as a constructor. This invokes the Hero
function’s internal [[Construct]]
method. When invoked as a constructor, the value of the this
keyword inside the body of the Hero
function is the current instance under construction.
let hero = new Hero("Bjorn", 1);
console.log(hero instanceof Hero); // true
console.log(Object.getPrototypeOf(hero) === Hero.prototype); // true
let entries = Object.entries(hero);
console.log(entries); // [["name", "Bjorn"], ["level", 1]]
The result is an instance of the Hero
“class” with two of its own properties: .name
and .level
. The Hero
instance inherits from the object that is assigned to the Hero.prototype
property. Note that we haven’t added any instance methods yet.
The derived “class”
The derived “class,” Warrior
, represents a specific type of hero. In addition to the .name
and .level
properties, every instance of the Warrior
class has an extra own property: .weapon
.
When a function is invoked as a constructor, the value of the new.target
meta-property inside the function body is a reference to the constructor that the new
operator was called upon. To make sure the value is correct, we can use the static Reflect.construct()
method. This method is like the new
operator, except it’s a function. It also lets us specify a different prototype for new.target
.
function Warrior(name, level, weapon) {
let warrior = Reflect.construct(Hero, [name, level], Warrior);
warrior.weapon = weapon;
return warrior;
}
It may seem weird that we’re not using the this
keyword anywhere inside the body of the Warrior
function. However, it’s perfectly legal to return an object from a constructor. When the function is invoked with the new
operator, the returned object is used. Because we’re using the Warrior
“class” for the third parameter of the Reflect.construct()
method, the returned object is still considered an instance of the Warrior
“class.”
let warrior = new Warrior("Bjorn", 1, "axe");
console.log(warrior instanceof Warrior); // true
console.log(Object.getPrototypeOf(warrior) === Warrior.prototype); // true
Unfortunately, we still have work to do. If we check the prototypes of Warrior
and Warrior.prototype
, we’ll see that they are Function.prototype
and Object.prototype
, respectively.
Object.getPrototypeOf(Warrior) === Function.prototype; // true
Object.getPrototypeOf(Warrior.prototype) === Object.prototype; // true
For the Warrior
“class” to be a true “subclass” of the Hero
“class,” we need the prototype of Warrior
to be Hero
, and the prototype of Warrior.prototype
to be Hero.prototype
.
Linking the prototypes
To set the prototype of an object, we can use the static Object.setPrototypeOf()
method. Because of the way JavaScript engines optimize prototypes, this is unfortunately a very slow operation. This is one advantage of the class syntax: it doesn’t force you to set the prototypes dynamically.
Object.setPrototypeOf(Warrior, Hero);
Object.setPrototypeOf(Warrior.prototype, Hero.prototype);
Now the Warrior
“class” correctly inherits from the Hero
“class.” The first line makes sure the static properties and methods are inherited, while the second line makes sure the instance properties and methods are inherited.
Adding instance methods
To share properties and methods among all instances of a “class,” we can add them to the object that is assigned to the “class’” .prototype
property. Let’s add a .greet()
method to the Hero
“class” and an .attack()
method to the Warrior
“class.”
Hero.prototype.greet = function () {
return `${this.name} says hello.`;
};
Warrior.prototype.attack = function () {
return `${this.name} attacks with the ${this.weapon}.`;
};
Because we linked the prototypes, instances of the Warrior
“class” have access to the methods of Warrior.prototype
and the methods of Hero.prototype
.
let warrior = new Warrior("Bjorn", 1, "axe");
console.log(warrior.greet()); // "Bjorn says hello."
console.log(warrior.attack()); // "Bjorn attacks with the axe."
console.log(Object.getPrototypeOf(warrior) === Warrior.prototype); // true
console.log(Object.getPrototypeOf(Warrior) === Hero); // true
The prototype chain
The Warrior
“class”
The following series of Object.getPrototypeOf()
calls shows what the prototype chain of the derived Warrior
“class” looks like.
Object.getPrototypeOf(Warrior); // Hero
Object.getPrototypeOf(Hero); // Function.prototype
Object.getPrototypeOf(Function.prototype); // Object.prototype
Object.getPrototypeOf(Object.prototype); // null
A Warrior
instance
The following series of Object.getPrototypeOf()
calls shows what the prototype chain of an instance of the derived Warrior
“class” looks like.
Object.getPrototypeOf(new Warrior()); // Warrior.prototype
Object.getPrototypeOf(Warrior.prototype); // Hero.prototype
Object.getPrototypeOf(Hero.prototype); // Object.prototype
Object.getPrototypeOf(Object.prototype); // null
Complete example
function Hero(name, level) {
this.name = name;
this.level = level;
}
function Warrior(name, level, weapon) {
let warrior = Reflect.construct(Hero, [name, level], Warrior);
warrior.weapon = weapon;
return warrior;
}
Object.setPrototypeOf(Warrior, Hero);
Object.setPrototypeOf(Warrior.prototype, Hero.prototype);
Hero.prototype.greet = function () {
return `${this.name} says hello.`;
};
Warrior.prototype.attack = function () {
return `${this.name} attacks with the ${this.weapon}.`;
};
Equivalent classes
class Hero {
constructor(name, level) {
this.name = name;
this.level = level;
}
greet() {
return `${this.name} says hello.`;
}
}
class Warrior extends Hero {
constructor(name, level, weapon) {
super(name, level);
this.weapon = weapon;
}
attack() {
return `${this.name} attacks with the ${this.weapon}.`;
}
}
Summary
- To make a derived “class” inherit from a base “class,” you can use the static
Reflect.construct()
method. - You also need to link the prototypes with the static
Object.setPrototypeOf()
method. - The class syntax is easier to read, more performant, and less error-prone. However, it’s still useful to understand how JavaScript really works.