DEV Community

Cover image for A deep dive into ES6 Classes
Mustapha Aouas
Mustapha Aouas

Posted on

A deep dive into ES6 Classes

Classes were introduced in ECMAScript 6, and we can use them to structure our code in a traditional OOP fashion by defining a template for creating objects.
In this post we'll learn everything about ES6 classes, then we will compare them to constructor functions and prototypal inheritance.

A quick word before we start. This article is intended to appeal to a wide range of readers. So, if you're an advanced JS user, you can use the table of contents below to select which sections to read. If, on the other hand, you're just getting started with JS and you're having trouble understanding something, feel free to ask me in the comments section.

Table of Contents

We will see how to define classes and how to create objects using them, then we will talk about inheritance and more - But first, let's start right away by taking a look at the anatomy of a class.

Anatomy of a class

The class keyword

To declare a class we use the class keyword followed by the name of the class.

Class declaration

class Point {
  constructor() {}
}
Enter fullscreen mode Exit fullscreen mode

In the snippet above we declared a "Point" class. This is called a class declaration.

Note that I'm using the PascalCase notation for the name of the class. This is not mandatory but a common convention.

In fact classes are special functions, and like with functions, you can use either class declarations or class expressions.

Class expression

This is a class expression:

let Point = class {
  constructor() {}
}
Enter fullscreen mode Exit fullscreen mode

Constructor

The constructor method is a special method for creating and initialising an object created with a class.

There can only be one constructor in each class. A SyntaxError will be thrown if the class contains more than one occurrence of a constructor.

It is not mandatory to have a constructor in the class definition. The code bellow is valid.

class Point { }
Enter fullscreen mode Exit fullscreen mode

Properties

Instance properties

Instance properties must be defined inside of class methods. In the snippet below x and y are instance properties:

class Point {
  constructor(a, b) {
    this.x = a;
    this.y = b;
  }
}
Enter fullscreen mode Exit fullscreen mode

Fields

The code can be more self documenting by declaring fields up-front. Let's refactor the code above using fields, and while we're at it, let's give them a default value:

class Point {
  x = 0;
  y = 0;

  constructor(a, b) {
    this.x = a;
    this.y = b;
  }
}
Enter fullscreen mode Exit fullscreen mode

Note that fields are always present whereas instance properties must be defined inside of class methods.
Note also that fields can be declared with or without a default value.

Private fields

To declare a private field all you have to do is prefix its name with #. See the code below:

class Point {
  #x = 0;
  #y = 0;

  constructor(a, b) {
    this.#x = a;
    this.#y = b;
  }
}
Enter fullscreen mode Exit fullscreen mode

Trying to access a private field outside the scope of the class will result in a syntax error.

Note that instance properties can not be private, only fields can. So you can't create an instance property with the # prefix. This would result in a syntax error.

Methods

Public methods

To declare a method we can use the ES6 shorter syntax for method definitions on objects:

class Point {
  #x = 0;
  #y = 0;

  translate(a, b) {
    this.#x += a;
    this.#y += b;
  }
}
Enter fullscreen mode Exit fullscreen mode

Private methods

Like we did with private fields, we can use a # as a prefix of our private methods:

class Point {
  #x = 0;
  #y = 0;

  constructor(x, y) {
    this.#setXY(x, y)
  }

  translate(a, b) {
    this.#setXY(
      this.#x + a,
      this.#y + b);
  }

  // Private method
  #setXY(x, y) {
    this.#x = x;
    this.#y = y;
  }
}
Enter fullscreen mode Exit fullscreen mode

Generator methods

The same way as public methods we can declare generator methods:

class Point {
  #x = 0;
  #y = 0;
  #historyPositions = [];

  translate(a, b) {
    this.#x += a;
    this.#y += b;

    this.#historyPositions.unshift(
      [this.#x, this.#y]
    );
  }

