Classes give you a way to create instances of objects with common functionality. You create them using the class
keyword. Here is an example of a class:
class Person {
constructor(name) {
this.name = name
}
sayName() {
console.log("My name is", this.name)
}
static askName() {
console.log("What is your name?")
}
}
const paul = new Person("Paul")
Person.askName() // What is your name?
paul.sayName() // My name is Paul
Under the hood, classes mainly use concepts covered in the previous chapters. In fact, almost all the functionality classes provide can be implemented without them1. This was done for many years before classes were introduced in 2015. Nevertheless, classes make development a lot easier.
The first thing you need to know about classes is that they are constructors. To clarify, the class itself is a constructor. Meaning a class is a function with both a [[Call]] and [[Construct]] internal method. However, if you call a class using [[Call]], it will throw a TypeError
. This means there is practically only one way to call classes, with [[Construct]].
new MyClass() // Internally calling [[Construct]], OK
MyClass() // Internally calling [[Call]], throws TypeError
A class method called constructor
will be considered special to classes. To understand it, it helps to remember what a class is. It’s a function. A function, of course, has code inside it. Turns out, the code inside a class comes from its constructor
method. It sounds strange but bear with me.
Let’s create a class and a traditional function that do the same when called with new
.
class MyClass {
constructor() {
console.log("Hello!")
}
}
function MyFunc() {
console.log("Hello!")
}
new MyClass() // Hello!
new MyFunc() // Hello!
For the class, isn’t it a bit strange that we executed the console.log
code by calling new MyClass()
and didn’t have to call new MyClass.constructor()
? After all, the console.log
code exists inside a method inside the class. But this works because the code of the constructor
method becomes the code of the class function itself.
⚠️ A class definition doesn’t actually produce a traditional function definition like shown above. Both definitions create their own internal function object. However, it’s safe to assume, for now, that they produce an identical function object. Meaning, you can use the above figure as a helpful mental model.
💡 It’s not required to define a
constructor
method. If you don’t, it’s the equivalent of defining an empty one (constructor(){}
).This doesn’t apply to derived classes which will be covered later.
Certain properties will be own properties of objects created with classes. Meaning, the properties will exist on the instance. Not in its prototype chain or anywhere else.
Classes have two ways to give their instances own properties:
- Instance properties
- Field declarations
Instance properties are defined inside the special constructor
class method we covered above. You define them by assigning properties to this
.
class Person {
constructor(name) {
this.name = name
}
}
const paul = new Person("Paul")
console.log(paul) // { name: "Paul" }
Above, we have a class called Person
that has a constructor
method that assigns a value to this.name
. When we invoke [[Construct]] on the class (using new
), we get an object with our instance property, name
.
We can mimic this with a traditional function:
function Person(name) {
this.name = name
}
const paul = new Person("Paul")
console.log(paul) // { name: "Paul" }
Field declarations are another way to define own properties on class instances. They are defined in the body of a class (outside any method).
class Cat {
livesLeft = 9
}
const garfield = new Cat()
console.log(garfield) // { livesLeft: 9 }
💡 This covers non-static methods.
Defining a method on a class will actually define it on the class's .prototype
property. Remember, a class is a constructor, and the .prototype
property is special to constructors. The prototype of instances created with a constructor will come from the constructors .prototype
property. To illustrate this, let’s create two instances from a class with a sayName
method.
class Person {
constructor(name) {
this.name = name
}
sayName() {
console.log("My name is", this.name)
}
}
const paul = new Person("Paul")
const owen = new Person("Owen")
paul.sayName() // My name is Paul
owen.sayName() // My name is Owen
At first glance, it seems like paul
and owen
each have their own sayName
method. But in reality, they both share the exact same method.
console.log(paul.sayName === owen.sayName) // true
This is because they both inherited the method from their prototype. Since paul
and owen
were created as instances of a constructor, their prototype comes from the constructor’s .prototype
property. In this case, the constructor is Person
.
const paulProto = Reflect.getPrototypeOf(paul)
const owenProto = Reflect.getPrototypeOf(owen)
console.log(paulProto === Person.prototype) // true
console.log(owenProto === Person.prototype) // true
Below is an illustration of how paul
and owen
access the sayName
method.
Static members, that is, static field declarations and methods, exist as own properties of the class itself. They do not appear in the prototype chain of instances, and they can only be accessed directly on the class.
class Person {
static totalPeople = 0
static incrementPeople() {
Person.totalPeople += 1
}
}
Person.incrementPeople()
console.log(Person.totalPeople) // 1
Above, we have a class with two static members, totalPeople
and incrementPeople
. We access these members directly on Person
.
Classes can inherit functionality from other classes using the extends
keyword. A class that “extends” another class is called a derived class. Setting up this inheritance will add another link in the prototype chain of class instances. Specifically, the object stored on the .prototype
property of a derived class will have a prototype pointing to the .prototype
property of the parent class.
To explore this, let’s create a derived class called Cat
that derives from Animal
. We can then create an instance called cat
and examine its prototype chain.
class Animal {}
class Cat extends Animal {}
const cat = new Cat()
As we can see, the prototype of Cat.prototype
is Animal.prototype
. This means that properties defined on Animal.prototype
are available to our instance cat
through prototype chain inheritance. We can see this by defining a sayHi
method on Animal
.
class Animal {
sayHi() {
console.log("Hi! I'm an animal")
}
}
class Cat extends Animal {}
const cat = new Cat()
cat.sayHi() // Hi! I'm an animal
Above, we can see that sayHi
is available to our cat
instance. It’s inherited from Animal.prototype
.
console.log(cat.sayHi === Animal.prototype.sayHi) // true
The super
keyword allows a derived class to interact with its parent.
Calling super
inside the constructor
method of a derived class calls [[Construct]] on the parent class. When this happens, the this
inside both functions is the same object. This can be seen in the below demonstration.
let this1
let this2
class Parent {
constructor() {
this1 = this
}
}
class Derived extends Parent {
constructor() {
super()
this2 = this
}
}
new Derived()
console.log(this1 === this2) // true
This means that any assignment to this
inside the parent class' constructor
method also happens to this
inside the derived class’ constructor
method. We can see this below where we define a message on this.hello
inside the parent class Parent
. The message is then available on the this
inside Derived
after we call super
.
class Parent {
constructor() {
this.hello = "Hello from Parent"
}
}
class Derived extends Parent {
constructor() {
super()
console.log(this.hello) // Hello from Parent
}
}
new Derived()
⚠️ Any use ofthis
before callingsuper
inside a derived class will result in aReferenceError
.
The arguments to a super
call become the arguments used when [[Construct]] is called on the parent class.
class Calculator {
constructor(num1, num2) {
this.result = num1 + num2
}
}
class Derived extends Calculator {
constructor() {
super(2, 6)
console.log(this.result) // 8
}
}
new Derived()
💡 If you don’t define a
constructor
method inside a derived class, it’s the equivalent of defining one that callssuper
with all its own arguments (constructor(...args) { super(...args) }
).
Accessing a property on super
will go up the prototype chain, starting with the classes parent. This makes it useful if a method with the same name is defined in both a derived and parent class. We can see this below where we access method
on both this
and super
with different results.
class Parent {
method() {
console.log("You accessed Parent")
}
}
class Derived extends Parent {
constructor() {
super()
this.method() // You accessed Derived
super.method() // You accessed Parent
}
method() {
console.log("You accessed Derived")
}
}
new Derived()
Private members are exclusive to classes. They allow you to define properties and methods which are inaccessible outside their class. A private class member is prefixed with a hashtag (#
).
💡 Private members won’t become part of the class's prototype chain inheritance. Meaning, they can’t be inherited by derived classes.
class Person {
#age = 0
constructor(age) {
this.#age = age
}
}
const person = new Person(30);
person.#age // syntaxError
As we can see, #age
is not accessible outside the class.
💡 Private properties have to be declared as fields. Meaning, they have to be declared in the body of the class (outside any method). This is unlike public properties, which can be declared directly on
this
without first being declared as a field.
Like private fields, it’s also possible to define private methods.
class Person {
#age = 0
constructor(age) {
this.#age = age
}
#canVote() {
return this.#age >= 18
}
}
const person = new Person(30)
person.#canVote() // syntaxError
It’s also possible to define private static members. These fields and methods, while static, are only accessible inside the scope of the class they are defined within.
class Person {
constructor() {
Person.#incrementPeople()
}
static #totalPeople = 0
static #incrementPeople() {
Person.#totalPeople += 1
}
}
Person.#incrementPeople() // SyntaxError
Person.#totalPeople // SyntaxError
Footnotes
-
.Almost is an important keyword here. This great StackOverflow answer provides an in-depth explanation regarding whether classes are just syntactic sugar https://stackoverflow.com/a/54861781. The answer, no. ↩