JavaScript is a prototypical language where inheritance is achieved via prototypes. This can be a daunting concept, but it is, in fact, beautifully simple. JavaScript's prototypal behavior can be described like this: every time a property is accessed on an object, if it is not available on the object itself, JavaScript will attempt to access it on an internally available property called [[Prototype]]. It will then repeat this process until it either finds the property or gets to the top of the prototype chain and returns undefined.
A [[Prototype]] object, which could feasibly be attached to any other object, is just a regular object itself. We could create one called engineerPrototype and have it contain data and methods related to the role of an engineer, for example:
const engineerPrototype = {
type: 'Engineer',
sayHello() {
return `Hello, I'm ${this.name} and I'm an ${this.type}`;
}
};
Then, we could attach this prototype to another object, thus making its properties available there as well. To do this, we use Object.create(), which creates a new object with a hardcoded [[Prototype]]:
const pandaTheEngineer = Object.create(engineerPrototype);
With this newly created pandaTheEngineer object, we are able to access any properties available on its [[Prototype]], such as engineerPrototype:
pandaTheEngineer.name = 'Panda';
pandaTheEngineer.sayHello(); // => "Hello, I'm Panda and I'm an Engineer"
We can illustrate that the objects are now linked by adding a new property to engineerPrototype and observe how it is made available on pandaTheEngineer:
pandaTheEngineer.sayGoodbye; // => TypeError: sayGoodbye is not a function
engineerPrototype.sayGoodbye = () => 'Goodbye!';
pandaTheEngineer.sayGoodbye(); // => 'Goodbye!'
As we mentioned previously, the [[Prototype]] of an object will only be used to resolve a property if it is not already available on the object itself. The following code shows how we can set our own sayHello method on our pandaTheEngineer object, and that by doing so we no longer have access to the sayHello method defined on [[Prototype]]:
pandaTheEngineer.sayHello = () => 'Yo!';
pandaTheEngineer.sayHello(); // => "Yo!"
However, deleting this newly added sayHello method would mean we once again have access to the [[Prototype]] sayHello method:
delete pandaTheEngineer.sayHello;
pandaTheEngineer.sayHello(); // => // => "Hello, I'm Panda and I'm an Engineer"
To understand what's happening and which properties are coming from which object, we are always able to inspect the [[Prototype]] of an object using Object.getPrototypeOf:
// We can inspect its prototype:
Object.getPrototypeOf(pandaTheEngineer) === engineerPrototype; // => true
Now, we can inspect its properties via Object.getOwnPropertyNames:
Object.getOwnPropertyNames(
Object.getPrototypeOf(pandaTheEngineer)
); // => ["type", "sayHello", "sayGoodbye"]
Here, we can see that the [[Prototype]] object (that is, engineerPrototype) is providing the type, sayHello, and sayGoodbye properties. If we inspect the pandaTheEngineer object itself, we can see that it only has a name property:
Object.getOwnPropertyNames(pandaTheEngineer); // => ["name"]
As we observed with our earlier addition of the sayGoodbye method, we can modify that prototype at any time and have our changes accessible to any objects that use that prototype. Here's another example of doing this:
// Modify the prototype object:
engineerPrototype.type = "Awesome Engineer";
// Call a method on our object (that uses the prototype):
pandaTheEngineer.sayHello(); // => "Hello, I'm Panda and I'm an Awesome Engineer"
Here, you can see how our inherited sayHello method is producing a string that includes our mutated type property (that is, "Awesome Engineer").
We could feasibly create another prototype that itself uses engineerPrototype, possibly fullStackEngineerPrototype, and it would work as expected, with each prototype defining another layer of property resolution.
Below the surface, JavaScript's newer Class Definition Syntax, which you may have grown accustomed to, relies on this underlying mechanism of prototypes as well. This can be observed here:
class Engineer {
type = 'Engineer'
constructor(name) {
this.name = name;
}
sayHello() {
return `Hello, I'm ${this.name} and I'm an ${this.type}`;
}
}
const pandaTheEngineer = new Engineer();
Object.getOwnPropertyNames(pandaTheEngineer); // => ["type", "name"]
Object.getOwnPropertyNames(
Object.getPrototypeOf(pandaTheEngineer)
); // => ["constructor", "sayHello"]
You'll notice that there are some subtle differences here. The most crucial one is that, when declaring classes, there is currently no way to define non-method properties on the prototype object. When we declare the type property, we are populating the instance itself so that when we inspect the properties of the instance, we get "type" and "name". However, the methods (such as sayHello) will exist on the [[Prototype]]. Another difference is that, of course, when using classes, we are able to declare a constructor, which itself is a method/property on the [[Prototype]].
Before class definitions existed, we typically wrote class-like abstractions by assigning our intended [[Prototype]] object to the prototype property of a function, as shown here:
function Engineer(name) {
this.name = name;
}
Engineer.prototype = {
type: 'Engineer',
sayHello() {
return `Hello, I'm ${this.name} and I'm an ${this.type}`;
}
};
When a function is instantiated via the new operator, JavaScript will implicitly create a new object with its [[Prototype]] set to the function's prototype property, if it has one. Let's try instantiating the Engineer function:
const pandaTheEngineer = new Engineer();
Inspecting this yields the same characteristics that we saw in our original Object.create approach:
Object.getOwnPropertyNames(pandaTheEngineer); // => ["name"]
Object.getOwnPropertyNames(
Object.getPrototypeOf(pandaTheEngineer)
); // => ["type", "sayHello"]
Broadly, all of these approaches are the same but have some subtle differences around where certain properties reside (that is, whether its properties are on the instance itself or on its [[Prototype]]). The newer Class Definition Syntax is useful and succinct and so is preferable nowadays, but it is nonetheless useful to have an underlying knowledge about how prototypes work as it drives the entirety of the language, including all of its native types. We can inspect these native types in the same manner as in the preceding code:
const array = ['wow', 'an', 'array'];
Object.getOwnPropertyNames(array); // => ["0", "1", "2", "length"]
Object.getOwnPropertyNames(
Object.getPrototypeOf(array)
); // => ["constructor", "concat", "find", "findIndex", "lastIndexOf", "pop", "push", ...]
If you ever catch yourself trying to modify a native prototype, it may be better if you created your own subclass of that type and added your functionality there:
class HeartArray extends Array {
join() {
return super.join(' ❤ ');
}
}
const yay = new HeartArray('this', 'is', 'lovely');
yay.join(); // => "this ❤ is ❤ lovely"
Here, we're creating our own Array subclass called HeartArray so that we can add our own specialized join method.