ES6 Classes & Class Inheritance - extends & super

This comprehensive guide covers the fundamentals of ES6 Classes and Class Inheritance in JavaScript, including class declarations, methods, inheritance using the `extends` keyword, and the `super` keyword. Dive into creating, extending, and overriding classes with real-world examples and code snippets.

Introduction to ES6 Classes & Class Inheritance

JavaScript, the heart of the web, has undergone significant enhancements with the introduction of ECMAScript 6 (ES6) in 2015. One of the most notable features is the introduction of classes, which provide a more structured way to create objects and manage inheritance. If you're new to JavaScript or haven't delved into ES6, this guide is your perfect starting point. Let's explore the world of ES6 classes, inheritance, and the powerful extends and super keywords.

Understanding Classes

At the core, a class is a blueprint for creating objects. In traditional object-oriented programming languages like Java or C++, classes are essential for defining the structure and behavior of objects. ES6 introduced a more familiar and cleaner syntax for creating classes, making it easier for developers to adopt object-oriented programming principles in JavaScript.

What is a Class?

A class is essentially a template for creating objects. It encapsulates data for the object and methods to manipulate that data. Before ES6, you could create objects using constructor functions, but ES6 classes provide a more readable and maintainable approach.

Imagine a class as a blueprint for a car. Just like how a blueprint specifies the shape, size, and components of a car, a class specifies the properties and methods (functions) that an object can have.

Creating a Class

There are two main ways to define a class in JavaScript: using a class declaration or a class expression.

Class Declaration

To declare a class, you use the class keyword followed by the name of the class. Here’s an example:

class Car {
  constructor(make, model, year) {
    this.make = make;
    this.model = model;
    this.year = year;
  }

  displayInfo() {
    return `This car is a ${this.year} ${this.make} ${this.model}.`;
  }
}

const myCar = new Car('Toyota', 'Corolla', 2021);
console.log(myCar.displayInfo()); // Output: This car is a 2021 Toyota Corolla.

In this example, we define a Car class with a constructor that initializes the properties make, model, and year. The displayInfo method is used to return a string with the car's details.

Class Expression

A class expression is similar to a class declaration, but it can be named or unnamed. Named class expressions can be referenced with their name within the class body, while unnamed class expressions can only be referenced by the variable they are assigned to.

const Vehicle = class {
  constructor(type) {
    this.type = type;
  }

  startEngine() {
    return `${this.type} engine started.`;
  }
};

const myVehicle = new Vehicle('Bike');
console.log(myVehicle.startEngine()); // Output: Bike engine started.

In this example, an unnamed class expression is assigned to the variable Vehicle. The class defines a startEngine method that returns a string indicating that the engine has started.

ES5 vs ES6 Classes

Before ES6, you would create objects using constructor functions. Here’s how you might define a similar Car class using ES5 syntax:

function CarES5(make, model, year) {
  this.make = make;
  this.model = model;
  this.year = year;
}

CarES5.prototype.displayInfo = function() {
  return `This car is a ${this.year} ${this.make} ${this.model}.`;
};

const myCarES5 = new CarES5('Toyota', 'Corolla', 2021);
console.log(myCarES5.displayInfo()); // Output: This car is a 2021 Toyota Corolla.

Comparing the ES5 and ES6 methods:

  • ES5: You define constructor functions and then add methods to the prototype. This approach works but can be less intuitive and error-prone.
  • ES6 Classes: The syntax is more concise and intuitive. You define the constructor and methods within the class body, making the code cleaner and easier to understand.

Defining Methods and Properties

Methods and properties are key components of a class. We’ll explore how to define and use them in ES6 classes.

Constructor Method

The constructor method is a special method for initializing new objects created with a class. It runs automatically when a new instance of the class is created using the new keyword.

class Car {
  constructor(make, model, year) {
    this.make = make;
    this.model = model;
    this.year = year;
  }

