Better JavaScript with ES6, Pt. II: A Deep Dive into Classes

Out with the Old, In with the new

Under the hood, ES6 classes are not something that is radically new: They mainly provide more convenient syntax to create old-school constructor functions. ~ Axel Rauschmayer

Functionally, class is little more than syntactic sugar over the prototype-based behavior delegation capabilities we've had all along. This article will take a close look at the basic use of ES2015's class keyword, from the perspective of its relation to prototypes. We'll cover:

Defining and instantiating classes;

Creating subclasses with extends;

super calls from subclasses; and

Examples of important symbol methods.

Along the way, we'll pay special attention to how class maps to prototype-based code under the hood.

Let's take it from the top.

Note: This is part 2 of the Better JavaScript series. Be sure to check out part 1:

A Step Back: What Classes Aren't

JavaScript's "classes" aren't anything like classes in Java, Python, or . . . Really, any other object-oriented language you're likely to have used. Which, by the way, I'll refer to as class-oriented languages, as that's more accurate.

In traditional class-oriented languages, you create classes, which are templates for objects. When you want a new object, you instantiate the class, which tells the language engine to copy the methods and properties of the class into a new entity, called an instance. The instance is your object, and, after instantiation, has absolutely no active relation with the parent class.

JavaScript does not have such copy mechanics. "Instantiating" a class in JavaScript does create a new object, but not one that is independent of its parent class.

Rather, it creates an object that is linked to a prototype. Changes to that prototype propagate to the new object, even after instantiation.

Prototypes are an immensely powerful design pattern in their own right. There are a number of techniques for using them to emulate something like traditional class mechanics, and it's these techniques that class provides compact syntax for.

To summarize:

JavaScript does not have classes, the way that Java and other languages have classes; and

JavaScript's class is (mostly) just syntactical sugar for prototypes, which are very different from traditional classes.

With that out of the way, let's get our feet wet with class.

Base Classes: Declarations & Expressions

You create classes with the class keyword, followed by an identifier, and finally, a code block, called the class body. These are called class declarations. Class declarations that don't use the extends keyword are called base classes:

Everything we discussed above regarding base classe holds true for derived classes, but with a few additional points.

Subclasses are declared with the class keyword, followed by an identifier, and then the extends keyword, followed by an arbitrary expression. This will generally just be an identifier, but could, in theory, be a function.

If your derived class needs to refer to the class it extends, it can do so with the super keyword.

A derived class can't contain an empty constructor. Even if all the constructor does is call super(), you'll still have to do so explicitly. It can, however, contain no constructor.

You must call super in the constructor of a derived class before you use this.

In JavaScript, there are precisely two use cases for the super keyword.

Within subclass constructor calls. If initializing your derived class requires you to use the parent class's constructor, you can call super(parentConstructorParams[ ) within the subclass constructor, passing along any necessary parameters.

To refer to methods in the superclass. Within normal method definitions, derived classes can refer to methods on the parent class with dot notation: super.methodName.

Our FatFreeFood demonstrates both use cases:

In the constructor, we simply call super, passing along 0 as our quantity of fat.

In our print method, we first call super.print, and add additional logic after.

Believe it or not, that wraps up the basic syntactical overview of class; this is all you need to start experimenting.

Three of these four steps are straightforward. Creating an object, assigning properties, and writing a return statement are unlikely to give most developers any conceptual trouble: It's the prototype weirdness that trips people up.

Grokking the Prototype Chain

Under normal circumstances, all objects in JavaScript -- including Functions -- are linked to another object, called its prototype.

If you request a property on an object that the object doesn't have, JavaScript checks the object's prototype for that property. In other words, if you ask for a property on an object that the object doesn't have, it says: "I don't know. Ask my prototype."

This process -- referring lookups for nonexistent properties to another object -- is called delegation.

The output from our toString calls is utterly useless, but note that this snippet doesn't raise a single ReferenceError! That's because, while neither joe or sara has a toString property, their prototype does.

When we look for sara.toString(), sara says, "I don't have a toString property. Ask my prototype." JavaScript, obligingly, does as told, and asks Object.prototype if it has a toString property. Since it does, it hands Object.prototype's toString back to our program, which executes it.

