Why JavaScript's class is Not Just Syntactic Sugar
The class
syntax in JavaScript is often described as "syntactic sugar" for normal constructor functions, meaning it's just a different way of doing the same thing. But this is not entirely true. While JavaScript continues to use constructor functions to represent its version of classes - even when using the class
syntax - constructors created using class
are quite different from those created through normal function declarations.
A Simple Desugaring
First we'll look at a breakdown of a class
-defined constructor roughly see how it would desugar into a standard JavaScript function. Given:
class Foo {}
It would be approximately equivalent to:
let Foo = function () {}
At its simplest, this is what class
is doing, creating a function. As with normal function declarations, all the plumbing that is necessary for using this definition as a constructor, for example, the presence of a prototype
property, is all there.
Next, a more complicated example:
class Foo {
constructor (input) {
this.input = input
}
getInput () {
return this.input
}
}
class Bar extends Foo {
constructor (input) {
super(input)
}
getInput () {
const input = super.getInput()
return `Bar: ${input}`
}
}
This would roughly desugar to:
let Foo = function (input) {
this.input = input
}
Foo.prototype.getInput = function () {
return this.input
}
let Bar = function (input) {
Object.getPrototypeOf(Bar).call(this, input)
}
Bar.prototype.getInput = function () {
const input = Object.getPrototypeOf(Bar.prototype).getInput.call(this)
return `Bar: ${input}`
}
Object.setPrototypeOf(Bar, Foo)
Object.setPrototypeOf(Bar.prototype, Foo.prototype)
Here, the details around the implementation of the inheritance becomes more apparent in the desugaring. When one class extends another, the prototype chain is updated to include that of the subclass's. The class's themselves are also linked for inheriting static definitions. Also we can see how calls with super
resolve into calls from the superclass against the current instance.
For the most part, the original and the desugaring are equivalent definitions, but there are differences you should be aware of.
class
Functions Are Not Callable Without new
One feature of class
functions is that you are required to call them with new
. Attempting to call them as normal functions will throw an error.
class Foo {}
Foo() // Error
For better or worse, this is a protection keeping you from accidentally calling constructor code against an unexpected this
value. There may be cases where you might want this behavior, for example if re-using the constructor as a conversion function (not unlike what you see with String()
and Number()
etc.), but with the class
syntax, this is not allowed.
You can emulate this behavior with the function
constructor syntax (assuming you want it):
let Foo = function () {
if (new.target === undefined) {
throw new Error()
}
}
However, unlike the class
error, this error is thrown after the call to the function is already made. For class
functions, the error is thrown before the function gets a chance to be called. It's a subtle difference, but a difference nevertheless.
Note that while you cannot call a class
-defined constructor as a function, it is still technically a callable. What causes the error is a specific behavior to class functions, not the fact that the function object is not callable.
class
Functions Can Correctly Extend Built-ins
At a high level, there are two categories of objects in JavaScript: ordinary objects and exotic objects. Ordinary objects are objects anyone can create in JavaScript using the Object
constructor or the object literal syntax ({...}
) etc.. Exotic objects are special objects that have an additional internal behavior that go beyond ordinary objects.
Array objects are of the most common exotic objects. Arrays are very much like ordinary JavaScript objects except for one special property: length
. The length
property is unique in that it is able to automatically update based on changes to the indexed members of an array instance.
const arr = []
arr.length // 0
arr[2] = 2
arr.length // 3
Prior to the class
syntax, attempts to properly extend the Array
class have largely failed because of this magical property. Construction and inheritance could work, but not length
.
function MyArray () {
Array.prototype.push.apply(this, arguments) // super
}
MyArray.prototype = Object.create(Array.prototype) // legacy prototype hookup
MyArray.prototype.constructor = MyArray
var myArr = new MyArray(0, 1, 2)
myArr.length // 3
myArr[3] = 3
myArr.length // 3 but expected 4
The reason for this is because the object created by the MyArray constructor is an ordinary object and the behavior of length
in arrays is dependent on the behavior of an array's exotic definition.
With the class
syntax, by extending Array
, the instance created by the class is able to take on the exotic array behavior.
class MyArray extends Array {} // default constructor/super
var myArr = new MyArray(0, 1, 2)
myArr.length // 3
myArr[3] = 3
myArr.length // 4 as expected
The same applies to other exotic types like Date
, Map
, etc.. Using the class
syntax, the special exotic requirements needed during initialization are automatically applied to the instance created for the class constructor.
class
Function Instances Are Created From Superclasses
The biggest and probably most important difference with class
constructors is how they create instances. With normal function constructors, instances are created immediately with the invocation of the constructor function. This instance object is an ordinary JavaScript object whose prototype has been set to equal the value currently referenced by the prototype
property of the constructor. At this point it then becomes the responsibility of the constructor to make super-like calls to any superclasses to provide any initialization defined there.
function Foo () {}
function Bar (input) {
// `this` created as part of new Bar() call
Foo.call(this) // super(), using existing `this`
this.input = input
}
new Bar()
With class
constructors, the instance is not created immediately for the constructor, but rather provided by the superclass. In fact its this behavior that allows class
-defined classes to properly extend exotic built-ins.
class Foo {}
class Bar extends Foo {
constructor (input) {
super() // creates `this`
this.input = input // `this` here is determined by superclass
}
}
new Bar()
It's this behavior that causes an error if access to this
is attempted prior to super
(assuming extending another class).
class Foo {}
class Bar extends Foo {
constructor (input) {
this.input = input // Error, `this` not yet defined
super() // now `this` is available
}
}
new Bar()
In the above example, the value of this
would be coming from the Foo
constructor (though super
will also perform the necessary updates to the instance's prototype for inheritance), so attempting to set a property on this
before the Foo
constructor has run via the call to super
causes an error. Any access to this
needs to happen after super
because it's not until then that this
exists.
Abusing Instance Creation in Superclass
One thing about this behavior is that it can also potentially cause problems depending on what the superclass is doing. Consider the following:
class Foo {
constructor () {
Object.freeze(this)
}
}
class Bar extends Foo {
constructor (input) {
super()
this.input = input // Error
}
}
new Bar()
Here, the superclass froze the instance with Object.freeze
so the subclass is not able to add any more properties to it. Because the subclass has no access to this
until after the superclass returns it via super
, there's nothing it can do. Using the function
syntax for constructors, however, this
could be accessed before calling into the superclass.
function Foo () {
Object.freeze(this)
}
function Bar (input) {
this.input = input
Foo.call(this) // freezes, but property already added
}
new Bar()
Taking this further, we can use superclass initialization behavior to do some very unconventional things, such as adding private properties to any arbitrary, ordinary object.
class SetThis {
constructor (thisValue) {
return thisValue
}
}
class AddPrivateFoo extends SetThis {
#foo
constructor (obj, initValue) {
super(obj)
this.#foo = initValue
}
getFoo () {
return this.#foo
}
}
const { getFoo } = AddPrivateFoo.prototype
const myObj = {}
getFoo.call(myObj) // Error
new AddPrivateFoo(myObj, 'bar')
getFoo.call(myObj) // 'bar'
This example uses two classes to add a private property to an arbitrary JavaScript object. The object, myObj
, is passed into the AddPrivateFoo
constructor which then passes it to the SetThis
superclass through super
. SetThis
simply returns the object it was given overriding it's own, automatically created version of this
. Then, because superclasses in the class
syntax determine this
, this
in AddPrivateFoo
becomes myObj
which allowed the private property #foo
to be initialized for it. After the constructor resolves, the getFoo
method correctly runs identifying the #foo
property in myObj
.
Additional Differences
Here are some additional, smaller differences you might also see with class
functions:
class
toString
returns theclass
syntaxclass
functions are always in strict mode- The
prototype
property in aclass
is defined as read-only and can't be reassigned - Unlike
function
declarations,class
declarations are not hoisted - A
class
name binding is available everywhere within a class for named classes, for both expressions and declarations (the only exception being for computed property names which are evaluated before the class name binding is made)
Internal Slots Used with class
A list of internal slot values which are unique to class
definitions.
Slot | Where | Value | Description |
---|---|---|---|
[[FunctionKind]] |
class constructor functions | "classConstructor" | Differentiates between different kinds of functions where only class -defined constructors get "classConstructor". |
[[ConstructorKind]] |
class constructor functions | "derived" | Will be "derived" if extending another class (otherwise is "base" like normal functions) |
[[HomeObject]] |
class methods | class prototype | Used to determine how super calls are made within methods defined within class bodies. Note: object literals also support use of super and setting of [[HomeObject]] , so technically this field could be replicated with non-class constructor methods if their prototype objects were defined with an object literal. |