  displayInfo() {
    return `This car is a ${this.year} ${this.make} ${this.model}.`;
  }
}

In the above example, the constructor method takes three parameters (make, model, and year) and assigns them to the properties of the class instance.

Parameters in Constructor

You can pass parameters to the constructor to initialize the object with specific values. For instance, the Car class above takes three parameters that specify the make, model, and year of the car.

Accessing Properties in Constructor

Inside the constructor, you can access the class properties using the this keyword. This keyword refers to the instance of the class, allowing you to define and manipulate its properties.

Instance Methods

Instance methods are functions that can be called on an instance of a class. They operate on the instance properties and can perform operations using those properties.

class Car {
  constructor(make, model, year) {
    this.make = make;
    this.model = model;
    this.year = year;
  }

  displayInfo() {
    return `This car is a ${this.year} ${this.make} ${this.model}.`;
  }

  calculateAge() {
    const year = new Date().getFullYear();
    return `This car is ${year - this.year} years old.`;
  }
}

In this example, the Car class has two instance methods: displayInfo and calculateAge. The calculateAge method calculates the age of the car based on its year of manufacture.

Static Methods

Static methods are methods that are bound to the class itself and not to instances of the class. They can be called directly on the class without creating an instance. Static methods are often used for utility functions that perform operations that don't require accessing the instance.

class MathUtils {
  static add(a, b) {
    return a + b;
  }

  static multiply(a, b) {
    return a * b;
  }
}

console.log(MathUtils.add(5, 3)); // Output: 8
console.log(MathUtils.multiply(5, 3)); // Output: 15

In this example, MathUtils is a class with two static methods: add and multiply. These methods can be called using the class name directly, without creating an instance of the class.

Using this Keyword in ES6 Classes

The this keyword is a fundamental concept in JavaScript, and its behavior can vary depending on the context. In the context of classes, this refers to the instance of the class.

Scope of this

Inside class methods, this refers to the instance of the class, allowing you to access the instance's properties and methods.

class Car {
  constructor(make, model, year) {
    this.make = make;
    this.model = model;
    this.year = year;
  }

  displayInfo() {
    return `This car is a ${this.year} ${this.make} ${this.model}.`;
  }
}

const myCar = new Car('Toyota', 'Corolla', 2021);
console.log(myCar.displayInfo()); // Output: This car is a 2021 Toyota Corolla.

In this example, the displayInfo method uses this to access the year, make, and model properties of the class instance.

Arrow Functions and this

Arrow functions do not have their own this context. Instead, they inherit the this value from the enclosing execution context. This can be useful when you want to maintain the this value within a nested function.

class Car {
  constructor(make, model, year) {
    this.make = make;
    this.model = model;
    this.year = year;
  }

  displayInfoLater() {
    setTimeout(() => {
      console.log(`This car is a ${this.year} ${this.make} ${this.model}.`);
    }, 1000);
  }
}

const myCar = new Car('Toyota', 'Corolla', 2021);
myCar.displayInfoLater(); // Output after 1 second: This car is a 2021 Toyota Corolla.

In this example, the arrow function inside displayInfoLater uses the this value from the Car class, ensuring that it refers to the correct instance.

Inheritance in JavaScript

Inheritance is a fundamental concept in object-oriented programming that allows you to create a new class (child class) based on an existing class (parent class). This promotes code reusability and a logical hierarchy.

What is Inheritance?

Inheritance allows a subclass to inherit properties and methods from a parent class. This means you can create a base class with common functionality and then extend it to create more specific subclasses. Inheritance helps in reducing code duplication and organizing code in a more structured manner.

Using extends Keyword

The extends keyword is used to create a subclass that inherits from a parent class. The subclass can add new methods and properties or override existing ones from the parent class.

class Vehicle {
  constructor(type) {
    this.type = type;
  }

  startEngine() {
    return `${this.type} engine started.`;
  }
}