It doesn't matter that sara didn't have the property herself -- we just delegated the lookup to the prototype.

In other words, we can access non-existent properties on an object as long as that object's prototype does have those properties. We can take advantage of this by assigning properties and methods to an object's prototype, so that we can use them as if they existed on the object itself.

Even better, if several objects share the same prototype -- as is the case with joe and sara above -- they can all access that prototype's properties, immediately after we assign them, without our having to copy those properties or methods to each individual object.

This is what people generally refer to as prototypical/prototypal inheritance -- if my object doesn't have it, but my object's prototype does, my object inherits the property.

In reality, there's no "inheritance" going on, here. In class-oriented languages, inheritance implies behavior is copied from a parent to a child. In JavaScript, no such copying takes place -- which is, in fact, one of the major benefits of prototypes over classes.

Here's a quick recap before we see precisely where these prototypes come from:

joe and sara do not "inherit" a toString property;

joe and sara, as a matter of fact, do not "inherit" from Object.prototypeat all;

joe and saraarelinked to Object.prototype;

Both joe and sara are linked to the sameObject.prototype.

To find the prototype of an object -- let's call it O -- you use: Object.getPrototypeOf(O).

And, just to hammer it home: Objects do not "inherit from" their prototypes. They delegate to them.

Period.

Let's dig deeper.

Setting an Object's Prototype

We learned above that (almost) every object (O) has a prototype (P), and that, when you look for a property on O that O doesn't have, the JavaScript engine will look for that property on P instead.

From here, the questions are:

How do functions play into all of this?

Where do these prototypes come from, anyway?

A Function Named Object

Before the JavaScript engine executes a program, it builds an environment to run it in, in which it creates a function, called Object, and an associated object, called Object.prototype.

In other words, Object and Object.prototypealways exist, in any executing JavaScript program.

The function, Object, is like any other function. In particular, it's a constructor -- calling it returns a new object:

"use strict";
typeof new Object(); // "object"
typeof Object(); // A peculiarity of the Object function is that it does /not/ need to be called with new.

The object, Object.prototype, is . . . Well, an object. And, like many objects, it has properties.

Here's what you need to know about Object and Object.prototype:

The function, Object, has a property, called .prototype, which points to an object (Object.prototype);

The object, Object.prototype, has a property, called .constructor, which points to a function (Object).

As it turns out, this general scheme is true for all functions in JavaScript. When you create a function -- someFunction -- it will have a property, .prototype, that points to an object, called someFunction.prototype.

Conversely, that object -- someFunction.prototype -- will have a property, called .constructor, which points back to the function someFunction.

All functions have a property, called .prototype, which points to an object associated with that function.

All function prototypes have a property, called .constructor, which points back to the function.

A function prototype's .constructor does not necessarily point to the function that created the function prototype . . . Confusingly enough. We'll touch on this in greater detail soon.

These are the rules for setting a function's prototype. With that out of the way, we can cover three rules for setting an object's prototype:

The "default" rule;

Setting the prototype implicitly, with new;

Setting the prototype explicitly, with Object.create.

The Default Rule

Consider this snippet:

"use strict";
const foo = { status : 'foobar' };

Refreshingly simple. All we've done is create an object, called foo, and give it a property, called status.

Behind the scenes, however, JavaScript does a little extra work. When we create an object literal, JavaScript sets the object's prototype reference to Object.prototype, and sets its .constructor reference to Object:

At Line A, we have to set FatFreeFood.prototype equal to a new object, whose prototype reference is to Food.prototype. If we fail to do this, our "child classes" won't have access to "superclass" methods.

Unfortunately, this results in the rather bizarre behavior that FatFreeFood.constructor is Function . . . Not FatFreeFood. So, to keep everything sane, we have to manually set FatFreeFood.constructor by hand at Line B.

Sparing developers from the noise and unwieldliness of emulating class behavior with prototypes is one of the motives for the class keyword. It does provide a solution to the most common gotchas of prototype syntax.

Now that we've seen so much of JavaScript's prototype mechanics, it should be easier to appreciate just how much easier it can make things!

