|

Understanding Classes in JavaScript

Understanding Classes in JavaScript Photo by Ferenc Almasi on Unsplash

Introduction

JavaScript is a prototype-based language, and every object in JavaScript has a hidden internal property called [[Prototype]] that can be used to extend properties and methods of the object.

Until recently, developers used constructor methods to mimic an object-oriented design pattern in JavaScript. The ECMAScript 2015 specification, often referred to as ES6, introduced classes to the JavaScript language. Classes in JavaScript do not offer additional functionality; they are often described as providing “syntactic sugar” for prototypes and inheritance, offering a cleaner and more elegant syntax. As other programming languages use classes, the class syntax in JavaScript makes it easier for developers to transition between languages.

Classes Are Functions

A JavaScript class is a type of function. Classes are declared with the class keyword. We will use the function syntax to initialize a function and the class syntax to initialize a class.

// Initialize a function with a function expression
const x = function() {}
// Initialize a class with a class expression
const y = class {}

We can access the [[Prototype]] of an object using the Object.getPrototypeOf() method. Now, we will use this to test the empty function we just created.

Object.getPrototypeOf(x);
Output
ƒ () { [native code] }

We can also use this method on the class we just created.

Object.getPrototypeOf(y);
Output
ƒ () { [native code] }

Both segments declared with function and class return a [[Prototype]] function. With prototypes, any function can become a constructor instance using the new keyword.

const x = function() {}
// Initialize a constructor from a function
const functionConstructor = new x();
console.log(functionConstructor);
Output
x {}
constructor: ƒ ()

This applies to classes as well.

const y = class {}
// Initialize a constructor from a class
const classConstructor = new y();
console.log(classConstructor);
Output
y {}
constructor: class

These prototype constructor examples are empty, but we can see that behind the syntax, both methods achieve the same end result.

Defining a Class

A constructor function is initialized with various parameters that would be assigned as properties of this, referring to the function itself. The first letter of the identifier would be capitalized by convention.

// Initialize a constructor function
function Drink(type, amount) {
    this.type = type;
    this.amount = amount;
}

When we translate this to class syntax, shown below, we see that it is structured very similarly.

// Initialize a class definition
class Drink {
    constructor(type, amount) {
        this.type = type;
        this.amount = amount;
    }
}

We know that a constructor function intends to be an object model by the uppercase of the first letter of the initializer (which is optional) and through familiarity with the syntax. The class keyword communicates the purpose of our function more directly. The only difference in initialization syntax is the use of the class keyword instead of function and the assignment of properties within a constructor() method.

Defining Methods

The common practice in constructor functions is to assign methods directly to the prototype, rather than in initialization, as seen in the confirmDrink() method below.

function Drink(type, amount) {
    this.type = type;
    this.amount = amount;
} 
// Add a method to the constructor
Drink.prototype.confirmDrink = function() {
    return `The order is ${this.amount} of ${this.name}.`;
}

With classes, the syntax is streamlined, and the method can be added directly to the class. Using the method definition introduced in ES6, defining a method is an even more concise process.

class Drink {
    constructor(type, amount) { 
        this.type = type;
        this.amount = amount; 
    }
    // Add a method to the constructor
    confirmDrink() {
        return `The order is ${this.amount}ml of ${this.name}.`;
    }
}

Let’s take a look at these properties and methods in action. We will create a new instance of Drink using the new keyword and assign some values to it.

const drink1 = new Drink('Coke', 200);

If we log more information about our new object with console.log(drink1), we can see more details about what is happening with the class initialization.

Output
Drink {type: "Coke", amount: 200}
__proto__:
constructor: class Drink 
confirmDrink: ƒ confirmDrink()

We can see in the output that the constructor() and confirmDrink() functions were applied to the __proto__, or [[Prototype]], of drink1, and not directly as a method of the drink1 object. While this is clear when creating constructor functions, it is not obvious when creating classes. Classes provide a simpler and more succinct syntax but sacrifice some clarity in the process.

Extending a Class

An advantageous feature of constructor functions and classes is that they can be extended into new object models based on the parent class. This avoids code repetition for objects that are similar but need additional or more specific features. New constructor functions can be created from the parent using the call() method. In the example below, we will create a more specific drink class called Coffee and assign the properties of Drink to it using call(), in addition to adding an additional property.

// Create a new constructor function from the parent
function Coffee(type, amount, flavor) {
    // Use the constructor with call
    Drink.call(this, type, amount); this.flavor = flavor;
} 

At this point, we can create a new instance of Coffee using the same properties as Drink, as well as a new one we added.

const drink2 = new Coffee('Espresso', 300, 'Iced Matcha');

When showing drink2 in the console, we can see that we created a new Coffee based on the constructor.

Output
Coffee {type: "Espresso", amount: 300, flavor: "Iced Matcha"}
__proto__:
constructor: ƒ Coffee(type, amount, flavor)

With ES6 classes, the super keyword is used instead of call to access parent functions. We will use extends to refer to the parent class.

// Creating a new class from the parent
class Coffee extends Drink {
    constructor(type, amount, flavor) {
        // Chain the constructor with super
        super(type, amount);
        // Add a new property
        this.flavor = flavor;
    }
}

Now, we can create a new instance of Coffee in the same way.

const drink2 = new Coffee('Espresso', 300, 'Iced Matcha');

Let’s show drink2 in the console and see what appears:

Output Coffee {type: "Coke", amount: 300, flavor: "Iced Matcha"}
__proto__: Drink 
constructor: class Coffee

The output is almost exactly the same, except that in the class construction, the [[Prototype]] is linked to the parent, in this case, Drink. Below is a side-by-side comparison of the entire process of initialization, adding methods, and inheriting from a constructor function and a class.

function Drink(type, amount) {
    this.type = type;
    this.amount = amount;
}
// Add a method to the constructor function
Drink.prototype.confirmDrink = function() {
    return `The order is ${this.amount}ml of ${this.name}.`;
}
// Create a new constructor function from the parent
function Coffee(type, amount, flavor) {
    // Use the constructor with call
    Drink.call(this, type, amount);
    this.flavor = flavor;
}
// Initialize a class
class Drink {
    constructor(type, amount) { 
        this.type = type;
        this.amount = amount;
    }
    // Add a method to the constructor
    confirmDrink() {
        return `The order is ${this.amount}ml of ${this.name}.`;
    }
}
// Create a new class from the parent
class Coffee extends Drink {
    constructor(type, amount, flavor) {
        // Chain the constructor with super
        super(type, amount);
        // Add a new property
        this.flavor = flavor;
    }
}

Although the syntax is quite different, the result is fundamentally the same with both methods. Classes offer us a more concise way to create object models, and constructor functions describe more accurately what is happening behind the scenes.

Conclusion

In this article, we learned about the similarities and differences between JavaScript constructor functions and ES6 classes. Both classes and constructors mimic an object-oriented inheritance model for JavaScript, which is a prototype-based inheritance language. Understanding prototype inheritance is crucial to being an effective JavaScript developer. Being familiar with classes is extremely helpful, as some popular JavaScript libraries frequently make use of class syntax. This article was based on and adapted to Portuguese and then to English from the Understanding Classes in JavaScript tutorial published by Digital Ocean.