99

Concurrent visitors over the last 30 minutes. (~)

Peak concurrent visitors -
Yesterday: 0 / Today: 0

JS Class Core Patterns

JS Class Core Patterns

In terms of object-oriented programming, JavaScript is a prototype-based language, and the class syntax was added in ES2015.
However, JavaScript is still prototype-based, not class-based.
The class syntax is just syntactic sugar for prototypes.

In this post, let's understand the concept of prototypes in JavaScript and look at the core patterns of the class syntax.

# Understanding prototypes

Every object (instance) in JavaScript dynamically inherits properties and methods from a prototype object.
The data returned from a constructor function called with the new keyword is called an instance, and an instance can reference the members (properties and methods) of the constructor function's prototype object.

To distinguish constructor functions (classes) from regular functions, it is a convention to write constructor function names in PascalCase.
And a constructor function defined this way is called a class.

The basic structure of a constructor function (class) and its instances is as follows.

JS
1
2
3
4
5
6
7
8
9
10
11
12
13
function Class() { // constructor! } Class.prototype.method = function() { // ... } console.log(Class.prototype) // { constructor: f, method: f } const instanceA = new Class() instanceA.method() const instanceB = new Class() instanceB.method()

Let's look at the following constructor function example.
You can see that the greet method added to the prototype property of the User function is shared by each instance (the heropy and neo objects).
So comparing the greet method of each instance with the strict equality operator gives true.

The strict equality operator (===) compares whether the left and right operands have the same memory address.

JS
1
2
3
4
5
6
7
8
9
10
11
12
13
14
function User(name) { this.name = name } User.prototype.greet = function() { return `Hello, I'm ${this.name}.` } const heropy = new User('Heropy') console.log(heropy.greet()) // 'Hello, I'm Heropy.' const neo = new User('Neo') console.log(neo.greet()) // 'Hello, I'm Neo.' console.log(heropy.greet === neo.greet) // true

Each instance can check its reference to its prototype object through the __proto__ property, and the console shows it as [[Prototype]].

The __proto__ property is simply a non-standard property that browsers support.

JS
1
2
3
console.log(User.prototype === heropy.__proto__) // true console.log(heropy.__proto__) console.log(neo.__proto__)

The output in the console.

Unlike the example above, the greet methods of objects created each time with the literal style are not shared with each other.
So comparing the greet method of each object with the strict equality operator gives false.

JS
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function createUser(name) { return { name, greet() { return `Hello ${this.name}` } } } const heropy = createUser('Heropy') console.log(heropy.greet()) // 'Hello, I'm Heropy.' const neo = createUser('Neo') console.log(neo.greet()) // 'Hello, I'm Neo.' console.log(heropy.greet === neo.greet) // false

When we create data, we usually use the literal style, which is syntactically simpler.
But basic data creation is based on the constructor function style. Let's look at array data as a typical example.

Each array created with either the constructor function style or the literal style is an instance of the Array class.

JS
1
2
3
4
5
6
7
8
9
const numsA = new Array(1, 2, 3) // Constructor function style const numsB = [1, 2, 3] // Literal style console.log(numsA.__proto__ === Array.prototype) // true console.log(numsB.__proto__ === Array.prototype) // true console.log(numsA.__proto__ === numsB.__proto__) // true console.log(numsA) console.log(numsB)

The output in the console.

So, as follows, you can use the many built-in members already defined in Array.prototype.

JS
1
2
3
4
5
6
7
8
9
10
11
const numsA = new Array(1, 2, 3) // Constructor function style const numsB = [1, 2, 3] // Literal style console.log(Array.prototype.map) console.log(Array.prototype.filter) console.log(Array.prototype.reduce) // ... const doubles = numsA.map(v => v * 2) const even = numsB.filter(v => v % 2 === 0) const sum = numsA.reduce((acc, cur) => acc + cur, 0)

And you can check each member available on array data directly through Array.prototype.
Properties and methods that you can check through prototype like this are called prototype properties and prototype methods.

JS
1
console.log(Array.prototype)

The output.

If you check the MDN documentation, you can see the many prototype members related to arrays.

MDN Array

With prototypes, you can also add custom members that array data can use, as follows.

JS
1
2
3
4
5
6
7
8
Array.prototype.helloWorld = function () { console.log(this) // [1, 2, 3] return this.map(v => v / 2) } const nums = [1, 2, 3] console.log(nums.helloWorld()) // [0.5, 1, 1.5]

# Class syntax

The class syntax added in ES2015 is a syntactically simplified version of prototype-based constructor functions.
So only the way you write it differs, and it is the same as prototype-based code.

JS
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Class { constructor() { // constructor! } method() { // ... } } console.log(Class.prototype) // { constructor: f, method: f } const instanceA = new Class() instanceA.method() const instanceB = new Class() instanceB.method()

# constructor

constructor is a special method that initializes the instance object inside a class.
A class can have only one, and if you do not write it explicitly, an empty constructor is created implicitly.
So if there is nothing to initialize, you do not have to write a constructor.

JS
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class User { constructor(name) { this.name = name } greet() { return `Hello, I'm ${this.name}.` } } const heropy = new User('Heropy') console.log(heropy.greet()) // 'Hello, I'm Heropy.' const neo = new User('Neo') console.log(neo.greet()) // 'Hello, I'm Neo.' console.log(heropy.greet === neo.greet) // true

# Getters

With the get keyword, you can define a method that gets the value of a specific property or computes and returns a property.
A method defined this way is called a getter, and you can think of it as a computed property.
A getter is a function, but you use it like a property without calling it.

JS
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class Counter { constructor(initialValue = 1) { this.value = initialValue } get double() { return this.value * 2 } increase() { this.value += 1 } } const counter = new Counter(1) console.log(counter.value) // 1 console.log(counter.double) // 2 counter.increase() console.log(counter.value) // 2 console.log(counter.double) // 4

# Setters

With the set keyword, you can define a method that is called when you assign a value to a getter.
A method defined this way is called a setter.

In the following example, the double property (getter) computes and returns a value based on the value property.
So when you assign a value to double, you have to update the value of the underlying value property so that the getter's return value can change too.

JS
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
class Counter { constructor(initialValue = 1) { this.value = initialValue } get double() { return this.value * 2 } set double(newValue) { this.value = newValue / 2 } increase() { this.value += 1 } } const counter = new Counter(1) console.log(counter.value) // 1 console.log(counter.double) // 2 counter.increase() console.log(counter.value) // 2 console.log(counter.double) // 4 counter.double = 10 console.log(counter.value) // 5 console.log(counter.double) // 10

# Static methods

With the static keyword, you can define a method that you call without an instance of the class.
A method defined this way is called a static method.
It is mainly used to define utility functions used at the class level.

JS
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
class User { constructor(name, age) { this.name = name this.age = age } greet() { return `Hello, I'm ${this.name}.` } static isUser(value) { return !!(value && typeof value.name === 'string' && typeof value.age === 'number') } } const lewis = new User('Lewis', 92) console.log(User.isUser(lewis)) // true const neo = { name: 'Neo', age: 12 } console.log(User.isUser(neo)) // true const amy = { name: 'Amy' } console.log(User.isUser(amy)) // false

A typical example is the Array.isArray() method used with array data, which is also a static method.
It checks whether a value is array data, and you call it on the Array class itself, not on an Array instance (array data).

JS
1
2
3
4
5
const arr = [1, 2, 3] const obj = { a: 1 } console.log(Array.isArray(arr)) // true console.log(Array.isArray(obj)) // false

You can also write a static method on a prototype basis, as follows.

JS
1
2
3
4
5
6
7
8
9
10
function User(name, age) { this.name = name this.age = age } User.prototype.greet = function() { return `Hello, I'm ${this.name}.` } User.isUser = function(value) { return !!(value && typeof value.name === 'string' && typeof value.age === 'number') }

# Arrow function methods

You can define methods with arrow functions.
Note that arrow function methods are added as separate methods on each created instance, not on prototype.
In other words, arrow function methods are not prototype methods.

JS
1
2
3
4
class Class { regularFunctionMethod() {} arrowFunctionMethod = () => {} }

First, here is an example of a prototype method written as a regular function.
Because greet is a prototype method in each instance, you can see that they are strictly equal.

JS
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class User { constructor(name, age) { this.name = name this.age = age } greet() { return `Hello, I'm ${this.name}.` } } const neo = new User('Neo', 12) const evan = new User('Evan', 10) console.log(neo.greet()) // 'Hello, I'm Neo.' console.log(evan.greet()) // 'Hello, I'm Evan.' console.log(neo.greet === evan.greet) // true
Defining a prototype method

greet as a method of the prototype object

Then, if you change greet to an arrow function method as follows, greet is added as a separate method on each instance, and you can see that they are no longer equal.
Now greet is not a prototype method.

JS
1
2
3
4
5
6
7
8
9
class User { // ... greet = () => { return `Hello, I'm ${this.name}.` } } // ... console.log(neo.greet === evan.greet) // false
Defining an arrow function method

greet as a method of each instance

Also, unlike prototype methods that use regular functions, in arrow function methods the this keyword is defined statically at the place of declaration.
So you cannot change this with the .call(), .apply(), or .bind() methods.

For more about the difference in the this keyword between regular functions and arrow functions, see JS Function Core Patterns / this.

In the following example, the evan object borrows and calls the greet method of the neo object.
Here, because this in the prototype method greet is defined dynamically at the call site, it can reference the name property of the evan object.

JS
1
2
3
4
5
6
7
8
9
10
11
class User { // ... greet() { return `Hello, I'm ${this.name}.` } } const neo = new User('Neo', 12) const evan = { name: 'Evan' } console.log(neo.greet.call(evan)) // Hello, I'm Evan.
How this is defined in a prototype method

However, because this in an arrow function method is defined statically, even if you borrow and call the greet method, it only references the name property of the neo object.

JS
1
2
3
4
5
6
7
8
9
10
11
class User { // ... greet = () => { return `Hello, I'm ${this.name}.` } } const neo = new User('Neo', 12) const evan = { name: 'Evan' } console.log(neo.greet.call(evan)) // Hello, I'm Neo.
How this is defined in an arrow function method

Using prototype methods versus arrow function methods involves trade-offs in memory and performance.
It is best to use prototype methods by default and use arrow function methods when the situation calls for them.

# Inheritance

When you define a class, you can inherit from another class with the extends keyword, and you can call the constructor of the parent (super) class with the super function.
Through inheritance, a child (sub) class can use the properties and methods of its parent class.

You must call the super function before using the this keyword in the child class!

JS
1
2
3
4
5
6
class Class extends ParentClass { constructor() { super() // ... } }

In the following example, the Admin and Member classes can use the property (name) and method (getName) of the User class through inheritance.

JS
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
class User { constructor(name) { this.name = name } getName() { return this.name } } class Admin extends User { constructor(name) { super(name) this.admin = true } } class Member extends User { constructor(name) { super(name) this.admin = false } } const admin = new Admin('Neo') console.log(admin.getName()) // 'Neo' console.log(admin.admin) // true const member = new Member('Trinity') console.log(member.getName()) // 'Trinity' console.log(member.admin) // false

With the .prototype.isPrototypeOf() method, you can check whether the User class is the parent class of the Admin or Member class.

JS
1
2
3
4
5
// ParentClass.prototype.isPrototypeOf(ChildClass) console.log(User.prototype.isPrototypeOf(Admin.prototype)) // true console.log(User.prototype.isPrototypeOf(Member.prototype)) // true console.log(User.prototype === Admin.prototype) // false

You can also implement inheritance on a prototype basis, as follows.
This is called a prototype chain.

Object.create() creates a new prototype object that inherits from the prototype object passed as an argument.

JS
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
function User(name) { this.name = name } User.prototype.getName = function() { return this.name } function Admin(name) { User.call(this, name) this.admin = true } Admin.prototype = Object.create(User.prototype) Admin.prototype.constructor = Admin function Member(name) { User.call(this, name) this.admin = false } Member.prototype = Object.create(User.prototype) Member.prototype.constructor = Member const admin = new Admin('Neo') console.log(admin.getName()) // 'Neo' console.log(admin.admin) // true const member = new Member('Trinity') console.log(member.getName()) // 'Trinity' console.log(member.admin) // false

# Information hiding

Inside a class, you can use the # keyword to restrict specific properties or methods so that they cannot be accessed from outside the class. These are called private fields.
You define private fields in the class body.

JS
1
2
3
4
5
6
7
class Class { // Class body } class Class { #property = initialValue #method() {} }

In the following example, the #age property can be accessed only inside the class.
So even a child class cannot access it.

JS
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class User { #age = 0 constructor(name, age) { this.name = name this.#age = age } getAge() { return this.#age } } class Admin extends User { constructor(name, age) { super(name, age) console.log(this.#age) // Error: Private field '#age' must be declared in an enclosing class } } const neo = new User('Neo', 12) console.log(neo.name) // 'Neo' console.log(neo.getAge()) // 12 console.log(neo.#age) // Error: Private field '#age' must be declared in an enclosing class

# Anonymous classes

You can also omit the class name and define an anonymous class.
It is used in forms such as a module's default export, where the name is decided where it is used.

myClass.js
JS
1
2
3
4
5
export default class { constructor(name) { this.name = name } }
main.js
JS
1
2
3
4
import MyClass from './myClass.js' const neo = new MyClass('Neo') console.log(neo.name) // 'Neo'

# Checking instances

With the instanceof keyword, you can check which class an object is an instance of, including the inheritance chain of that class.
Or, you can check whether it is an instance of a specific class with the instance's .constructor property.

JS
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class User { constructor(name) { this.name = name } } class Admin extends User { constructor(name) { super(name) this.admin = true } } const neo = new Admin('Neo') console.log(neo instanceof Admin) // true console.log(neo instanceof User) // true console.log(neo instanceof Object) // true console.log(neo.constructor === Admin) // true console.log(neo.constructor === User) // false console.log(neo.constructor === Object) // false console.log(neo)

The output in the console.