Room Full of Mirrors

… and all I could see was me

Thoughts On JavaScript Classes

Lately, the topic of JavaScript classes has come up in some open source projects I participate in. In short, a contributor wants to convert existing prototype defined code to class defined code. I have mostly stayed out of the conversation as my primary position is one of the “old man yells at cloud” variety. But the primary counter argument is that “classes are easier to maintain,” and I’d like to address that in this article.

Background §

It should be well known by now that es2015 introduced the class syntax to JavaScript. I don’t want to rehash everything here, but for the context of this article, we will mainly concern ourselves with the difference between:

// A prototype defined object ("class").
function Person (name) {
  if (!new.target) return new Person(...arguments)
  this._name = name
}

Person.prototype.sayName = function (stream = process.stdout) {
  stream.write(this._name + '\n')
}

And:

// An es2015 class defined object.
class Person {
  #name

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

  sayName (stream = process.stdout) {
    stream.write(this.#name + '\n')
  }
}

Notice that in the prototype based instance we are utilizing a convention for “private” fields, prefixing the field name with an underscore, and in the class based instance we are utilizing language defined syntax for the private field.

A Peek Behind The Curtain §

The line is getting fuzzier with each improvement to the class syntax, but we can still see hints that the syntax is sugar on top of the prototype. Consider this alternate implementation of the Person class:

class Person {
  name

  constructor (name) {
    this.name = name
  }
}

Person.prototype.sayName = function (stream = process.stdout) {
  stream.write(this.name + '\n')
}

So we are able to define the class with a minimum amount of code in the class block, and extend it by adding to the prototype. Thus showing that the class is still a prototype based object after the syntax is interpreted.

Which Is More Maintainable? §

I think the context matters when considering this question. When I started working on the v3.0.0 release of ldapjs I opted to rework the prototype defined objects into class defined objects:

  1. So that I could get familiar with the syntax.
  2. It made sense considering the way the code was structured.
  3. I wanted new contributors to be able to follow the code.

I quickly encountered an issue that continues to frustrate me with the class syntax: objects defined in this way really should have all of the associated code written within the class {} block. First, that’s what makes it legible to programmers who are only familiar with class defined objects. And second, not all code can be defined by extending the prototype.

Look closely at the two class defined objects in this article. In the first, we defined the name field as a private field, and in the second as a public field. Let’s try combining the two:

class Person {
  #name

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

Person.prototype.sayName = function (stream = process.stdout) {
  stream.write(this.#name + '\n')
}

When we try to include that definition in a script we will get a syntax error:

SyntaxError: Private field '#name' must be declared in an enclosing class

In short, we lose access to private fields and methods when we try to extend a class through its prototype. So, if we have an object of any siginificant complexity, we cannot use this “trick” to break up our code into multiple scripts that would be easier to read and maintain unless we make all members of the object public. Instead, we end up with a very large file that is, at least to me, difficult to follow and find focus in. As example, see the base filter class that ended up in ldapjs@3.

If we want to retain the “private” nature of members along with being able to organize our code into self-contained concerns, we can alter our prototype defined object like so:

const symName = Symbol('nameField')

function Person (name) {
  if (!new.target) return new Person(...arguments)
  this[symName] = name
}

Person.prototype.sayName = function (stream = process.stdout) {
  stream.write(this[symName] + '\n')
}

We need to provide access to our symbols in any files we break code out into, but we retain our organizational flexibity along with strong hints to others to keep away from the internals.

Conclusion §

I understand that more and more people are learning JavaScript from the perspective of solely using the class syntax, and that using it makes it easier for them to understand the code. But I think that code organization is an important factor in “maintainability.” I find that having to scroll up and down to remind myself of relevant sibling code is more difficult than having the two pieces opened side-by-side via different files. And I get just as lost as scrolling when trying to have the same file open in two panes while positioned to two different locations.

Ultimately, I think it’s probably best to write new code with class defined objects specifically to have the widest reach for contributors. But I’m not quite convinced it is such a big win that all code needs to be converted. It’d be easier for me to accept that outcome if we had the ability to organize the code as described. I’m not sure I really like the way Golang organizes packages, but it’s quite similar to what I’ve described here for prototype defined objects. Maybe there’s a way to “fix” the private member access from non-block defined members in a future version of the language?