class Car extends Vehicle {
  constructor(make, model, year) {
    super(type, make, model, year);
    this.make = make;
    this.model = model;
    this.year = year;
  }

  displayInfo() {
    return `This car is a ${this.year} ${this.make} ${this.model}.`;
  }
}

const myCar = new Car('Toyota', 'Corolla', 2021);
console.log(myCar.startEngine()); // Output: Car engine started.
console.log(myCar.displayInfo()); // Output: This car is a 2021 Toyota Corolla.

In this example, the Car class extends the Vehicle class. The Car class inherits the startEngine method from the Vehicle class and adds its own displayInfo method.

Example of Basic Inheritance

Let’s look at another example to understand inheritance better:

class Shape {
  constructor(colors) {
    this.colors = colors;
  }

  displayColors() {
    return `Colors: ${this.colors.join(', ')}`;
  }
}

class Circle extends Shape {
  constructor(colors, radius) {
    super(colors);
    this.radius = radius;
  }

  area() {
    return Math.PI * Math.pow(this.radius, 2);
  }
}

const myCircle = new Circle(['red', 'blue'], 5);
console.log(myCircle.displayColors()); // Output: Colors: red, blue
console.log(myCircle.area()); // Output: 78.53981633974483

In this example, the Circle class extends the Shape class. The Circle class inherits the displayColors method from the Shape class and adds its own area method to calculate the area of the circle.

Accessing Parent Class Methods

In subclasses, you can access methods and properties from the parent class using the super keyword.

class Vehicle {
  constructor(type) {
    this.type = type;
  }

  startEngine() {
    return `${this.type} engine started.`;
  }
}

class Car extends Vehicle {
  constructor(make, model, year) {
    super(type);
    this.make = make;
    this.model = model;
    this.year = year;
  }

  displayInfo() {
    return `${super.startEngine()} It is a ${this.year} ${this.make} ${this.model}.`;
  }
}

const myCar = new Car('Toyota', 'Corolla', 2021);
console.log(myCar.displayInfo()); // Output: Car engine started. It is a 2021 Toyota Corolla.

In this example, the displayInfo method in the Car class uses super.startEngine() to call the startEngine method from the Vehicle class.

The super Keyword

The super keyword is used to call the constructor or methods from a parent class. It is crucial for inheriting and extending the functionality of the parent class.

Using super to Call Parent Constructor

When extending a class, you must call the parent constructor using super() in the subclass constructor. This is necessary to initialize the properties of the parent class.

class Vehicle {
  constructor(type) {
    this.type = type;
  }

  startEngine() {
    return `${this.type} engine started.`;
  }
}

class Car extends Vehicle {
  constructor(make, model, year, type) {
    super(type);
    this.make = make;
    this.model = model;
    this.year = year;
  }

  displayInfo() {
    return `This car is a ${this.year} ${this.make} ${this.model}.`;
  }
}

const myCar = new Car('Toyota', 'Corolla', 2021, 'Car');
console.log(myCar.startEngine()); // Output: Car engine started.
console.log(myCar.displayInfo()); // Output: This car is a 2021 Toyota Corolla.

In this example, the Car class calls the Vehicle class’s constructor using super(type), initializing the type property of the Vehicle class.

Using super to Call Parent Methods

You can also use super to call methods from the parent class. This is particularly useful when you want to extend or override a method from the parent class while still using its functionality.

class Vehicle {
  constructor(type) {
    this.type = type;
  }

  startEngine() {
    return `${this.type} engine started.`;
  }

  stopEngine() {
    return `${this.type} engine stopped.`;
  }
}

class Car extends Vehicle {
  constructor(make, model, year, type) {
    super(type);
    this.make = make;
    this.model = model;
    this.year = year;
  }

  displayInfo() {
    return `This car is a ${this.year} ${this.make} ${this.model}.`;
  }

  startEngine() {
    return `${super.startEngine()} It is ready to drive.`;
  }
}