A Closer Look at Methods

Now that we've seen the essentials of JavaScript's prototype system, we'll wrap up by taking a closer look at three kinds of methods classes support, and a special case of the last sort:

Constructors;

Static methods;

Prototype methods; and

"Symbol methods", a special case of prototype methods

I didn't come up with these groups -- credit goes to Dr Rauschmayer for identifying them in Exploring ES6.

Class Constructors

A class's constructor function is where you'll focus your initialization logic. The constructor is special in a few ways:

It's the only method of a class from which you can make a superconstructor call;

It handles all the dirty work of setting up the prototype chain properly; and

It acts as the definition of the class.

Point 2 is one of the principle benefits to using class in JavaScript. To quote heading 15.2.3.1 of Exploring ES6:

The prototype of a subclass is the superclass.

As we've seen, setting this up manually is tedious and error-prone. That the language takes care of it all behind the scenes if we use class is a major boon.

Point 3 is interesting. In JavaScript, a class is just a function -- it's equivalent to the constructor method in the class.

Unlike normal-functions-as-constructors, you can't call a class's constructor without the new keyword:

const burrito = Food('Heaven', 100, 100, 25); // TypeError

. . . Which raises another question: What happens when we call a function-as-constructor without new?

The short answer: It returns undefined, as does any function without an explicit return. You just have to trust your users will constructor-call your function. This is why the community has adopted the convention of only capitalizing constructor names: It's a reminder to call with new.

new.target is a property defined on all functions called with new, including class constructors. When you call a function with the new keyword, the value of new.target within the function body is the function itself. If the function wasn't called with new, its value is undefined.

Prototype Methods

Any method that isn't a constructor or a static method is prototype method. The name comes from the fact that we used to achieve this functionality by attaching functions to the .prototype of functions-as-constructors:

Symbol Methods

Finally, there are the symbol methods. These are functions whose names are Symbol values, and which the JavaScript engine recognizes and uses when you use certain built-in constructs with your custom objects.

The MDN docs provide a succinct overview of what Symbols are in general:

A symbol is a unique and immutable data type and may be used as an identifier for object properties.

Creating a new symbol provides you with a value that is guaranteed to be unique within your program. This is what makes it useful for naming object properties: You're guaranteed never to accidentally shadow anything. Symbol-valued keys also aren't innumerable, so they're largely invisible to the outside world (but not completely).

When you use a for...of loop on an object, JavaScript will try to execute the object's iterator function, which is the function associated with the key, Symbol.iterator. If you provide your own definition, JavaScript will use that. If you don't, it'll use the default implementation, if there is one; or do nothing, if there isn't.

Symbol.species is a bit more exotic. In a custom class, the default Symbol.species function is the constructor for your class. When you subclass built-in collections, like Array or Set, however, you'll often want to be able to use your subclass wherever you could use an instance of the parent class.

Returning instances of the parent class from methods on the class instead of instances of the derived class gets us closer to ensuring full interoperability of a subclass with more general code. This is Symbol.species allows.

Don't sweat it if this bit doesn't make much sense. Using symbols this way -- or at all -- is still a niche case, and the point of these examples is to demonstrate:

That you can use certain built-in JavaScript constructs with custom classes; and

How you achieve that, in two common cases.

Conclusion

ES2015's class keyword does not bring us "true classes", a là Java or SmallTalk. Rather, it simply provides a more convenient syntax for creating objects related via prototype linkage. Under the hood, there's nothing new here.

I covered enough of JavaScript's prototype mechanism for our discussion, but there's quite a bit more to say. Read Kyle Simpson's this & Object Prototypes for fuller coverage on that front. Appendix A is particularly relevant.

For the nitty-gritty on ES2015 classes, Dr Rauschmayer's Exploring ES6: Classes should be your go-to resource. It was the inspiration for much of what I've written here.

Finally, if you've got questions, drop a line in the comments, or hit me on Twitter. I'll do my best to get back to everyone directly.

What do you think about class? Love it, hate it, no strong feelings? It seems like everyone's got an opinion -- let us know yours below!

Note: This is part 2 of the Better JavaScript series. You can see parts 1, 2, and 3 here: