The prototype

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.

Understanding what this [[Prototype]] property is capable of will give you great power over the language and will immediately make JavaScript less daunting.  It can be difficult to grasp but is worth it in the end.

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);
The internal [[Prototype]] property cannot be directly set, so we must use mechanisms such as Object.create and Object.setPrototypeOf. Note that you may have seen code that uses the non-standard __proto__ property to set [[Prototype]], but this is a legacy feature and should not be relied on.

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").

Hopefully, you are beginning to see how we could construct a hierarchy of inheritance using prototypes. The very simple mechanism of [[Prototype]] allows us to express complex hierarchical relations between problem domains expressed as objects. This is how OOP is achieved in JavaScript.

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]].

Fundamentally, the Class Definition Syntax (introduced in ECMAScript 2015), does not make anything possible that was not already possible in the language. It's just utilizing the existing prototypical mechanism. However, the newer syntax does make some things simpler, such as referring to a superclass with the super keyword.

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", ...]
Mutating native prototypes is an anti-pattern and should be avoided at all costs as it can create unexpected conflicts with other code in your code base. Since a runtime will only have a single set of native types available, when you modify them, you are modifying the capabilities of every single instance of that type that currently exists. Therefore, it is best to abide by a simple rule: only modify your own prototypes.

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.

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset
3.143.235.23