JS classes are not “just syntactic sugar”

Andrea Giammarchi
5 min readApr 10, 2021

--

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

Using "use strict" directive in ES5 won’t forbid constructors to be called without new keyword.

// 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

Despite my personal attempts since 2004 to subclass Arrays and others, it’s not really possible to extend builtins in ES5 in a useful, or meaningful, way.

// 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

In case you’re wondering “how come list.slice(0) is not instance of List?”, the answer is Symbol.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

In case you’re wondering “how come Array.apply(this, arguments) in the constructor wouldn’t work in ES5?” the answer is that:

  • 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

This is not strictly some JS classes feature, but it’s something many don’t know: methods are not constructible, same as shorthand literal 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

In JS classes both static and non static methods are not enumerable. Yes, we can do that with ES5 too, but the boilerplate is huge, slow, and awkward.

Arrows

In JS classes we can specify arrows in the class definition. We can do the same in ES5 constructors, as well as other public fields, but the class representation is definitively more natural/intuitive, and the definition is internal:

// ES5
function WithArrows() {
this.method1 = () => "arrow 1";
}
// this could be done before (hoisted function)
// or after the class definition, or at any time
WithArrows.method1 = () => "static 1";
// ES6+ keep definition confined within
// its boundaries
class WithArrows {
static method1 = () => "static 1";
method1 = () => "arrow 1";
}
// (new WithArrows).method1();

Privates

In JS classes we have private properties and, recently, private methods too.

// 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

Yes, there are many things that could be simulated via ES5 and old prototypal inheritance, but none of these come out of the box, are as fast, or as safe, as using appropriate syntax for classes and, on top of that, there are things that are just not possible with prototypal inheritance.

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!

--

--

Andrea Giammarchi
Andrea Giammarchi

Written by Andrea Giammarchi

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