Tutorial

Demystifying ES6 Classes And Prototypal Inheritance

Draft updated on Invalid Date
author

Neo Ighodaro

Demystifying ES6 Classes And Prototypal Inheritance

This tutorial is out of date and no longer maintained.

Introduction

In the early history of the JavaScript language, a cloud of animosity formed over the lack of a proper syntax for defining classes like in most object-oriented languages. It wasn’t until the ES6 spec release in 2015 that the class keyword was introduced; it is described as syntactical sugar over the existing prototype-based inheritance.

At its most basic level, the class keyword in ES6 is equivalent to a constructor function definition that conforms to prototype-based inheritance. It may seem redundant that a new keyword was introduced to wrap an already existing feature but it leads to readable code and lays the foundation upon which future object-oriented features can be built.

Before ES6, if we had to create a blueprint (class) for creating many objects of the same type, we’d use a constructor function like this:

function Animal(name, fierce) {
  Object.defineProperty(this, 'name', {
    get: function() { return name; }
  });

  Object.defineProperty(this, 'fierce', {
    get: function() { return fierce; }
  });
}

Animal.prototype.toString = function() {
  return 'A' + ' ' + (this.fierce ? 'fierce' : 'tame') + ' ' + this.name;
}

This is a simple object constructor that represents a blueprint for creating instances of the Animal class. We have defined two read-only own properties and a custom toString method on the constructor function. We can now create an Animal instance with the new keyword:

var Lion = new Animal('Lion', true);

console.log(Lion.toString()); // "A fierce Lion"

Great! It works as expected. We can rewrite the code using the ES6 class for a concise version:

class Animal {
  constructor(name, fierce) {
    this._name = name;
    this._fierce = fierce;
  }

  get name() {
    return this._name;
  }

  get fierce() {
    return `This animal is ${ this._fierce ? 'fierce' : 'tame' }`;
  }

  toString() {
    return `This is a ${ this._fierce ? 'fierce' : 'tame' } ${this._name}`;
  }
}

Let’s create an instance of the Animal class with the new keyword as we did before:

let Lion = new Animal('Lion', true);

console.log(Lion.fierce);

console.log(Lion.toString())

Defining classes in ES6 is very straightforward and feels more natural in an object-oriented sense than the previous simulation using object constructors. Let’s take an in-depth look at the ES6 class by exploring some of its attributes and ramifications.

Defining Classes

Making a transition from using the older object constructors to the newer ES6 classes shouldn’t be difficult at all since the class keyword is just a special function and exhibits expected function behavior. For example, just like a function, a class can be defined by either a declaration or an expression, where the latter can be named or unnamed.

Class Declarations

A class declaration is defined with the class keyword and followed by the name of the class.

class Animal {}

We already used the class declaration when we wrote the ES6 version of the Animal constructor function :

class Animal {
  constructor(name, fierce) {
    this._name = name;
    this._fierce = fierce;
  }
}

Class Expressions

A class expression allows for a bit more flexibility; a class may be named or unnamed, however, when a class expression is named, the name attribute becomes a local property on the class’ body and can be accessed using the .name property.

An unnamed class expression skips the name after the class keyword:

// unnamed
let animal = class {}

A named class expression, on the other hand, includes the name:

// named
let animal = class Animal {}

When comparing the object constructor to the ES6 class, it is worthy of note that, unlike the object constructor that can be accessed before its scope is defined because of hoisting, the class can’t and isn’t hoisted.

While this may seem like a major limitation on ES6 classes, it doesn’t have to be; good ES6 practice demands that if any function must mutate an instance of a class then it can be defined anywhere in the program but should be invoked only after the class itself has been defined.

Constructor and Body of a Class

After defining a class using any of the two stated methods, the curly brackets {} should hold class members, such as instance variables, methods, or constructor; the code within the curly brackets make up the body of the class.

A class’ constructor is simply a method whose purpose is to initialize an instance of that class. This means that whenever an instance of a class is created, the constructor (where it is defined) of the class is invoked to do something on that instance; it could maybe initialize the object’s properties with received parameters or default values when the former isn’t available.

There can only be a single constructor method associated with a class so be careful not to define multiple constructor methods as this would result in a SyntaxError. The super keyword can be used within an object’s constructor to call the constructor of its superclass.

class Animal {
  constructor(name, fierce) { // there can only be one constructor method
    this._name = name;
    this._fierce = fierce;
  }
}

The code within the body of a class is executed in strict mode.

Defining Methods

The body of a class usually comprises instance variables to define the state of an instance of the class, and prototype methods to define the behavior of an instance of that class. Before ES6, if we needed to define a method on a constructor function, we could do it like this:

function Animal(name, fierce) {
  Object.defineProperty(this, 'name', {
    get: function() { return name; }
  });

  Object.defineProperty(this, 'fierce', {
    get: function() { return fierce; }
  });
}

Animal.prototype.toString = function() {
  return 'A' + ' ' + (this.fierce ? 'fierce' : 'tame') + ' ' + this.name;
}

