Understanding Prototypes in JavaScript

In practice, we often encounter situations where we need to take an existing object, and then extend it to create slightly different variants.

For example, you could have a user object.

javascript
1let user = {
2  firstName: "John",
3  lastName: "Doe",
4
5  get fullName() {
6    return `${this.firstName} ${this.lastName}`;
7  },
8
9  set fullName(value) {
10    [this.firstName, this.lastName] = value.split(" ");
11  },
12};

And then based on user, you could have admin, editor, and visitor, which are variants of user.

javascript
1let admin = {
2  firstName: "John",
3  lastName: "Doe",
4  role: "Admin",
5
6  get fullName() {
7    return `${this.firstName} ${this.lastName}`;
8  },
9
10  set fullName(value) {
11    [this.firstName, this.lastName] = value.split(" ");
12  },
13};
14
15let editor = {
16  firstName: "John",
17  lastName: "Doe",
18  role: "Editor",
19
20  . . .
21};
22
23let visitor = {
24  firstName: "John",
25  lastName: "Doe",
26  role: "Visitor",
27
28  . . .
29};
30

In this example, we hardcoded everything. But notice that there are many repetitions. And when you need to change the properties in the user object, you'll have to change the other objects in the exact same way. That is a lot more space for potential mistakes.

A better way to do this is to make admin, editor, and visitor extend on user.

Object.setPrototypeOf

Instead of creating the admin object directly, you should use the Object.setPrototypeOf() method, which is a built-in method used to extend an object based on a prototype. In this case, the prototype would be user.

javascript
1let user = {
2  firstName: "John",
3  lastName: "Doe",
4
5  get fullName() {
6    return `${this.firstName} ${this.lastName}`;
7  },
8
9  set fullName(value) {
10    [this.firstName, this.lastName] = value.split(" ");
11  },
12};
13
14let admin = {
15  role: "Admin",
16};
17
18Object.setPrototypeOf(admin, user);

The setPrototypeOf() function takes two arguments. In this case, it will set user to be admin's prototype, which allows you to do this:

javascript
1console.log(admin.firstName);
2console.log(admin.lastName);
3console.log(admin.fullName);
text
1John
2Doe
3John Doe

Notice that we successfully pulled the properties that belong to the user object out of admin. The admin object inherited everything from the user object.

This inheritance applies to methods as well. For example,

javascript
1let user = {
2  . . .
3
4  example() {
5    console.log("This is a method.");
6  },
7};
8
9let admin = {
10  role: "Admin",
11};
12
13Object.setPrototypeOf(admin, user);
14
15admin.example();
text
1This is a method.

This system can also have a nested structure, meaning you can then create another object based on the admin object.

javascript
1let user = {
2  . . .
3};
4
5let admin = {
6  role: "Admin",
7};
8
9Object.setPrototypeOf(admin, user);
10
11let profile = {
12  created: "2022-09-01",
13};
14
15Object.setPrototypeOf(profile, admin);
16
17console.log(profile.firstName);
18console.log(profile.lastName);
19console.log(profile.role);
20console.log(profile.created);
text
1John
2Doe
3Admin
42022-09-01

The profile object inherited everything from the admin, as well as the user object.

__proto__

Setting a prototype does not mean the properties in the prototype are copied to the new object. In fact, if you print the admin object, you will find that admin only has a role property.

javascript
1let user = {
2  . . .
3};
4
5let admin = {
6  role: "Admin",
7};
8
9Object.setPrototypeOf(admin, user);
10
11console.log(admin);
text
1{ role: 'Admin' }

Instead, what happens here is that there is a hidden __proto__ property in every object, and this property points to the prototype of the current object. What Object.setPrototypeOf(admin, user) does is simply setting admin.__proto__ to user.

javascript
1console.log(admin.__proto__);
text
1{
2  firstName: 'John',
3  lastName: 'Doe',
4  fullName: [Getter/Setter],
5  example: [Function: example]
6}

This way, when you change something in user, the changes will be automatically reflected in the admin object.

javascript
1let user = {
2  . . .
3};
4
5let admin = {
6  role: "Admin",
7};
8
9Object.setPrototypeOf(admin, user);
10console.log(admin.fullName);
11
12user.fullName = "Alice Cooper";
13console.log(admin.fullName);
text
1John Doe
2Alice Cooper

There are built-in corresponding __proto__ getters and setters. However, those are the historical ways of setting prototypes, and they are considered outdated nowadays.

Object.create

In the previous example, we defined an object first, and then set its prototype using the setPrototypeOf() function. There is, in fact, a shortcut that allows you to create an object and set its prototype at the same time.

javascript
1let user = {
2  firstName: "John",
3  lastName: "Doe",
4
5  get fullName() {
6    return `${this.firstName} ${this.lastName}`;
7  },
8
9  set fullName(value) {
10    [this.firstName, this.lastName] = value.split(" ");
11  },
12
13  example() {
14    console.log("This is a method.");
15  },
16};
17
18let admin = Object.create(user);
19
20admin.role = "Admin";
21
22console.log(admin.fullName);
23console.log(admin.role);

