JS classes with ES2015+
By Christophe Porteneuve • Published on 17 January 2022 • 12 min

Cette page est également disponible en français.

Welcome to the third installment of our Idiomatic JS series.

Although JS has always allowed Object-Oriented Programming (OOP), its initial (constrained) choice of prototypal inheritance has confused most JS adopters. However, ever since ES2015, JS has accrued more familiar syntaxes and feature extensions for defining and managing classes and their instances.

You think you already know class, extends, constructor and super? Don’t be so sure… not to mention all the newer stuff. Here’s a solid rundown, full of tasty details.

Traditional / classical OOP vs. prototypal OOP: a refresher

JavaScript and Java were designed at the same time, to be released in the same product (Netscape Navigator 2, late 1995). I already told you that story (FR)… over 10 years ago #NotGettingAnyYounger.

Sun Microsystems’ salespeople pressured Netscape so that JS wouldn’t steal Java’s thunder, as Java was intended to be marketed as the “serious,” “professional” language aiming to replace C++. Because of this, JS was absolutely forbidden to put forth OOP features too similar to Java’s, and Java’s OOP-related keywords were strictly verboten and became reserved words that could not even be used for any identifier or property name (e.g. class, extends, interface, implements, private, public, final…). This is why the DOM has properties such as className (which later percolated in React, for instance, to the sorrow of a great many devs). ES5 (2009) relaxed this to allow such names for property names, by the way. So you can write const obj = { class: 'Foo' } but not const class = 'Foo'.

Still, for Brendan Eich, who created JS, putting forth a lousy language with no OOP capability was out of the question. This is why he went and looked at the whole breadth of OOP, beyond the minimalistic, “traditional” approach, and opted to use prototypal OOP, drawing inspiration from Self. It now lies at the heart of several programming languages, such as IO.

With prototypal inheritance, there are no classes per se, only objects. And any object can serve as a prototype for other objects. An object C can use as prototype an object B, which itself uses object A as its prototype… This is similar to class-based inheritance, but without classes, and perhaps even more crucially with the ability to modify prototype relationships on-the-fly, and the contents of prototype objects themselves, making it all vastly more dynamic!

In pratice, prototypal OOP can emulate all of classical OOP, and can even go much further (for languages that go way beyond classical OOP, just look at Ruby or the OG of OOP: Smalltalk). It allows highly-dynamic typing, live updates to prototypal relationships, to prototypes themselves (e.g. as live mixins), singleton objects, eigenclasses and much, much more.

So what does vanilla prototypal OOP look like?

Say we want to create a base class Shape with a couple methods, then a specializing class Square. We’ll intentionally go for old-school, ES3-style code (pre-2009) and forego the then-unofficial __proto__ property. (In ES5 we could have gone with property descriptors and Object.create(), cleaning up that code quite a bit.) It would go something like this:

// Class + constructor
function Shape(x, y) {
this.moveTo(x, y)
this.fillColor = 'white'
this.strokeColor = 'black'
this.strokeWidth = 1
}
// Instance methods
Shape.prototype.moveTo = function moveTo(x, y) {
this.x = x
this.y = y
}
Shape.prototype.moveBy = function moveBy(dx, dy) {
this.x += dx
this.y += dy
}
Shape.prototype.draw = function draw() {
// Drawing prep code, but not drawing itself, as we don't yet
// know what actual shape we're drawing…
}
// Static methods
Shape.createRandomShape = function createRandomShape() {
return new Shape(Math.random(), Math.random())
}

function Square(x, y, size) {
Shape.call(this, x, y)
this.size = size
}

inherit(Square, Shape)

Square.prototype.draw = function draw() {
Shape.prototype.draw.call(this)
// Actual drawing code based on x, y and size
}

// And here goes the magic utility (for lack of Object.create):
function inherit(Child, Parent) {
function Synth() {}
Synth.prototype = Parent.prototype
Child.prototype = new Synth()
Child.prototype.constructor = Child
}

My eyes! My eyes!!! (right?) I know… And yet, this provides enormous power. But it is massively confusing when coming from more widespread OOP syntactic approaches.

ES2015’s class syntax

ES2015 (long known as ES6) acknowledged two things when it came to OOP in JS:

  • Prototype-related syntax was a major hurdle for most people, not to mention underpinning concepts that felt exotic.
  • Regardless of the OOP approach, a lot of folks felt victim to common pitfalls in their class code, especially when defining class hierarchies.

Therefore, it started by introducing a new class syntax, that felt immediately more familiar and comfortable. To port the previous example:

class Shape {
constructor(x, y) {
this.moveTo(x, y)
this.fillColor = 'white'
this.strokeColor = 'black'
this.strokeWidth = 1
}

static createRandomShape() {
return new Shape(Math.random(), Math.random())
}

moveTo(x, y) {
this.x = x
this.y = y
}

moveBy(dx, dy) {
this.x += dx
this.y += dy
}

draw() {
// Drawing prep code, but not drawing itself, as we don't yet
// know what actual shape we're drawing…
}
}

class Square extends Shape {
constructor(x, y, size) {
super(x, y)
this.size = size
}

draw() {
super.draw()
// Actual drawing code based on x, y and size
}
}

Haaaa, that’s better! It feels a lot more like home!

Hidden gems

It’s not just about familiar syntax, too. This comes with a lot more flexibility than you’d find in most other languages.

For starters, the class itself is an expression, not a declaration. This means you can store a reference to it in a variable, or even return it on-the-fly. So the class could even be anonymous! Look at this:

function wrapCounterWithClass(counterFx) {
return class {
getNextIndex() {
return counterFx()
}
}
}

const WrappedCounter = wrapCounterWithClass(genNumbers)
// …
const counter = new WrappedCounter()
counter.getNextIndex() // etc.

This is because under the hood, a class remains a function, the new syntax doesn’t change the underlying implementation based on functions and prototypes:

typeof class {} // => 'function'

Let’s one-up that: the extends clause also accepts an expression, not just a fixed identifier. You could extend a class that was provided to you dynamically, or even built on-the-fly through function calls:

function subClass(parentClass) {
return class extends parentClass {
// …
}
}

function makeClassWithMixins(...mixins) {
return class extends buildMixinPrototypeChain(mixins) {
// … (the 'buildMixinPrototypeChain' function would need to be
// implemented manually, JS doesn't provide a built-in one.)
}
}

Also note that a class definition being an expression means it is not hoisted, unlike function declarations. Just like the two other declarative keywords from ES2015 (const and let), what it declares isn’t accessible until after it’s run:

new Person() // => ReferenceError

class Person {}

Not just syntax, but extra protections

Using the modern syntax for defining classes goes well beyond simple writing comfort: JS took this opportunity to shield you against a number of common pitfalls and tighten a few bolts as it went.

First, considering a class body didn’t exist pre-ES2015, it doesn’t have to maintain backward compatibility. This means that an ES2015 class body is automatically in strict mode, which provides a number of protections, the most important of which is likely a better behavior of this when the function reference isn’t called immediately (it will be undefined instead of referencing the global object).

There is also the common pitfall of forgetting the new operator when instantiating a class, which would result in the constructor being used like a regular function. Old-school code would have had to handle this scenario manually, to avoid inadvertently polluting the global scope and returning undefined instead of the new instance:

// ⚠ Old-school, no protection
function Person(first, last) {
this.first = first
this.last = last
}

const alice = Person('Grace', 'Hopper')
alice // => undefined 😱
first // => 'Grace' 😱
last // => 'Hopper' 😱

// Explicit (but manual) protection
function Person(first, last) {
if (!(this instanceof Person)) {
throw new TypeError(
"Class constructor Person cannot be invoked without 'new'"
)
}
this.first = first
this.last = last
}

const bob = Person('Bob', 'Sponge')
// => TypeError!

JS functions have an internal marker (what the spec calls an internal slot) called [[IsClassConstructor]], which is true for functions representing classes defined with class. The [[Call]] operation in the JS engine, which implements the regular call of a function (as opposed to [[Construct]], which handles calls via new), checks this marker and, if true, throws a TypeError. This means we are automatically protected against forgetting new.

Another common pitfall occurs when a class specializes another (through inheritance) but forgets to call the parent constructor in its own, or calls it after it started initializing fields:

class Person {
constructor(first, last) {
this.first = first
this.last = last
}
}

// Case 1: I forget to call the parent constructor
class Geek extends Person {
constructor(first, last, nick) {
this.nick = nick
}
}

// Case 2: I do call it, but after having initialized stuff on `this`
class Geek extends Person {
constructor(first, last, nick) {
this.nick = nick
super(first, last)
}
}

JS doesn’t automagically call the parent constructor in the child constructor, if only because they don’t necessarily have matching signatures, but also because we might want to preprocess arguments before we forward some of them. It’s up to us to make an explicit parent call.

Not calling the parent constructor inside our own is a bit like building walls without any foundations! It’s unstable, even flimsy.

The risk with the second scenario here is that our parent constructor may later evolve to initialize some of the fields we wrote to in our child constructor, effectively erasing our own initialization, as they happened earlier. Say the Person constructor grows to become this:

class Person {
constructor(first, last) {
this.first = first
this.last = last
this.nick = null
}
}

We would suddenly have intances of Geek where nick is null, despite it looking fine in our own constructor, and although it used to work fine and the code for Geek hasn’t changed one bit! This is hard to debug.