const myCar = new Car('Toyota', 'Corolla', 2021, 'Car');
console.log(myCar.startEngine()); // Output: Car engine started. It is ready to drive.

In this example, the Car class overrides the startEngine method from the Vehicle class. Inside the startEngine method, super.startEngine() is used to call the startEngine method from the Vehicle class and extend its functionality.

Example: Simple Inheritance with super

Here’s a more comprehensive example illustrating class inheritance with super:

class Animal {
  constructor(name) {
    this.name = name;
  }

  speak() {
    return `${this.name} makes a noise.`;
  }
}

class Dog extends Animal {
  constructor(name, breed) {
    super(name);
    this.breed = breed;
  }

  speak() {
    return `${super.speak()} Its breed is ${this.breed}.`;
  }
}

const myDog = new Dog('Rex', 'Golden Retriever');
console.log(myDog.speak()); // Output: Rex makes a noise. Its breed is Golden Retriever.

In this example, the Dog class extends the Animal class. The Dog class calls the Animal class’s constructor using super(name) and adds a breed property. The speak method in the Dog class overrides the method from the Animal class and extends its functionality by adding information about the breed.

class vs function Keyword

While ES6 classes and constructor functions serve similar purposes, they have some differences in syntax and behavior.

Comparison in Creation and Usage

  • Classes: Classes are defined using the class keyword and have a constructor method to initialize the instance. They are block-scoped and not hoisted, meaning you cannot use them before declaring them in your code.
  • Functions: Functions can be defined using function declarations or expressions. They are hoisted, which means you can call a function before its declaration in the code. Functions can also be used to create objects using the new keyword, but they lack many of the features of classes, such as inheritance and easier method definitions.
// Function declaration
function Vehicle(type) {
  this.type = type;
}

Vehicle.prototype.startEngine = function() {
  return `${this.type} engine started.`;
};

// ES6 Class
class VehicleClass {
  constructor(type) {
    this.type = type;
  }

  startEngine() {
    return `${this.type} engine started.`;
  }
}

In this example, both Vehicle (constructed using a function) and VehicleClass (constructed using a class) serve the same purpose but have different syntaxes. The class syntax is more concise and intuitive.

When to Use Classes vs Functions

  • Classes: Use classes when you’re building large-scale applications using modern JavaScript and need the features of object-oriented programming, such as inheritance and static methods. Classes are more readable and maintainable.
  • Functions: Use functions when you’re working in environments that do not support ES6 or when you need to define simple functions without the overhead of class syntax.

Private Properties and Methods

ES6 introduces private properties and methods, which are not accessible from outside the class. These features enhance encapsulation and protect the internal state of the class.

Private Instance Fields

Private instance fields are denoted by a # symbol before the property name. They are accessible only within the class.

class Vehicle {
  #engineType;

  constructor(type, engineType) {
    this.type = type;
    this.#engineType = engineType;
  }

  startEngine() {
    return `${this.type} engine started with ${this.#engineType} engine.`;
  }
}

const myVehicle = new Vehicle('Car', 'V8');
console.log(myVehicle.startEngine()); // Output: Car engine started with V8 engine.
// console.log(myVehicle.#engineType); // Uncommenting this line will throw an error

In this example, the #engineType property is private and cannot be accessed outside the Vehicle class.

Private Methods

Private methods are also denoted by a # symbol. They can only be called within the class.

class Vehicle {
  #engineType;

  constructor(type, engineType) {
    this.type = type;
    this.#engineType = engineType;
  }

  #privateMethod() {
    return `${this.type} engine is private.`;
  }

  startEngine() {
    return `${this.#engineType} engine started. ${this.#privateMethod()}`;
  }
}

const myVehicle = new Vehicle('Car', 'V8');
console.log(myVehicle.startEngine()); // Output: V8 engine started. Car engine is private.
// myVehicle.#engineType; // Uncommenting this line will throw an error
// myVehicle.#privateMethod(); // Uncommenting this line will throw an error

