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.
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
.
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
.
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:
1console.log(admin.firstName);
2console.log(admin.lastName);
3console.log(admin.fullName);
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,
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();
1This is a method.
This system can also have a nested structure, meaning you can then create another object based on the admin
object.
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);
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.
1let user = {
2 . . .
3};
4
5let admin = {
6 role: "Admin",
7};
8
9Object.setPrototypeOf(admin, user);
10
11console.log(admin);
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
.
1console.log(admin.__proto__);
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.
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);
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.
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,
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);
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:
1{
2 role: "Admin",
3}
A property descriptor is in the form of a nested object, which has the following syntax:
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.
1let admin = Object.create(user);
2
3admin.role = "Admin";
Object.getPrototypeOf
The getPrototypeOf()
function retrieves the prototype of an object.
1let user = {. . .};
2
3let admin = Object.create(user, {. . .});
4
5console.log(Object.getPrototypeOf(admin));
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.
1let user = {};
2
3console.log(Object.getPrototypeOf(user));
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.
1let user = {};
2
3console.log(user.toString());
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
.
1let arr = [1, 2, 3];
2
3console.log(Object.getPrototypeOf(arr) == Array.prototype);
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.
1let arrProto = Array.prototype;
2
3console.log(Object.getPrototypeOf(arrProto) == Object.prototype);
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.
1function example() {}
2
3console.log(example.name);
1example
And the length
property returns the number of function arguments.
1function example(arg1, arg2, arg3) {}
2
3console.log(example.length);
13
You can also set custom properties for the function.
1function example() {}
2
3example.custom = "123";
4
5console.log(example.custom);
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.
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);
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.
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));
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.