For all these reasons, ES2015 constructors mandate that when inside a class specializing another, we call the parent constructor, and before any reference to this, too. If we don’t, instantiating our class wil lthrow a ReferenceError with a pretty explicit message:

new Geek('Thomas', 'Anderson')
// => ReferenceError: Must call super constructor in derived class
// before accessing 'this' or returning from derived constructor

We are therefore protected against that type of issue.

Finally, when defining old-school classes, simply adding properties to prototypes created enumerable properties, which polluted for…in loops, among other things:

// ⚠ Old-school, fugly™
function Character(first, last) {
this.first = first
this.last = last
}
Character.prototype.greet = function greet(whom) {
return 'Hi ' + whom + ', my name is ' + this.first
}

const jamie = new Character('Nomi', 'Marks')
for (var prop in jamie) {
console.log(prop)
}
// first, last, greet 😱

In ES5, it would have taken property descriptors to mark these as non-enumerable:

// ⚠ Old-school, fugly™
function Character(first, last) {
this.first = first
this.last = last
}
Object.defineProperty(Character.prototype, 'greet', {
// Well, this also marks it as non-removable and non-modifiable,
// but that's not a bad side effect.
value: function greet(whom) {
return 'Hi ' + whom + ', my name is ' + this.first
},
})

const jamie = new Character('Nomi', 'Marks')
for (var prop in jamie) {
console.log(prop)
}
// first, last

So here’s another piece of good news: instance methods defined in an ES2015 clas body are automatically non-enumerable. I mean, duh.

Interoperability with the old way

Again, under the hood classes remain implemented the exact same way: these are functions with their prototypes. Your legacy know-how thus remains usable, and there is full interoperability between classes defined both ways.

In particular, they can inherit from each other (regardless of the direction) with no issue.

Anything new since ES2015?

Most folks look at what ES2015 introduced and stop at that. Here’s the full list of these features:

  • class
  • extends
  • constructor
  • super
  • static methods

But there’s been a ton of OOP-related work since then, even if it went through quite a few back-and-forths, dead-ends, detours and refactorings, and ES2022 finally released the culmination of that massive amount of work. All of this is natively supported in evergreen browsers (as always with JS, check the details on Safari though) and Node 16+ (and if you need older runtimes, Babel’s got your back).

Let’s review this newer, exciting stuff.

Field initializers

This feature became popular in part because of React tutorials; field initializers notably let you avoid having to write a constructor just to initialize some instance fields. let’s say you have the following class:

class Crypto {
cipher() {
/* … */
}
decipher() {
/* … */
}
}

Now say we want to setup an internal buffer at construction time, as our new implementation needs it. Before ES2022, we would have had to create a constructor for this:

// Pre-ES2022, a constructor was needed
class Crypto {
constructor() {
this.buffer = new Uint8Array(256)
}

cipher() {
/* … */
}
decipher() {
/* … */
}
}

We can now initialize that directly from the class body:

class Crypto {
buffer = new Uint8Array(256)

cipher() {
/* … */
}
decipher() {
/* … */
}
}

In React, class-based components (which were mostly replaced by function components, removing that need entirely) used this a lot:

class OldSchoolComponent extends React.Component {
state = { visible: this.props.initialVisible, collapsed: true }

// …

render() {
/* … */
}
}

Notice how the initialization expression can use this, which references the fresh instance. This is truly the equivalent of that same code inside the constructor, prefixed by this.. Something like what follows:

// "Desugaring" the previous instance field initializer
class OldSchoolComponent extends React.Component {
constructor(...args) {
super(...args)
this.state = { visible: this.props.initialVisible, collapsed: true }
}

// …
}

You can also initialize static fields, not just instance ones. This avoids having to wait for the class body to end before slapping them on top, like we used to have to do:

class OldSchoolComponent extends React.Component {
static defaultProps = {
initialVisible: false,
}

static propTypes = {
initialVisible: bool.isRequired,
items: arrayOf(ItemPropType).isRequired,
}

state = { visible: this.props.initialVisible, collapsed: true }

// …

render() {
/* … */
}
}

Truly private members

When ES2015 introduced its class syntax, it got significant angry feedback at the lack of familiar qualifiers such as private, protected and public.

First, please understand that in most languages, there qualifier are more of an “FYI” thing than a safety thing. They are seldom ironclad. For instance:

  • In Java, the reflection API lets you read a private field or call a private method, unless explicitly prevented by an active security manager (e.g. obj.class.getDeclaredField('secret').get(obj)).
  • In C#, the reflection API also lets you do that (notably with GetField and BindingFlags).
  • Same for PHP (via ReflectionProperty).
  • In Python, the private aspect boils down to a naming convention (__ prefix) that doesn’t prevent external access, even if this requires a prefix.
  • In Ruby, the .send method lets you read or call private members of an object.
  • etc.

