JS classes are not “just syntactic sugar”

Photo by Umanoide on Unsplash

After reading yet another blog post about JS classes being “just sugar for prototypal inheritance”, I’ve decided to write this post to help clarifying, one more time, why this statement is misleading; a post that hopefully explains what’s the difference and why it matters to understand it.

Strictly guarded by default

// ES5
function Test() { "use strict"; }
Test.call({}); // it's OK
// ES6+
class Test {}
Test.call({}); // it throws

The reason is that modern classes have a new.target concept otherwise impossible to replicate in ES5, without using transpilers that simulate such behavior. Transpiler must use an instanceof check too, resulting in slower, bloated, code.

Builtin Extends

// ES5 epic fail
function List() { "use strict"; }
List.prototype = Object.create(Array.prototype);
var list = new List;
list.push(1, 2, 3);
JSON.stringify(list);
// {"0":1,"1":2,"2":3,"length":3}
list.slice(0) instanceof List; // false

Let’s ignore the fact I am not even using Array.apply(this, arguments) in the constructor, as that also will fail expectations, an ES5 Array extend is awkward no matter how you look at it, and so is every other builtin, including String or others.

// ES5 epic fail v2
function Text(value) {
"use strict";
String.call(this, value);
}
new Text("does this work?");
// nope, it doesn't ... no way it can.

OK, I hear you, “who’s gonna need to extend String mate?”, and you’re right: you might not need to do so, but the point here is that it’s impossible to do it with ES5, or better: the prototypal inheritance cannot do that, JS classes can.

Species

// ES6+
class List extends Array {}
(new List).slice(0) instanceof List; // true
[].slice.call(new List) instanceof List; // true

Accordingly, unless you are guarding every single method to return the initial kind of instance one would expect through all Array methods, ES5 here is just painful, unreliable, fragile, and not designed to work with species.

That’s it: JS classes are much better at preserving expectations than ES5.

The super

  • Array builtin creates a new array, it doesn’t care at all about the context, and neither do other builtins
  • JS classes promote/upgrade instances, which is something impossible to do in ES5 without a transpiler and, even with the transpiler, it’s a mess when it comes to builtin extends
// ES5
function Button() {
return document.createElement('button');
}
function MyButton(value) {
Button.call(this);
this.textContent = value;
}
Object.setPrototypeOf(MyButton, Button);
Object.setPrototypeOf(MyButton.prototype, Button.prototype);

What do you think will happen once new MyButton("content") is called?

  • a button with the text value is returned
  • an instance of MyButton with a textContent property is returned

And yes, the correct answer is the latter one, so that unless we write all sub-classes as such:

function MySubClass() {
var self = Class.apply(this, arguments) || this;
// do anything with the self
return self;
}

our expectations will fail … and what’s wrong with this approach anyway?

  • if the super class returns something else we lose the inheritance
  • if the super class is a Builtin, we might have a self that points at a primitive instead

So here another variant, that fixes the first point, but not the latter one:

function MySubClass() {
var self = Class.apply(this, arguments);
if (self == null)
self = this;
else if (!(self instanceof MySubClass))
Object.setPrototypeOf(self, MySubClass.prototype);
// do things with self
return self;
}

Now, let’s see how JS classes work instead:

// ES6+
class Button {
constructor() {
return document.createElement('button');
}
}
class MyButton extends Button {
constructor(value) {
super();
this.textContent = value;
}
}
document.body.appendChild(new MyButton("hello"));

Once again: should we write code like this? It depends.

Should we say JS classes are much better and powerful than ES5? Yes!

Methods

// ES6+
class Test {
method() {}
}
new Test.prototype.method;
// TypeError: Test.prototype.method is not a constructor

In ES5 all functions can be used as constructor, and there’s no way to avoid this unless we check the context brand each time.

// ES5
function Test() {}
Test.prototype.method = function () {
if (!(this instanceof Test))
throw new TypeError("not a constructor");
};

Do you see yourself writing code like this to assert JS classes are just sugar?

Enumerability

Arrows

// ES5
function WithArrows() {
Object.defineProperties(this, {
method1: {
configurable: true,
writable: true,
value: () => "arrow 1"
}
});
}
// ES6+
class WithArrows {
method1 = () => "arrow 1";
}
// (new WithArrows).method1();

Privates

// ES6+
class WithPrivates {
#value;
#method(value) {
this.#value = value;
}
constructor(value) {
this.#method(value);
}
}

Could we simulate privates in ES5? Not really, unless we use a transpiler and WeakMap to trap each instance with specific privates that should never leak out there, with no guards in case such leak happens.

As Summary

Accordingly, let’s please stop saying that JS classes are just sugar, because the amount of details missing in such statement cannot be overlooked, or ignored, unless we decide that we don’t want to use modern classes features that could make OOP in JS much better than it’s been for the last 20, prototypal, years.

In such case though, the right statement would be more like: “I don’t like JS classes hence I think we were just OK with prototypal inheritance”.

Now that’s a much more honest, and correct, statement … while saying classes are “just sugar” is a very poor idea of modern JS and its features.

Thanks for spreading the word to all those developers that ignore all the differences!

Web, Mobile, IoT, and all JS things since 00's. Formerly JS engineer at @nokia, @facebook, @twitter.

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store