Classes
The concept of prototypes and prototypal inheritance in ES5 are hard to understand for many developers transitioning from another programming language to JavaScript development.
ES6 classes introduce syntactic sugar to make prototypes look like classical inheritance.
For this reason, some people applaud classes, as it makes JavaScript appear more familiar to them.
Other people seem to have launched a holy war against classes, claiming, that the class syntax is flawed, and it takes away the main advantages of using JavaScript.
On some level, all opinions have merit. My advice to you is that the market is always right. Knowing classes gives you the advantage that you can maintain code written in the class syntax. It does not mean that you have to use it. If your judgement justifies that classes should be used, go for it!
Not knowing the class syntax is a disadvantage.
Judgement on the class syntax, or offering alternatives are beyond the scope of this section.
Prototypal Inheritance in ES5
Let's start with an example, where we implement a classical inheritance scenario in JavaScript.
function Shape( color ) {
this.color = color;
}
Shape.prototype.getColor = function() {
return this.color;
}
function Rectangle( color, width, height ) {
Shape.call( this, color );
this.width = width;
this.height = height;
};
Rectangle.prototype = Object.create( Shape.prototype );
Rectangle.prototype.constructor = Rectangle;
Rectangle.prototype.getArea = function() {
return this.width * this.height;
};
let rectangle = new Rectangle( 'red', 5, 8 );
console.log( rectangle.getArea() );
console.log( rectangle.getColor() );
console.log( rectangle.toString() );
Rectangle is a constructor function. Even though there were no classes in ES5, many people called constructor functions and their prototype extensions classes.
We instantiate a class with the new keyword, creating an object out of it. In ES5 terminology, constructor functions return new objects, having defined of properties and operations.
Prototypal inheritance is defined between Shape and Rectangle, as a rectangle is a shape. Therefore, we can call the getColor method on a rectangle.
Prototypal inheritance is implicitly defined between Object and Shape. As the prototype chain is transitive, we can call the toString built-in method on a rectangle object, even though it comes from the prototype of Object.
The code looks a bit weird, and chunks of code that should belong together are separated.
The ES6 way
Let's see the ES6 version. As we'll see later, the two versions are not equivalent to each other, we just describe the same problem domain with ES5 and ES6 code.
class Shape {
constructor( color ) {
this.color = color;
}
getColor() {
return this.color;
}
}
class Rectangle extends Shape {
constructor( color, width, height ) {
super( color );
this.width = width;
this.height = height;
}
getArea() {
return this.width * this.height;
}
}
let rectangle = new Rectangle( 'red', 5, 8 );
console.log( rectangle.getArea() );
console.log( rectangle.getColor() );
console.log( rectangle.toString() );
Classes may encapsulate
- a constructor function
- additional operations extending the prototype
- reference to the parent prototype.
Notice the following:
- the
extendskeyword defines the is-a relationship betweenShapeandRectangle. All instances ofRectangleare also instances ofShape. - the
constructormethod is a method that runs when you instantiate a class. You can call the constructor method of your parent class withsuper. More onsuperlater. - methods can be defined inside classes. All objects are able to call methods of their class and all classes that are higher in the inheritance chain.
- Instantiation works in the exact same way as the instantiation of an ES5 constructor function.
You can observe the equivalent ES5 code by pasting the above code into the BabelJs online editor.
The reason why the generated code is not equivalent with the ES5 code we studied is that the class syntax comes with additional features. You will never need the protection provided by these features during regular use. For instance, if you call the class name as a regular function, or you call a method of the class with the new operator as a constructor, you get an error.
Your code becomes more readable, when you capitalize class names, and start object names and method names with a lower case letter. For instance,
Personshould be a class, andpersonshould be an object.
Super
Calling super in a constructor should happen before accessing this. As a rule of thumb:
Call
superas the first thing in a constructor of a class defined withextends.
If you fail to call super, an error will be thrown. If you don't define a constructor in a class defined with extends, one will automatically be created for you, calling super.
class A { constructor() { console.log( 'A' ); } }
class B extends A { constructor() { console.log( 'B' ); } }
new B()
B
Uncaught ReferenceError: this is not defined(…)
class C extends A {}
new C()
A
> C {}
C.constructor
> Function() { [native code] }
Shadowing
Methods of the parent class can be redefined in the child class.
class User {
constructor() {
this.accessMatrix = {};
}
hasAccess( page ) {
return this.accessMatrix[ page ];
}
}
class SuperUser extends User {
hasAccess( page ) {
return true;
}
}
var su = new SuperUser();
su.hasAccess( 'ADMIN_DASHBOARD' );
> true
Creating abstract classes
Abstract classes are classes that cannot be instantiated. One example is Shape in the above example. Until we know what kind of shape we are talking about, we cannot do much with a generic shape.
Often times, you have a couple of business objects on the same level. Assuming that you are not in the WET (We Enjoy Typing) group of developers, it is natural that you abstract the common functionalities into a base class. For instance, in case of stock trading, you may have a BarChartView, a LineChartView, and a CandlestickChartView. The common functionalities related to these three views are abstracted into a ChartView. If you want to make ChartView abstract, do the following:
class ChartView {
constructor( /* ... */ ) {
if ( this.new === ChartView ) {
throw new Error( 'Abstract class ChartView cannot be instantiated.' );
}
// ...
}
// ...
}
The property new.target contains the class written next to the new keyword during instantiation. This is the name of the class whose constructor was first called in the inheritance chain.
Getters and Setters
Getters and setters are used to create computed properties.
In the below example, I will use > to indicate the response of an expression. Feel free to experiment with the below classes using your Chrome console.
class Square {
constructor( width ) { this.width = width; }
get area() {
console.log( 'getter' );
return this.width * this.width;
}
}
let square = new Square( 5 );
square.area
get area
> 25
square.area = 36
> undefined
square.area
get area
> 25
Note that in the above example, area only has a getter. Setting area does not change anything, as area is a computed property that depends on the width of the square.
For the sake of demonstrating setters, let's define a height computed property.
class Square {
constructor( width ) { this.width = width; }
get height() {
console.log( 'get height' );
return this.width;
}
set height( h ) {
console.log( 'set height', h );
this.width = h;
}
get area() {
console.log( 'get area' );
return this.width * this.height;
}
}
let square = new Square( 5 );
square.width
> 5
square.height
get height
> 5
square.height = 6
set height 6
> 6
square.width
> 6
square.area
get area
get height
> 36
square.width = 4
> 4
square.height
get height
> 4
Width and height can be used as regular properties of a Square object, and the two values are kept in sync using the height getter and setter.
Advantages of getters and setters:
- Elimination of redundancy: computed fields can be derived using an algorithm depending on other properties.
- Information hiding: do not expose properties that are retrievable or settable through getters or setters.
- Encapsulation: couple other functionality with getting/setting a value.
- Defining a public interface: keep these definitions constant and reliable, while you are free to change the internal representation used for computing these fields. This comes handy e.g. when dealing with a DOM structure, where the template may change
- Easier debugging: just add debugging commands or breakpoints to a setter, and you will know what caused a value to change.
Static methods
Static methods are operations defined on a class. These methods can only be referenced from the class itself, not from objects.
class C {
static create() { return new C(); }
constructor() { console.log( 'constructor'); }
}
var c = C.create();
constructor
c.create();
> Uncaught TypeError: e.create is not a function(…)
Exercises
Exercise 1. Create a PlayerCharacter and a NonPlayerCharacter with a common anchestor Character. The characters are located in a 10x10 game field. All characters appear at a random location. Create the three classes, and make sure you can query where each character is.
Exercise 2. Each character has a direction (up, down, left, right). Player characters initially go right, and their direction can be changed using the faceUp, faceDown, faceLeft, faceRight methods. Non-player characters move randomly. A move is automatically taken every 5 seconds in real time. Right after the synchronized moves, each character console logs its position. The player character can only influence the direction he is facing. When a player meets a non-player character, the non-player character is eliminated from the game, and the player's score is increased by 1.
Exercise 3. Make sure the Character class cannot be instantiated.