In this example, the #engineType property and #privateMethod method are private and cannot be accessed outside the Vehicle class.

Extending Built-in Objects

In ES6, you can extend built-in objects like Array and Object to create custom classes. This is particularly useful when you want to add custom functionality to built-in types.

Extending Array

You can extend the Array object to create custom array-based classes.

class MyArray extends Array {
  first() {
    return this[0];
  }

  last() {
    return this[this.length - 1];
  }
}

const myArray = new MyArray(1, 2, 3, 4, 5);
console.log(myArray.first()); // Output: 1
console.log(myArray.last()); // Output: 5

In this example, the MyArray class extends the built-in Array object and adds two methods: first and last, which return the first and last elements of the array, respectively.

Extending Object

While you can extend Object, it’s less common because Object is a base class for almost all JavaScript objects. However, it can be useful in certain scenarios.

class MyObject extends Object {
  constructor(name, value) {
    super();
    this.name = name;
    this.value = value;
  }

  printInfo() {
    return `${this.name}: ${this.value}`;
  }
}

const myObject = new MyObject('Speed', 'Fast');
console.log(myObject.printInfo()); // Output: Speed: Fast

In this example, the MyObject class extends the built-in Object class and adds a printInfo method to display the object's properties.

Extending Custom Classes

You can also extend custom classes to create a hierarchy of objects. This is useful for creating complex applications with a clear and organized structure.

class Vehicle {
  constructor(type) {
    this.type = type;
  }

  startEngine() {
    return `${this.type} engine started.`;
  }
}

class Car extends Vehicle {
  constructor(make, model, year, type) {
    super(type);
    this.make = make;
    this.model = model;
    this.year = year;
  }

  displayInfo() {
    return `This car is a ${this.year} ${this.make} ${this.model}.`;
  }
}

const myCar = new Car('Toyota', 'Corolla', 2021, 'Car');
console.log(myCar.startEngine()); // Output: Car engine started.
console.log(myCar.displayInfo()); // Output: This car is a 2021 Toyota Corolla.

In this example, the Car class extends the Vehicle class, inheriting the startEngine method and adding a displayInfo method.

Overriding Methods

Overriding methods in a subclass allows you to modify or extend the behavior of methods inherited from the parent class.

Why Override Methods?

Overriding methods is useful when you need to provide a specific implementation for a method in a subclass, which might differ from the parent class’s implementation.

Example of Method Overriding

class Vehicle {
  startEngine() {
    return `Vehicle engine started.`;
  }
}

class Car extends Vehicle {
  startEngine() {
    return `Car engine started. It is ready to drive.`;
  }
}

const myCar = new Car();
console.log(myCar.startEngine()); // Output: Car engine started. It is ready to drive.

In this example, the Car class overrides the startEngine method from the Vehicle class, providing a more specific implementation.

Summary and Recap

Key Takeaways

  • Classes and inheritance in ES6 provide a clean and intuitive way to create objects and extend their functionality.
  • The super keyword is used to call the parent class constructor and methods.
  • Private properties and methods provide encapsulation and protect the internal state of the class.
  • You can extend built-in objects like Array and Object to create custom classes.
  • Overriding methods allows you to provide specific implementations in subclasses.

Next Steps

Now that you have a solid understanding of ES6 classes and inheritance, you can apply these concepts to build scalable and maintainable JavaScript applications. Experiment with different use cases and explore more advanced features of JavaScript.

  • MDN Web Docs on Classes: MDN Classes
  • MDN Web Docs on Inheritance: MDN Inheritance
  • JavaScript: The Good Parts by Douglas Crockford: This book provides a comprehensive introduction to JavaScript and its features, including classes and inheritance.

By mastering classes and inheritance, you’ll be well-equipped to tackle more complex JavaScript projects and write cleaner, more maintainable code. Happy coding!