The create() method returns an object, whose prototype is set to user. You can then set extra properties for admin.

create() accepts an optional second argument that allows you to set the extra properties directly, but you are required to provide property descriptors. For example,

javascript
1let user = {
2  . . .
3};
4
5let admin = Object.create(user, {
6  role: {
7    value: "Admin",
8  },
9});
10
11console.log(admin.fullName);
12console.log(admin.role);
text
1John Doe
2Admin

Notice that the second argument looks like an object, but if you look closer, you'll find that it follows a certain format. Instead of just providing the extra properties like this:

javascript
1{
2  role: "Admin",
3}

A property descriptor is in the form of a nested object, which has the following syntax:

javascript
1{
2  <property>: {
3    value: <value>,
4  },
5}

Many people find this syntax unnecessarily complex, so you might be more comfortable adding the extra properties after creating the object like we did before.

javascript
1let admin = Object.create(user);
2
3admin.role = "Admin";

Object.getPrototypeOf

The getPrototypeOf() function retrieves the prototype of an object.

javascript
1let user = {. . .};
2
3let admin = Object.create(user, {. . .});
4
5console.log(Object.getPrototypeOf(admin));
text
1{
2  firstName: 'John',
3  lastName: 'Doe',
4  fullName: [Getter/Setter],
5  example: [Function: example]
6}

Something will still be returned even if the object does not have a prototype.

javascript
1let user = {};
2
3console.log(Object.getPrototypeOf(user));
text
1[Object: null prototype] {}

What's more interesting is that you can pull methods out of this empty object that does not have a prototype.

javascript
1let user = {};
2
3console.log(user.toString());
text
1[object Object]

So where did this toString() method come from?

The built-in prototypes

By default, when you create an object, it will be assigned the prototype Object.prototype, which provides some default methods such as toString().

There are other built-in prototypes in JavaScript. For example, when you create an array, it will be assigned Array.prototype.

javascript
1let arr = [1, 2, 3];
2
3console.log(Object.getPrototypeOf(arr) == Array.prototype);
text
1true

The Array.prototype provides the built-in methods and properties for arrays, which we've discussed before. And this Array.prototype prototype will extent to Object.prototype, which is the ancestor of prototypes.

javascript
1let arrProto = Array.prototype;
2
3console.log(Object.getPrototypeOf(arrProto) == Object.prototype);
text
1true

However, remember we can also use toString() on numbers and Boolean values? But these primitive data types are obviously not objects, so what is happening here?

When you try to access methods on primitives, JavaScript will create temporary object wrappers using String(), Number() and Boolean(). This process is done internally, and the temporary object wrappers will have their own prototypes, which eventually goes up to Object.prototype.

Don't use Object.setPrototypeOf

Even though we spend a large section of this article discussing how to set a prototype using setPrototypeOf(), but in practice, it is best not to use it. Setting a prototype after an object has been created is a lot slower than creating the object and assigning the prototype at the same time.

If performance is important to you, it is best to use Object.create() instead, especially when building a real-life application.

Prototype with constructor functions

Before wrapping up this lesson, there is one more problem we must tackle. As you know, objects can be created with constructor functions. So how can you create a constructor function that can return objects with prototypes?

Function object

To understand this question, we must first go back to the topic of functions. We know that a function is a piece of code as a value, but this definition doesn't tell us anything about the function's data type. Everything in JavaScript has a type, so what is the function's data type?

In JavaScript, functions are objects, and they have some built-in properties. For instance, the name property returns the name of the function.

javascript
1function example() {}
2
3console.log(example.name);
text
1example

And the length property returns the number of function arguments.

javascript
1function example(arg1, arg2, arg3) {}
2
3console.log(example.length);
text
13

You can also set custom properties for the function.

javascript
1function example() {}
2
3example.custom = "123";
4
5console.log(example.custom);
text
1123
So far, we have encountered four different variants of objects: functions, arrays, maps, and sets. However, if you try to use the typeof operator on these variants, you get a very confusing result.
javascript
1function func() {}
2const arr = [];
3const map = new Map();
4const set = new Set();
5
6console.log(typeof func);
7console.log(typeof arr);
8console.log(typeof map);
9console.log(typeof set);
text
1function
2object
3object
4object
The reason that JavaScript decide to "lie" about the function's data type is only known to the original designers of the language. You only need to remember that there are eight data types in JavaScript: numbers, BigInt, strings, Boolean values, null, undefined, symbols, and objects.

Constructor prototype

This is the key to creating a construction function that can create objects with prototypes. To do this, simply set the function's prototype property.

javascript
1function Admin() {
2  this.status = "Admin";
3}
4
5Admin.prototype = user;
6
7let admin = new Admin();
8
9console.log(admin);
10console.log(Object.getPrototypeOf(admin));
text
1{ status: 'Admin' }
2{ firstName: 'John', lastName: 'Doe' }

When the prototype property points to an object, that object will automatically become the prototype of the constructed object.

This is an old way of doing things in JavaScript. You probably would never use it. However, understanding prototypes helps you understand how classes work, which we are going to cover in the next lesson.