  *getHistoryPositions() {
    for(const position of this.#historyPositions){
      yield position;
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

In the snippet above we declared a getHistoryPositions generator method.

Note: to declare a private generator method use this syntax: *#getHistoryPositions() {}.

Getters and Setters

To implement getters and setters we use the get and set keyword:

Here is an example:

class Point {
  #x = 0;
  #y = 0;

  get position() {
    return [this.#x, this.#y];
  }

  set position(newPosition) {
    // newPosition is an array like [0, 0]
    [this.#x, this.#y] = newPosition;
  }
}
Enter fullscreen mode Exit fullscreen mode

Static fields and methods

Static methods and fields (of a class) can be defined using the static keyword. Static members (fields and methods) cannot be called through a class instance and must be called without instantiating the class.

Static methods are frequently used to construct utility functions, whereas static properties are excellent for caching, fixed-configuration, or any other data that does not need to be copied across instances.

Here is an example of a static method:

class Point {
  static isEqual(pointA, pointB) {
    const [x1, y1] = pointA.position;
    const [x2, y2] = pointB.position;
    return x1 === x2 && y1 === y2;
  }

  #x = 0;
  #y = 0;

  get position() {
    return [this.#x, this.#y];
  }

  constructor(a, b) {
    [this.#x, this.#y] = [a, b];
  }
}

// Consider that p1 and p2 are both instances of Point
Point.isEqual(p1, p2) // Boolean
Enter fullscreen mode Exit fullscreen mode

Creating an object with a class

The new keyword

To create a new instance of a class we use the new keyword:

class Point {}

const point = new Point();
Enter fullscreen mode Exit fullscreen mode

Hoisting

Function declarations and class declarations can be distinguished by the fact that function declarations are hoisted whereas class declarations are not. You must first define and then access your class; otherwise, code like this will throw a ReferenceError:

const point = new Point(); // ReferenceError

class Point {}
Enter fullscreen mode Exit fullscreen mode

Inheritance

The extends keyword

In class declarations or class expressions, the extends keyword is used to create a class that is a child of another class (a subclass).
We'll look at an example in the next section.

Super

The super keyword is used to access and call functions on an object's parent.
If there is a constructor present in the subclass, it needs to first call super() before using this.

See the code below:

class Vehicle {
  #numberOfPassengers = 0;

  constructor(nb) {
    this.#numberOfPassengers = nb;
  }

  getNumberOfPassengers() {
    return this.#numberOfPassengers;
  }
}

class Car extends Vehicle {
  constructor() {
    super(5);
  }
}

class Bike extends Vehicle {
  constructor() {
    super(1);
  }
}

const car = new Car();
const bike = new Bike();

car.getNumberOfPassengers(); // 5
bike.getNumberOfPassengers(); // 1
Enter fullscreen mode Exit fullscreen mode

Metadata

In class constructors, new.target refers to the constructor that was called directly by new. This is also true if the constructor belongs to a parent class and was delegated from a child constructor.

class Vehicle {
  constructor() {
    console.log(new.target.name);
  }
}

class Car extends Vehicle {
  constructor() {
    super();
  }
}

new Vehicle(); // Vehicle
new Car(); // Car
Enter fullscreen mode Exit fullscreen mode

Consider the following use case: If we want the Vehicle class to be abstract, we can throw an error if (new.target.name === 'Vehicle') is true. However, you've to keep in mind that if you use this in production and build your project with bundlers, the names of your classes may be prefixed, causing the condition to always be false.

Comparison with Constructor functions

Before there were classes, constructor functions and prototypes were the default. I won't go too deep in this section, but i wanted to show you how we could achieve pretty much the same with constructor functions and prototypes since ES6 classes use prototypes behind the hood.

Properties and methods

Let's start by setting some properties and methods:

function Point(x, y) {
  this.x = x;
  this.y = y;

  this.translate = function(a, b) {
    this.x += a;
    this.y += b;
  }
}

const point = new Point(4, 5);
point.translate(2, 2);
point.x; // 6
point.y; // 7
Enter fullscreen mode Exit fullscreen mode

Getters and Setters

To implement setters and getters we have to use Object.defineProperty or Object.defineProperties:

function Point(x, y) {
  this.x = x;
  this.y = y;

  Object.defineProperty(this, 'position', {
    set: function([x, y]) {
      [this.x, this.y] = [x, y];
    },
    get: function() {
      return [this.x, this.y];
    },
  });
}

const point = new Point();
point.position = [4, 5];
point.position; // [4, 5]
Enter fullscreen mode Exit fullscreen mode

Basically, I used Object.defineProperty to set/change the property descriptor of the position property. To learn more about property descriptors, you can check this article:

Prototypal inheritance

Here's an example of prototypal inheritance:

function Vehicle(numberOfPassengers) {
  this.numberOfPassengers = numberOfPassengers;

  this.getNumberOfPassengers = function() {
    return this.numberOfPassengers;
  }
}

function Car() {
  Vehicle.call(this, 5); // The same way we used super for classes, here we call the Vehicle constructor in this context (Car context) 
}

Car.prototype = Object.create(Vehicle.prototype); // Setting up the inheritance
Car.prototype.constructor = Car; // As a side effect of the line above, we loose the Car constructor. So we have to set it back

const car = new Car();
car.getNumberOfPassengers(); // 5
Enter fullscreen mode Exit fullscreen mode

I won't go into much details here as there's a lot to talk about. But this is the minimal setup to do prototypal inheritance.

You may agree with me or not, but I find it a lot less straight forward and less descriptive than the class implementation.

Wrap up

We covered a lot already. We saw all of the tools we can use to create classes that are tailored to our needs, we discussed how to create objects using classes and we talked about some caveats to be aware of. Finally we saw how difficult it can be to use constructor functions compared to using classes.

That's it for this post. I hope you liked it. If you did, please share it with your friends and colleagues. Also you can follow me on twitter at @theAngularGuy as it would greatly help me.

Have a good day !


What to read next?

Top comments (10)

Collapse
 
mustapha profile image
Mustapha Aouas

Hi @lukeshiru ,

Well because it all comes down to the paradigm you want to employ in your system. Object oriented or functional (as in your example).

Both have advantages and disadvantages. There's not one better than the other and immutability or code reuse should never be a deciding factor because you can achieve both with either.

I never mentioned paradigms when I said "You may agree with me or not, but I find it a lot less straight forward and less descriptive than the class implementation". I was talking about the two ways of setting inheritance :)

But, if you want my opinion on the subject, I believe that discarding a paradigm simply to avoid some "complexities" of the language should be a warning flag to back up and rethink the whole approach (for example this has noting to do with classes in the way it works).

Thanks for the feedback and have a nice day!

Collapse
 
peerreynders profile image
peerreynders

Inheritance

this post answers the how

In most cases the why decides whether how is even relevant.

The most compelling motivation for class inheritance in JavaScript is to build on platform specific capabilities like using custom elements that require class-based inheritance — to the point that prototypal inheritance, combination inheritance, parasitic inheritance or parasitic combination inheritance aren't enough and one has resort to using Reflect.construct() to create a class instance in ES5.1 (June 2011).

That said:

Favor object composition over class inheritance

Gamma, Erich et al. "Design Patterns Elements of Reusable Object-Oriented Software"". Putting Reuse Mechanisms to Work, p.20, 1994.

There are also other ways of exploiting and managing commonality/variability — for example TypeScript supports discriminated unions.

Example

type TutorialInfo = {
  topic: string;
};

type VideoTutorial = TutorialInfo & {
  kind: 'video';
  duration: number;
};

type PdfTutorial = TutorialInfo & {
  kind: 'pdf';
  pages: number;
};

type Tutorial = VideoTutorial | PdfTutorial;

function take(tutorial: Tutorial): void {
  switch (tutorial.kind) {
    case 'video':
      console.log(`Watch Video: ${tutorial.topic}`);
      break;

    case 'pdf':
      console.log(`Read PDF: ${tutorial.topic}`);
      break;

    default:
      const _exhaustiveCheck: never = tutorial;
  }
}

const v: VideoTutorial = {
  kind: 'video',
  topic: 'In The Loop',
  duration: 3081,
};

const p: PdfTutorial = {
  kind: 'pdf',
  topic: 'Modern Asynchronous JavaScript',
  pages: 75,
};

take(v); // "Watch Video : In The Loop"
take(p); // "Read PDF: Modern Asynchronous JavaScript"
Enter fullscreen mode Exit fullscreen mode

In terms of vanilla JavaScript this simply boils down to:

function take(tutorial) {
  switch (tutorial.kind) {
    case 'video':
      console.log(`Watch Video: ${tutorial.topic}`);
      break;

    case 'pdf':
      console.log(`Read PDF: ${tutorial.topic}`);
      break;

    default:
      const _exhaustiveCheck = tutorial;
  }
}

const v = {
  kind: 'video',
  topic: 'In The Loop',
  duration: 3081,
};

const p = {
  kind: 'pdf',
  topic: 'Modern Asynchronous JavaScript',
  pages: 75,
};

take(v); // "Watch Video : In The Loop"
take(p); // "Read PDF: Modern Asynchronous JavaScript"
Enter fullscreen mode Exit fullscreen mode

Of course by this point the "guard rails" (exhaustiveness checking) are off.

Refactoring:

Have a nice day!

Collapse
 
mustapha profile image
Mustapha Aouas • Edited

Hi Peerreynders,

Thanks for your feedback and for sharing these resources. I'll definitely add the "Design Patterns Elements of Reusable Object-Oriented Software" book to my reading list !

Have a great day.

Collapse
 
peerreynders profile image
peerreynders

I'll definitely add the "Design Patterns Elements of Reusable Object-Oriented Software" book to my reading list!

Look, don't get me wrong, it's a good book but from a contemporary view point you have to deal with ancient C++ code and pre-UML diagrams.

If you want the read the treatment on inheritance, the free preview on Google Play should be enough.

In terms of Design Patterns 10 years later Head First Design Patterns was published and lots of people found it to be more accessible as it explained everything in terms of object-oriented design principles (of which "Favour composition over inheritance" is one). Not sure what's in vogue these days.

The problem with Design Patterns are The 4 Stages of Learning Design Patterns: Ignorance, Awakening, Overzealous Application, Mastery

When it comes to SOLID — even that's just a milestone on the journey: The SOLID Design Principles Deconstructed.

In the end objects are not the only form of data abstraction (On Understanding Data Abstraction, Revisited).

MDN: JavaScript

JavaScript is a prototype-based, multi-paradigm, single-threaded, dynamic language, supporting object-oriented, imperative, and declarative (e.g. functional programming) styles.

and

MDN: Classes

Classes are a template for creating objects.

Most mainstream OO is class-based object-oriented programming; in JavaScript objects can be created in the absence of a class — opening up avenues to alternate solution approaches — not too surprising given that Brendan Eich was recruited to Netscape with the promise of "doing Scheme" in the browser.

So with JavaScript, classes are only part of the story (Eloquent JavaScript doesn't introduce them until Chapter 6).

 
mustapha profile image
Mustapha Aouas • Edited

I talked about the paradigms because the two snippets in your comment were in two different paradigms.

There are so many points to rectify in what you said in my opinion, but they are outside the scope of this post (this post tries to answer the how, not the why not the pros not the cons).
But if I may, all of the questions you asked you can find valid answers to. Maybe take some time to research Immutability in OOP (with classes) as you seem to have some big misconceptions.

from your point of view using prototype is more complex than just using classes, which is true

If we agree on that then I will leave it there.

Have a nice day!

Collapse
 
eppak profile image
Alessandro Cappellozza • Edited

Have you some tips (or best practice) to remediate the absence of interfaces? This is one of the reasons i stay away from plain js and adopt ts.

Collapse
 
mustapha profile image
Mustapha Aouas • Edited

Hi Alessandro,

That's a great question. Of course in JS there's no such thing as an Interface, but the entire idea of the interface segregation principle (from the 4th design principle in SOLID) is about segregating interfaces and making them smaller. This principle is applicable in JS even if there's no interfaces.

We can use class inheritance + prototypal composition. Here is an example:

class Vehicle {
  numberOfPassengers = 0;

  constructor(nb) {
    this.numberOfPassengers = nb;
  }
}

const hasEmergencyExit = {
  performAnEmergencyExit() {
    this.numberOfPassengers = 0;
  }
}
// or like this if we want the functionality to be more configurable
const hasEmergencyExitV2 = (nb) => ({
  performAnEmergencyExit() {
    this.numberOfPassengers = nb;
  }
});

// ---

class Car extends Vehicle {
  constructor() {
    super(5);
  }
}
Object.assign(Car.prototype, hasEmergencyExit);

const car = new Car();
car.performAnEmergencyExit();
Enter fullscreen mode Exit fullscreen mode

So the Car extends the Vehicle class, then we take the prototype of that Car class (which is basically the definition of that class) and we are adding in the functionality of hasEmergencyExit.


 

I don't know if it's a best practice, but that's how i would remediate the absence of interfaces. That being said, I know that this is quite different from how we usually use interfaces, and typescript can make that very straight forward and easy.

Collapse
 
eppak profile image
Alessandro Cappellozza

Thank you for the reply and the code, i asked because more or less all patterns are based on the interface priciple and is hard to use something that imitate the funcionality without a explicit support. Maybe can be a topic for a new awesome post like this one

Collapse
 
sergei profile image
Sergei Sarkisian

Oh, I spent like 5 minutes to decipher what's going on in the 'updatePoint' function. Maybe it's just me, but I found this very hard to read.

Collapse
 
qm3ster profile image
Mihail Malo

I wish I could write like that without the performance impact.

Some comments have been hidden by the post's author - find out more