Or

function Animal(name, fierce) {
  Object.defineProperty(this, 'name', {
    get: function() { return name; }
  });

  Object.defineProperty(this, 'fierce', {
    get: function() { return fierce; }
  });

  this.toString = function() {
    return 'A' + ' ' + (this.fierce ? 'fierce' : 'tame') + ' ' + this.name;
  }
}

The two different methods we defined above are referred to as prototype methods and can be invoked by an instance of a class. In ES6, we can define two types of methods: prototype and static methods. Defining a prototype method in ES6 is quite similar to what we have above, except that the syntax is cleaner (we don’t include the prototype property) and more readable:

class Animal {
  constructor(name, fierce) {
    this._name = name;
    this._fierce = fierce;
  }

  get name() {
    return this._name;
  }

  get fierce() {
    return ` This animal is ${ this._fierce ? 'fierce' : 'tame' }`;
  }

  toString() {
    return `This is a ${ this._fierce ? 'fierce' : 'tame' } ${this._name}`;
  }
}

Here we first define two getter methods using a shorter syntax, then we create a toString method that basically checks to see if an instance of the Animal class is a fierce or tame animal. These methods can be invoked by any instance of the Animal class but not by the class itself.

ES6 prototype methods can be inherited by children classes to simulate an object-oriented behavior in JavaScript but under the hood, the inheritance feature is simply a function of the existing prototype chain and we’d look into this very soon.

All ES6 methods cannot work as constructors and will throw a TypeError if invoked with the new keyword.

Static Methods

Static methods resemble prototype methods in the fact that they define the behavior of the invoking object but differ from their prototype counterparts as they cannot be invoked by an instance of a class. A static method can only be invoked by a class; an attempt to invoke a static method with an instance of a class would result in unexpected behavior.

A static method must be defined with the static keyword. In most cases, static methods are used as utility functions on classes.

Let’s define a static utility method on the Animal class that simply returns a list of animals:

class Animal {
  constructor(name, fierce){
    this._name = name;
    this._fierce = fierce;
  }

  static animalExamples() {
    return `Some examples of animals are Lion, Elephant, Sheep, Rhinoceros, etc.`
  }
}

Now, we can call the animalExamples() method on the class itself:

console.log(Animal.animalExamples()); // "Some examples of animals are Lion, Elephant, Sheep, Rhinoceros, etc."

Subclassing in ES6

In object-oriented programming, it’s good practice to create a base class that holds some generic methods and attributes, then create other more specific classes that inherit these generic methods from the base class, and so on. In ES5 we relied on the prototype chain to simulate this behavior and the syntax would sometimes become messy.

ES6 introduced the somewhat familiar extends keyword that makes inheritance very easy. A subclass can easily inherit attributes from a base class like this:

class Animal {
  constructor(name, fierce) {
    this._name = name;
    this._fierce = fierce;
  }

  get name() {
    return this._name;
  }

  get fierce() {
    return `This animal is ${ this._fierce ? 'fierce' : 'tame' }`;
  }

  toString() {
    return `This is a ${ this._fierce ? 'fierce' : 'tame' } ${this._name}`;
  }
}

class Felidae extends Animal {
  constructor(name, fierce, family) {
    super(name, fierce);
    this._family = family;
  }

  family() {
    return `A ${this._name} is an animal of the ${this._family} subfamily under the ${Felidae.name} family`;
  }
}

We have created a subclass here — Felidae (colloquially referred to as “cats”) — and it inherits the methods on the Animal class. We make use of the super keyword within the constructor method of the Felidae class to invoke the super class’ (base class) constructor. Awesome, let’s try creating an instance of the Felidae class and invoking and own method and an inherited method:

var Tiger = new Felidae('Tiger', true, 'Pantherinae');

console.log(Tiger.toString()); // "This is a fierce Tiger"
console.log(Tiger.family());   // "A Tiger is an animal of the Pantherinae subfamily under the Felidae family"

If a constructor is present within a sub-class, it needs to invoke super() before using “this”.

It is also possible to use the extends keyword to extend a function-based “class”, but an attempt to extend a class solely created from object literals will result in an error.

At the beginning of this article, we saw that most of the new keywords in ES6 are merely syntactical sugar over the existing prototype-based inheritance. Let’s now take a look under the sheets and see how the prototype chain works.

Prototypal Inheritance

While it’s nice to define classes and perform inheritance with the new ES6 keywords, it’s even nicer to understand how things work at the canonical level. Let’s take a look at JavaScript objects: All JavaScript objects have a private property that points to a second object (except in a few rare cases where it points to null) associated with them, this second object is called the prototype.

The first object inherits properties from the prototype object and the prototype may, in turn, inherit some properties from its own prototype and it goes on like that until the last prototype on the chain has its prototype property equal to null.

All JavaScript objects created by assigning an identifier the value of object literals share the same prototype object. This means that their private prototype property points to the same object in the prototype chain and hence inherits its properties. This object can be referred to in JavaScript code as Object.prototype.

