Classes
From nearly the beginning of JavaScript, syntax and development patterns have all strived (read: struggled) to put on a facade of supporting class-oriented development. With things like new
and instanceof
and a .constructor
property, who couldn’t help but be teased that JS had classes hidden somewhere inside its prototype system?
Of course, JS “classes” aren’t nearly the same as classical classes. The differences are well documented, so I won’t belabor that point any further here.
Note: To learn more about the patterns used in JS to fake “classes,” and an alternative view of prototypes called “delegation,” see the second half of the this & Object Prototypes title of this series.
class
Although JS’s prototype mechanism doesn’t work like traditional classes, that doesn’t stop the strong tide of demand on the language to extend the syntactic sugar so that expressing “classes” looks more like real classes. Enter the ES6 class
keyword and its associated mechanism.
This feature is the result of a highly contentious and drawn-out debate, and represents a smaller subset compromise from several strongly opposed views on how to approach JS classes. Most developers who want full classes in JS will find parts of the new syntax quite inviting, but will find important bits still missing. Don’t worry, though. TC39 is already working on additional features to augment classes in the post-ES6 timeframe.
At the heart of the new ES6 class mechanism is the class
keyword, which identifies a block where the contents define the members of a function’s prototype. Consider:
class Foo {
constructor(a,b) {
this.x = a;
this.y = b;
}
gimmeXY() {
return this.x * this.y;
}
}
Some things to note:
class Foo
implies creating a (special) function of the nameFoo
, much like you did pre-ES6.constructor(..)
identifies the signature of thatFoo(..)
function, as well as its body contents.- Class methods use the same “concise method” syntax available to object literals, as discussed in Chapter 2. This also includes the concise generator form as discussed earlier in this chapter, as well as the ES5 getter/setter syntax. However, class methods are non-enumerable whereas object methods are by default enumerable.
- Unlike object literals, there are no commas separating members in a
class
body! In fact, they’re not even allowed.
The class
syntax definition in the previous snippet can be roughly thought of as this pre-ES6 equivalent, which probably will look fairly familiar to those who’ve done prototype-style coding before:
function Foo(a,b) {
this.x = a;
this.y = b;
}
Foo.prototype.gimmeXY = function() {
return this.x * this.y;
}
In either the pre-ES6 form or the new ES6 class
form, this “class” can now be instantiated and used just as you’d expect:
var f = new Foo( 5, 15 );
f.x; // 5
f.y; // 15
f.gimmeXY(); // 75
Caution! Though class Foo
seems much like function Foo()
, there are important differences:
- A
Foo(..)
call ofclass Foo
must be made withnew
, as the pre-ES6 option ofFoo.call( obj )
will not work. - While
function Foo
is “hoisted” (see the Scope & Closures title of this series),class Foo
is not; theextends ..
clause specifies an expression that cannot be “hoisted.” So, you must declare aclass
before you can instantiate it. class Foo
in the top global scope creates a lexicalFoo
identifier in that scope, but unlikefunction Foo
does not create a global object property of that name.
The established instanceof
operator still works with ES6 classes, because class
just creates a constructor function of the same name. However, ES6 introduces a way to customize how instanceof
works, using Symbol.hasInstance
(see “Well-Known Symbols” in Chapter 7).
Another way of thinking about class
, which I find more convenient, is as a macro that is used to automatically populate a prototype
object. Optionally, it also wires up the [[Prototype]]
relationship if using extends
(see the next section).
An ES6 class
isn’t really an entity itself, but a meta concept that wraps around other concrete entities, such as functions and properties, and ties them together.
Tip: In addition to the declaration form, a class
can also be an expression, as in: var x = class Y { .. }
. This is primarily useful for passing a class definition (technically, the constructor itself) as a function argument or assigning it to an object property.
extends
and super
ES6 classes also have syntactic sugar for establishing the [[Prototype]]
delegation link between two function prototypes — commonly mislabeled “inheritance” or confusingly labeled “prototype inheritance” — using the class-oriented familiar terminology extends
:
class Bar extends Foo {
constructor(a,b,c) {
super( a, b );
this.z = c;
}
gimmeXYZ() {
return super.gimmeXY() * this.z;
}
}
var b = new Bar( 5, 15, 25 );
b.x; // 5
b.y; // 15
b.z; // 25
b.gimmeXYZ(); // 1875
A significant new addition is super
, which is actually something not directly possible pre-ES6 (without some unfortunate hack trade-offs). In the constructor, super
automatically refers to the “parent constructor,” which in the previous example is Foo(..)
. In a method, it refers to the “parent object,” such that you can then make a property/method access off it, such as super.gimmeXY()
.
Bar extends Foo
of course means to link the [[Prototype]]
of Bar.prototype
to Foo.prototype
. So, super
in a method like gimmeXYZ()
specifically means Foo.prototype
, whereas super
means Foo
when used in the Bar
constructor.
Note: super
is not limited to class
declarations. It also works in object literals, in much the same way we’re discussing here. See “Object super
“ in Chapter 2 for more information.
There Be super
Dragons
It is not insignificant to note that super
behaves differently depending on where it appears. In fairness, most of the time, that won’t be a problem. But surprises await if you deviate from a narrow norm.
There may be cases where in the constructor you would want to reference the Foo.prototype
, such as to directly access one of its properties/methods. However, super
in the constructor cannot be used in that way; super.prototype
will not work. super(..)
means roughly to call new Foo(..)
, but isn’t actually a usable reference to Foo
itself.
Symmetrically, you may want to reference the Foo(..)
function from inside a non-constructor method. super.constructor
will point at Foo(..)
the function, but beware that this function can only be invoked with new
. new super.constructor(..)
would be valid, but it wouldn’t be terribly useful in most cases, because you can’t make that call use or reference the current this
object context, which is likely what you’d want.
Also, super
looks like it might be driven by a function’s context just like this
— that is, that they’d both be dynamically bound. However, super
is not dynamic like this
is. When a constructor or method makes a super
reference inside it at declaration time (in the class
body), that super
is statically bound to that specific class hierarchy, and cannot be overridden (at least in ES6).
What does that mean? It means that if you’re in the habit of taking a method from one “class” and “borrowing” it for another class by overriding its this
, say with call(..)
or apply(..)
, that may very well create surprises if the method you’re borrowing has a super
in it. Consider this class hierarchy:
class ParentA {
constructor() { this.id = "a"; }
foo() { console.log( "ParentA:", this.id ); }
}
class ParentB {
constructor() { this.id = "b"; }
foo() { console.log( "ParentB:", this.id ); }
}
class ChildA extends ParentA {
foo() {
super.foo();
console.log( "ChildA:", this.id );
}
}
class ChildB extends ParentB {
foo() {
super.foo();
console.log( "ChildB:", this.id );
}
}
var a = new ChildA();
a.foo(); // ParentA: a
// ChildA: a
var b = new ChildB(); // ParentB: b
b.foo(); // ChildB: b
All seems fairly natural and expected in this previous snippet. However, if you try to borrow b.foo()
and use it in the context of a
— by virtue of dynamic this
binding, such borrowing is quite common and used in many different ways, including mixins most notably — you may find this result an ugly surprise:
// borrow `b.foo()` to use in `a` context
b.foo.call( a ); // ParentB: a
// ChildB: a
As you can see, the this.id
reference was dynamically rebound so that : a
is reported in both cases instead of : b
. But b.foo()
‘s super.foo()
reference wasn’t dynamically rebound, so it still reported ParentB
instead of the expected ParentA
.
Because b.foo()
references super
, it is statically bound to the ChildB
/ParentB
hierarchy and cannot be used against the ChildA
/ParentA
hierarchy. There is no ES6 solution to this limitation.
super
seems to work intuitively if you have a static class hierarchy with no cross-pollination. But in all fairness, one of the main benefits of doing this
-aware coding is exactly that sort of flexibility. Simply, class
+ super
requires you to avoid such techniques.
The choice boils down to narrowing your object design to these static hierarchies — class
, extends
, and super
will be quite nice — or dropping all attempts to “fake” classes and instead embrace dynamic and flexible, classless objects and [[Prototype]]
delegation (see the this & Object Prototypes title of this series).
Subclass Constructor
Constructors are not required for classes or subclasses; a default constructor is substituted in both cases if omitted. However, the default substituted constructor is different for a direct class versus an extended class.
Specifically, the default subclass constructor automatically calls the parent constructor, and passes along any arguments. In other words, you could think of the default subclass constructor sort of like this:
constructor(...args) {
super(...args);
}
This is an important detail to note. Not all class languages have the subclass constructor automatically call the parent constructor. C++ does, but Java does not. But more importantly, in pre-ES6 classes, such automatic “parent constructor” calling does not happen. Be careful when converting to ES6 class
if you’ve been relying on such calls not happening.
Another perhaps surprising deviation/limitation of ES6 subclass constructors: in a constructor of a subclass, you cannot access this
until super(..)
has been called. The reason is nuanced and complicated, but it boils down to the fact that the parent constructor is actually the one creating/initializing your instance’s this
. Pre-ES6, it works oppositely; the this
object is created by the “subclass constructor,” and then you call a “parent constructor” with the context of the “subclass” this
.
Let’s illustrate. This works pre-ES6:
function Foo() {
this.a = 1;
}
function Bar() {
this.b = 2;
Foo.call( this );
}
// `Bar` "extends" `Foo`
Bar.prototype = Object.create( Foo.prototype );
But this ES6 equivalent is not allowed:
class Foo {
constructor() { this.a = 1; }
}
class Bar extends Foo {
constructor() {
this.b = 2; // not allowed before `super()`
super(); // to fix swap these two statements
}
}
In this case, the fix is simple. Just swap the two statements in the subclass Bar
constructor. However, if you’ve been relying pre-ES6 on being able to skip calling the “parent constructor,” beware because that won’t be allowed anymore.
extend
ing Natives
One of the most heralded benefits to the new class
and extend
design is the ability to (finally!) subclass the built-in natives, like Array
. Consider:
class MyCoolArray extends Array {
first() { return this[0]; }
last() { return this[this.length - 1]; }
}
var a = new MyCoolArray( 1, 2, 3 );
a.length; // 3
a; // [1,2,3]
a.first(); // 1
a.last(); // 3
Prior to ES6, a fake “subclass” of Array
using manual object creation and linking to Array.prototype
only partially worked. It missed out on the special behaviors of a real array, such as the automatically updating length
property. ES6 subclasses should fully work with “inherited” and augmented behaviors as expected!
Another common pre-ES6 “subclass” limitation is with the Error
object, in creating custom error “subclasses.” When genuine Error
objects are created, they automatically capture special stack
information, including the line number and file where the error is created. Pre-ES6 custom error “subclasses” have no such special behavior, which severely limits their usefulness.
ES6 to the rescue:
class Oops extends Error {
constructor(reason) {
super(reason);
this.oops = reason;
}
}
// later:
var ouch = new Oops( "I messed up!" );
throw ouch;
The ouch
custom error object in this previous snippet will behave like any other genuine error object, including capturing stack
. That’s a big improvement!
new.target
ES6 introduces a new concept called a meta property (see Chapter 7), in the form of new.target
.
If that looks strange, it is; pairing a keyword with a .
and a property name is definitely an out-of-the-ordinary pattern for JS.
new.target
is a new “magical” value available in all functions, though in normal functions it will always be undefined
. In any constructor, new.target
always points at the constructor that new
actually directly invoked, even if the constructor is in a parent class and was delegated to by a super(..)
call from a child constructor. Consider:
class Foo {
constructor() {
console.log( "Foo: ", new.target.name );
}
}
class Bar extends Foo {
constructor() {
super();
console.log( "Bar: ", new.target.name );
}
baz() {
console.log( "baz: ", new.target );
}
}
var a = new Foo();
// Foo: Foo
var b = new Bar();
// Foo: Bar <-- respects the `new` call-site
// Bar: Bar
b.baz();
// baz: undefined
The new.target
meta property doesn’t have much purpose in class constructors, except accessing a static property/method (see the next section).
If new.target
is undefined
, you know the function was not called with new
. You can then force a new
invocation if that’s necessary.
static
When a subclass Bar
extends a parent class Foo
, we already observed that Bar.prototype
is [[Prototype]]
-linked to Foo.prototype
. But additionally, Bar()
is [[Prototype]]
-linked to Foo()
. That part may not have such an obvious reasoning.
However, it’s quite useful in the case where you declare static
methods (not just properties) for a class, as these are added directly to that class’s function object, not to the function object’s prototype
object. Consider:
class Foo {
static cool() { console.log( "cool" ); }
wow() { console.log( "wow" ); }
}
class Bar extends Foo {
static awesome() {
super.cool();
console.log( "awesome" );
}
neat() {
super.wow();
console.log( "neat" );
}
}
Foo.cool(); // "cool"
Bar.cool(); // "cool"
Bar.awesome(); // "cool"
// "awesome"
var b = new Bar();
b.neat(); // "wow"
// "neat"
b.awesome; // undefined
b.cool; // undefined
Be careful not to get confused that static
members are on the class’s prototype chain. They’re actually on the dual/parallel chain between the function constructors.
Symbol.species
Constructor Getter
One place where static
can be useful is in setting the Symbol.species
getter (known internally in the specification as @@species
) for a derived (child) class. This capability allows a child class to signal to a parent class what constructor should be used — when not intending the child class’s constructor itself — if any parent class method needs to vend a new instance.
For example, many methods on Array
create and return a new Array
instance. If you define a derived class from Array
, but you want those methods to continue to vend actual Array
instances instead of from your derived class, this works:
class MyCoolArray extends Array {
// force `species` to be parent constructor
static get [Symbol.species]() { return Array; }
}
var a = new MyCoolArray( 1, 2, 3 ),
b = a.map( function(v){ return v * 2; } );
b instanceof MyCoolArray; // false
b instanceof Array; // true
To illustrate how a parent class method can use a child’s species declaration somewhat like Array#map(..)
is doing, consider:
class Foo {
// defer `species` to derived constructor
static get [Symbol.species]() { return this; }
spawn() {
return new this.constructor[Symbol.species]();
}
}
class Bar extends Foo {
// force `species` to be parent constructor
static get [Symbol.species]() { return Foo; }
}
var a = new Foo();
var b = a.spawn();
b instanceof Foo; // true
var x = new Bar();
var y = x.spawn();
y instanceof Bar; // false
y instanceof Foo; // true
The parent class Symbol.species
does return this
to defer to any derived class, as you’d normally expect. Bar
then overrides to manually declare Foo
to be used for such instance creation. Of course, a derived class can still vend instances of itself using new this.constructor(..)
.