In summary, these qualifiers are more like hints; they’re not security protections. They just tell us “watch out, this is my internal cooking, touch it at your own risks, it might blow up and I’m free to trash all of that in my next release if I want to.”

JS, on the other hand, runs in environments where our code is not alone (a web page with, most of the time, tons of third-party scripts that can be quite shady), so the security mandate is a lot stronger.

Achieving instance-level private data could be done by jumping through flamey hoops based on WeakMap and module-local declarations, but that was quite unwieldy.

ES2022 therefore brings a syntax letting us mark our members (fields and methods, instance and static) as truly private. Here “private” should be understood as “inaccessible outside of the container class’ code.” Only the code inside the relevant class’ body can access them.

This means that instance A can access private members of instance B if they are of the same class: they share the same source code, so it wouldn’t make sense to prevent one from accessing data from the other, as the developer could easily circumvent that by just adding workaround code to the class.

Private members prefix their identifiers with a hash sign (#), both at declaration and indexation time, which has the neat side effect of immediately signaling that we’re looking up / calling a private member. Private fields also must be declared before they’re used (there’s no on-the-fly creation like we have for non-private fields); you generally do this at the top of the class body.

class PasswordManager {
static #STORAGE_KEY = Symbol('pm:storage-key')
#key

constructor(key) {
this.#key = key
}

addIdentity(identity) {
const store = this.#open()
store.add(identity)
store.persist()
}

#open() {
return CryptoStorage.get(PasswordManager.#STORAGE_KEY).open(this.#key)
}
}

This hash character is not part of the member’s name, but that method or property cannot be used without it.

Private members are absent from any mechanism for listing members (e.g. Object.keys(), Object.getOwnPropertyNames(), etc.) and cannot be index dynamically (with [] or ?.[]).

Learn more with Axel or in the MDN.

Static initialization blocks

Instances can be set up through their constructor, but what about classes? Sometimes we need to setup a class’ static fields from an external context, which requires code.

It is indeed possible to do this after the class body, in the remainder of the module that defines it. But that’s kind of a shame. We did have static field initializers (that took a single expression) and static methods: enter static initialization blocks.

They let us initialize multiple static fields in one fell swoop, or simply initialize them by using static private members (which couldn’t be accessed from outside the class’ code anyway).

Within a static initialization block or a static field initializer, this references the class itself.

Here’s an example I stole from Axel Rauschmayer:

class Translator {
static translations = {
yes: 'ja',
no: 'nein',
maybe: 'vielleicht',
}
static englishWords = []
static germanWords = []
static {
// Snap! We can initialize two fields using the same operation,
// instead of having to perform it twice, which can be needlessly
// long / intensive or even limited by outer constraints.
for (const [english, german] of Object.entries(this.translations)) {
this.englishWords.push(english)
this.germanWords.push(german)
}
}
}

Note that you can have multiple static initialization blocks in the same class body. They are run, along with other static initializers, top to bottom.

Again, there’s more at Axel’s and in the MDN.

What’s next?

Woah, quite a few things! We won’t spend too much time on it, but a lot of stuff is at early stages of the standardization process, although some of it hasn’t been presented to the standard commitee (TC39) for years.

There’s a bunch of cool stuff though:

Decorators

TypeScript fans know them well, and many frameworks rely heavily on them (Angular, Nest…). They should have become official a long time ago but their entire spec was sent back to the drawing board at the last minute when a blocking issue was belatedly discovered.

They’re now (February 2023) at stage 3 and are actively being worked on. They’ll get there, it just takes a lot of work.

Decorators are similar to Java’s annotations (although more powerful) or PHP’s attributes, to name a couple examples. They’re a comfortable way of doing Aspect-Oriented Programming (AOP), with in-situ metadata for classes and methods.

The language provides the core mechanics for decorators, and leaves it to the ecosystem to provide decorators for specific needs. Many npm modules provide decorators (such as the infamous @autobind) and many frameworks provide their custom ones:

// Angular 2+
import { Component, OnInit } from '@angular/core'

@Component({
selector: 'app-product-alerts',
templateUrl: './product-alerts.component.html',
styleUrls: ['./product-alerts.component.css'],
})
export class ProductAlertsComponent implements OnInit {
// …
}

// Nest.js
import { Controller, Get } from '@nestjs/common'

@Controller('cats')
export class CatsController {
@Get()
findAll(): string {
return 'This action returns all cats'
}
}

Stuff that is more on the fence…

We might become able to destructure private members, make private members accessible from a broader scope than the class’ body, ease access to static members from instance code, simplify calling generic methods on specific objects or even improve accessor definitions.

There’s always more!

Besides our wealth of other articles, you may wish to look at our live training courses! In particular, if you like super-deep dives into JavaScript itself, we heartily recommend our 360° ES course!