Objects created by invoking a class’ constructor or constructor function initialize their prototype from the prototype property of the constructor function. In other words, when a new object is created by invoking new Object(), that object’s prototype becomes Object.prototype just like any object created from object literals. Similarly, a new Date() object will inherit from Date.prototype() and a new Number() from Number.prototype().

Nearly all objects in JavaScript are instances of Object which sits on the top of a prototype chain.

We have seen that it’s normal for JavaScript objects to inherit properties from another object (prototype) but the Object.prototype exhibits a rare behavior where it does not have any prototype and does not inherit any properties (it sits on the top of a prototype chain) from another object.

Nearly all of JavaScript’s built-in constructors inherit from Object.prototype, so we can say that Number.prototype inherits properties from Object.prototype. The effect of this relationship : creating an instance of Number in JavaScript (using new Number()) will inherit properties from both Number.prototype and Object.prototype and that is the prototype chain.

JavaScript objects can be thought of as containers since they hold the properties defined on them and these properties are referred to as " own properties" but they are not limited to just their own properties. The prototype chain plays a big role when a property is being sought on an object:

  • The property is first sought on the object as an own property
  • If the property isn’t found on the object, its prototype is checked next
  • If the property doesn’t exist on the prototype, the prototype of the prototype is queried
  • This querying of prototype after prototype continues until the property is found or until the end of the prototype chain is reached and an error is returned.

Let’s write some code to clearly simulate the behavior of prototypal inheritance in JavaScript.

We will be using the ES5 method — Object.create() — for this example so let’s define it:

Object.create() is a method that creates a new object, using its first argument as the prototype of that object.

let Animals = {};                 // Animal inherits object methods from Object.prototype.
Animals.eat = true;               // Animal has an own property - eat (all Animals eat).

let Cat = Object.create(Animals); // Cat inherits properties from Animal and Object.prototype.
Cat.sound = true;                 // Cat has its own property - sound (the animals under the cat family make sounds).

let Lion = Object.create(Cat);    // Lion (a prestigious cat) inherits properties from Cat, Animal, and Object.prototype.
Lion.roar = true;                 // Lion has its own property - roar (Lions can roar)

console.log(Lion.roar);           // true - This is an "own property".
console.log(Lion.sound);          // true - Lion inherits sound from the Cat object.
console.log(Lion.eat);            // true - Lion inherits eat from the Animal object.
console.log(Lion.toString());     // "[object Object]" - Lion inherits toString method from Object.prototype.

Here’s a verbal interpretation of what we did above:

  • We created an Animal object and it inherits properties from Object.prototype
  • We initialized Animal``'``s own property — eat — to true (all Animals eat)
  • We created a new object — Cat — and initialized its prototype to Animal (Therefore Cat inherits properties from Animal and Object.prototype)
  • We initialized Cat's own property — sound — to true (the animals under the cat family make sounds)
  • We created a new object — Lion — and initialized its prototype to Cat (Therefore Lion inherits properties from Cat, Animal and Object.prototype)
  • We initialized Lion's own property — roar — to true (Lions can roar)
  • Lastly, we logged own and inherited properties on the Lion object and they all returned the right values by first seeking for the properties on the Lion object then moving on to the prototypes (and prototypes of prototypes) where it wasn’t available on the former.

This is a basic but accurate simulation of the prototypal inheritance in JavaScript using the prototype chain.

Conclusion

In this article, we have gone through the basics of ES6 classes and prototypal inheritance. Hopefully, you have learned a thing or two from reading the article. If you have any questions, leave them below in the comments section.

Thanks for learning with the DigitalOcean Community. Check out our offerings for compute, storage, networking, and managed databases.

Learn more about our products

About the authors
Default avatar
Neo Ighodaro

author

Still looking for an answer?

Ask a questionSearch for more help

Was this helpful?
 
Leave a comment


This textbox defaults to using Markdown to format your answer.

You can type !ref in this text area to quickly search our full set of tutorials, documentation & marketplace offerings and insert the link!

Try DigitalOcean for free

Click below to sign up and get $200 of credit to try our products over 60 days!

Sign up

Join the Tech Talk
Success! Thank you! Please check your email for further details.

Please complete your information!

Become a contributor for community

Get paid to write technical tutorials and select a tech-focused charity to receive a matching donation.

DigitalOcean Documentation

Full documentation for every DigitalOcean product.

Resources for startups and SMBs

The Wave has everything you need to know about building a business, from raising funding to marketing your product.

Get our newsletter

Stay up to date by signing up for DigitalOcean’s Infrastructure as a Newsletter.

New accounts only. By submitting your email you agree to our Privacy Policy

The developer cloud

Scale up as you grow — whether you're running one virtual machine or ten thousand.

Get started for free

Sign up and get $200 in credit for your first 60 days with DigitalOcean.*

*This promotional offer applies to new accounts only.