Are ES6 classes just syntactic sugar for the prototypal pattern in Javascript?
No, ES6 classes are not just syntactic sugar for the prototypal pattern.
While the contrary can be read in many places and while it seems to be true on the surface, things get more complex when you start digging into the details.
I wasn't quite satisfied with the existing answers. After doing some more research, this is how I classified the features of ES6 classes in my mind:
- Syntactic sugar for the standard ES5 pseudoclassical inheritance pattern.
- Syntactic sugar for improvements to the pseudoclassical inheritance pattern available but impractical or uncommon in ES5.
- Syntactic sugar for improvements to the pseudoclassical inheritance pattern not available in ES5, but which can be implemented in ES6 without the class syntax.
- Features impossible to implement without the
class
syntax, even in ES6.
(I have tried to make this answer as complete as possible and it became quite long as a result. Those more interested in a good overview should look at traktor53’s answer.)
So let me 'desugar' step by step (and as far as possible) the class declarations below to illustrate things as we go along:
// Class Declaration:
class Vertebrate {
constructor( name ) {
this.name = name;
this.hasVertebrae = true;
this.isWalking = false;
}
walk() {
this.isWalking = true;
return this;
}
static isVertebrate( animal ) {
return animal.hasVertebrae;
}
}
// Derived Class Declaration:
class Bird extends Vertebrate {
constructor( name ) {
super( name )
this.hasWings = true;
}
walk() {
console.log( "Advancing on 2 legs..." );
return super.walk();
}
static isBird( animal ) {
return super.isVertebrate( animal ) && animal.hasWings;
}
}
1. Syntactic sugar for the standard ES5 pseudoclassical inheritance pattern
At their core, ES6 classes indeed provide syntactic sugar for the standard ES5 pseudoclassical inheritance pattern.
Class Declarations / Expressions
In the background a class declaration or a class expression will create a constructor function with the same name as the class such that:
- The internal
[[Construct]]
property of the constructor refers to the code block attached to the class'constructor()
method. - The classe' methods are defined on the constructor’s
prototype
property (we are not including static methods for now).
Using ES5 syntax, the initial class declaration is thus roughly equivalent to the following (leaving out static methods):
function Vertebrate( name ) { // 1. A constructor function containing the code of the class's constructor method is defined
this.name = name;
this.hasVertebrae = true;
this.isWalking = false;
}
Object.assign( Vertebrate.prototype, { // 2. Class methods are defined on the constructor's prototype property
walk: function() {
this.isWalking = true;
return this;
}
} );
The initial class declaration and the above code snippet will both yield the following:
console.log( typeof Vertebrate ) // function
console.log( typeof Vertebrate.prototype ) // object
console.log( Object.getOwnPropertyNames( Vertebrate.prototype ) ) // [ 'constructor', 'walk' ]
console.log( Vertebrate.prototype.constructor === Vertebrate ) // true
console.log( Vertebrate.prototype.walk ) // [Function: walk]
console.log( new Vertebrate( 'Bob' ) ) // Vertebrate { name: 'Bob', hasVertebrae: true, isWalking: false }
Derived Class Declarations / Expressions
In addition to to the above, derived class declarations or derived class expressions will also set up an inheritance between the constructors' prototype
properties and make use of the super
syntax such that:
- The
prototype
property of the child constructor inherits from theprototype
property of the parent constructor. - The
super()
call amounts to calling the parent constructor withthis
bound to the current context.- This is only a rough approximation of the functionality provided by
super()
, which would also set the implicitnew.target
parameter and trigger the internal[[Construct]]
method (instead of the[[Call]]
method). Thesuper()
call will get fully 'desugared' in section 3.
- This is only a rough approximation of the functionality provided by
- The
super[method]()
calls amount to calling the method on the parent'sprototype
object withthis
bound to the current context (we are not including static methods for now).- This is only an approximation of
super[method]()
calls which don't rely on a direct reference to a parent class.super[method]()
calls will get fully replicated in section 3.
- This is only an approximation of
Using ES5 syntax, the initial derived class declaration is thus roughly equivalent to the following (leaving out static methods):
function Bird( name ) {
Vertebrate.call( this, name ) // 2. The super() call is approximated by directly calling the parent constructor
this.hasWings = true;
}
Bird.prototype = Object.create( Vertebrate.prototype, { // 1. Inheritance is established between the constructors' prototype properties
constructor: {
value: Bird,
writable: true,
configurable: true
}
} );
Object.assign( Bird.prototype, {
walk: function() {
console.log( "Advancing on 2 legs..." );
return Vertebrate.prototype.walk.call( this ); // 3. The super[method]() call is approximated by directly calling the method on the parent's prototype object
}
})
The initial derived class declaration and the above code snippet will both yield the following:
console.log( Object.getPrototypeOf( Bird.prototype ) ) // Vertebrate {}
console.log( new Bird("Titi") ) // Bird { name: 'Titi', hasVertebrae: true, isWalking: false, hasWings: true }
console.log( new Bird( "Titi" ).walk().isWalking ) // true
2. Syntactic sugar for improvements to the pseudoclassical inheritance pattern available but impractical or uncommon in ES5
ES6 classes further provide improvements to the pseudoclassical inheritance pattern that could already have been implemented in ES5, but were often left out as they could be a bit impractical to set up.
Class Declarations / Expressions
A class declaration or a class expression will further set things up in the following way:
- All code inside the class declaration or class expression runs in strict mode.
- The class’s static methods are defined on the constructor itself.
- All class methods (static or not) are non-enumerable.
- The constructor’s prototype property is non-writable.
Using ES5 syntax, the initial class declaration is thus more precisely (but still only partially) equivalent to the following:
var Vertebrate = (function() { // 1. Code is wrapped in an IIFE that runs in strict mode
'use strict';
function Vertebrate( name ) {
this.name = name;
this.hasVertebrae = true;
this.isWalking = false;
}
Object.defineProperty( Vertebrate.prototype, 'walk', { // 3. Methods are defined to be non-enumerable
value: function walk() {
this.isWalking = true;
return this;
},
writable: true,
configurable: true
} );
Object.defineProperty( Vertebrate, 'isVertebrate', { // 2. Static methods are defined on the constructor itself
value: function isVertebrate( animal ) { // 3. Methods are defined to be non-enumerable
return animal.hasVertebrae;
},
writable: true,
configurable: true
} );
Object.defineProperty( Vertebrate, "prototype", { // 4. The constructor's prototype property is defined to be non-writable:
writable: false
});
return Vertebrate
})();
NB 1: If the surrounding code is already running in strict mode, there is of course no need to wrap everything in an IIFE.
NB 2: Although it was possible to define static properties without problem in ES5, this was not very common. The reason for this may be that establishing inheritance of static properties was not possible without the use of the then non-standard
__proto__
property.
Now the initial class declaration and the above code snippet will also both yield the following:
console.log( Object.getOwnPropertyDescriptor( Vertebrate.prototype, 'walk' ) )
// { value: [Function: walk],
// writable: true,
// enumerable: false,
// configurable: true }
console.log( Object.getOwnPropertyDescriptor( Vertebrate, 'isVertebrate' ) )
// { value: [Function: isVertebrate],
// writable: true,
// enumerable: false,
// configurable: true }
console.log( Object.getOwnPropertyDescriptor( Vertebrate, 'prototype' ) )
// { value: Vertebrate {},
// writable: false,
// enumerable: false,
// configurable: false }
Derived Class Declarations / Expressions
In addition to to the above, derived class declarations or derived class expressions will also make use of the super
syntax such that:
- The
super[method]()
calls inside static methods amount to calling the method on the parent's constructor withthis
bound to the current context.- This is only an approximation of
super[method]()
calls which don't rely on a direct reference to a parent class.super[method]()
calls in static methods cannot fully be mimicked without the use of theclass
syntax and are listed in section 4.
- This is only an approximation of
Using ES5 syntax, the initial derived class declaration is thus more precisely (but still only partially) equivalent to the following:
function Bird( name ) {
Vertebrate.call( this, name )
this.hasWings = true;
}
Bird.prototype = Object.create( Vertebrate.prototype, {
constructor: {
value: Bird,
writable: true,
configurable: true
}
} );
Object.defineProperty( Bird.prototype, 'walk', {
value: function walk( animal ) {
return Vertebrate.prototype.walk.call( this );
},
writable: true,
configurable: true
} );
Object.defineProperty( Bird, 'isBird', {
value: function isBird( animal ) {
return Vertebrate.isVertebrate.call( this, animal ) && animal.hasWings; // 1. The super[method]() call is approximated by directly calling the method on the parent's constructor
},
writable: true,
configurable: true
} );
Object.defineProperty( Bird, "prototype", {
writable: false
});
Now the initial derived class declaration and the above code snippet will also both yield the following:
console.log( Bird.isBird( new Bird("Titi") ) ) // true
3. Syntactic sugar for improvements to the pseudoclassical inheritance pattern not available in ES5
ES6 classes further provide improvements to the pseudoclassical inheritance pattern that are not available in ES5, but can be implemented in ES6 without having to use the class syntax.
Class Declarations / Expressions
ES6 characteristics found elsewhere also made it into classes, in particular:
- Class declarations behave like
let
declarations - they are not initialised when hoisted and end up in the Temporal Dead Zone before the declaration. (related question) - The class name behaves like a
const
binding inside the class declaration - it cannot be overwritten within a class method, attempting to do so will result in aTypeError
. - Class constructors must be called with the internal
[[Construct]]
method, aTypeError
is thrown if they are called as ordinary functions with the internal[[Call]]
method. - Class methods (with the exception of the
constructor()
method), static or not, behave like methods defined through the concise method syntax, which is to say that:- They can use the
super
keyword throughsuper.prop
orsuper[method]
(this is because they get assigned an internal[[HomeObject]]
property). - They cannot be used as constructors - they lack a
prototype
property and an internal[[Construct]]
property.
- They can use the
Using ES6 syntax, the initial class declaration is thus even more precisely (but still only partially) equivalent to the following:
let Vertebrate = (function() { // 1. The constructor is defined with a let declaration, it is thus not initialized when hoisted and ends up in the TDZ
'use strict';
const Vertebrate = function( name ) { // 2. Inside the IIFE, the constructor is defined with a const declaration, thus preventing an overwrite of the class name
if( typeof new.target === 'undefined' ) { // 3. A TypeError is thrown if the constructor is invoked as an ordinary function without new.target being set
throw new TypeError( `Class constructor ${Vertebrate.name} cannot be invoked without 'new'` );
}
this.name = name;
this.hasVertebrae = true;
this.isWalking = false;
}
Object.assign( Vertebrate, {
isVertebrate( animal ) { // 4. Methods are defined using the concise method syntax
return animal.hasVertebrae;
},
} );
Object.defineProperty( Vertebrate, 'isVertebrate', {enumerable: false} );
Vertebrate.prototype = {
constructor: Vertebrate,
walk() { // 4. Methods are defined using the concise method syntax
this.isWalking = true;
return this;
},
};
Object.defineProperty( Vertebrate.prototype, 'constructor', {enumerable: false} );
Object.defineProperty( Vertebrate.prototype, 'walk', {enumerable: false} );
return Vertebrate;
})();
NB 1: Although instance and static methods are both defined with the concise method syntax,
super
references will not behave as expected in static methods. Indeed the internal[[HomeObject]]
property is not copied over byObject.assign()
. Setting the[[HomeObject]]
property correctly on static methods would require us to define a function constructor using an object literal, which is not possible.NB 2: To prevent constructors being invoked without the
new
keyword, similar safeguards could already be implemented in ES5 by making use of theinstanceof
operator. Those were not covering all cases though (see this answer).
Now the initial class declaration and the above code snippet will also both yield the following:
Vertebrate( "Bob" ); // TypeError: Class constructor Vertebrate cannot be invoked without 'new'
console.log( Vertebrate.prototype.walk.hasOwnProperty( 'prototype' ) ) // false
new Vertebrate.prototype.walk() // TypeError: Vertebrate.prototype.walk is not a constructor
console.log( Vertebrate.isVertebrate.hasOwnProperty( 'prototype' ) ) // false
new Vertebrate.isVertebrate() // TypeError: Vertebrate.isVertebrate is not a constructor
Derived Class Declarations / Expressions
In addition to to the above the following will also hold for a derived class declaration or derived class expression:
- The child constructor inherits from the parent constructor (i.e. derived classes inherit static members).
- Calling
super()
in the derived class constructor amounts to calling the parent constructor's internal[[Construct]]
method with the currentnew.target
value and binding thethis
context to the returned object.
Using ES6 syntax, the initial derived class declaration is thus more precisely (but still only partially) equivalent to the following:
let Bird = (function() {
'use strict';
const Bird = function( name ) {
if( typeof new.target === 'undefined' ) {
throw new TypeError( `Class constructor ${Bird.name} cannot be invoked without 'new'` );
}
const that = Reflect.construct( Vertebrate, [name], new.target ); // 2. super() calls amount to calling the parent constructor's [[Construct]] method with the current new.target value and binding the 'this' context to the returned value (see NB 2 below)
that.hasWings = true;
return that;
}
Bird.prototype = {
constructor: Bird,
walk() {
console.log( "Advancing on 2 legs..." );
return super.walk(); // super[method]() calls can now be made using the concise method syntax (see 4. in Class Declarations / Expressions above)
},
};
Object.defineProperty( Bird.prototype, 'constructor', {enumerable: false} );
Object.defineProperty( Bird.prototype, 'walk', {enumerable: false} );
Object.assign( Bird, {
isBird: function( animal ) {
return Vertebrate.isVertebrate( animal ) && animal.hasWings; // super[method]() calls can still not be made in static methods (see NB 1 in Class Declarations / Expressions above)
}
})
Object.defineProperty( Bird, 'isBird', {enumerable: false} );
Object.setPrototypeOf( Bird, Vertebrate ); // 1. Inheritance is established between the constructors directly
Object.setPrototypeOf( Bird.prototype, Vertebrate.prototype );
return Bird;
})();
NB 1: As
Object.create()
can only be used to set the prototype of a new non-function object, setting up the inheritance between the constructors themselves could only be implemented in ES5 by manipulating the then non-standard__proto__
property.NB 2: It is not possible to mimic the effect of
super()
using thethis
context, so we had to return a differentthat
object explicitly from the constructor.
Now the initial derived class declaration and the above code snippet will also both yield the following:
console.log( Object.getPrototypeOf( Bird ) ) // [Function: Vertebrate]
console.log( Bird.isVertebrate ) // [Function: isVertebrate]
4. Features impossible to implement without the class
syntax
ES6 classes further provide the following features that cannot be implemented at all without actually using the class
syntax:
- The internal
[[HomeObject]]
property of static class methods points to the class constructor.- There is no way to implement this for ordinary constructor functions, as it would require defining a function through an object literal (see also section 3 above). This is particularly problematic for static methods of derived classes making use of the
super
keyword like ourBird.isBird()
method.
- There is no way to implement this for ordinary constructor functions, as it would require defining a function through an object literal (see also section 3 above). This is particularly problematic for static methods of derived classes making use of the
It is possible to partially work around this issue if the parent class is known in advance.
Conclusion
Some features of ES6 classes are just syntactic sugar for the standard ES5 pseudoclassical inheritance pattern. However ES6 classes also come with features that can only be implemented in ES6 and some further features that cannot even be mimicked in ES6 (i.e. without using the class syntax).
Looking at the above, I think it is fair to say that ES6 classes are more concise, more convenient, and safer to use than the ES5 pseudoclassical inheritance pattern. They are also less flexible as a result (see this question for example).
Side Notes
It is worth pointing out a few more peculiarities of classes that did not find a place in the above classification:
super()
is only valid syntax in derived class constructors and may only be called once.- Trying to access
this
in a derived class constructor beforesuper()
is called results in aReferenceError
. super()
must be called in a derived class constructor if no object is explicitly returned from it.eval
andarguments
are not valid class identifiers (while they are valid function identifiers in non strict mode).- Derived classes set up a default
constructor()
method if none is provided (corresponding toconstructor( ...args ) { super( ...args ); }
). - It is not possible to define data properties on a class with a class declaration or a class expression (although you can add data properties on the class manually after its declaration).
Further Resources
- The chapter Understanding ES6 Classes in Understanding ES6 by Nicholas Zakas is the best write up on ES6 classes that I have come across.
- The 2ality blog by Axel Rauschmayer has a very thorough post on ES6 classes.
- Object Playground has a great video explaining the pseudoclassical inheritance pattern (and comparing it to the class syntax).
- The Babel transpiler is a good place to explore things on your own.
Yes, perhaps, but some of the syntactic sugar has teeth.
Declaring a class creates a function object that is the constructor for the class, using the code provided for constructor
within the class body, and for named classes, with the same name as the class.
The class constructor function has a normal prototype object from which class instances inherit properties in normal JavaScript fashion. Instance methods defined within the class body are added to this prototype.
ES6 does not provide a means to declare class instance default property values (i.e. values which are not methods) within the class body to be stored on the prototype and inherited. To initialize instance value you can either set them as local, non inherited properties within the constructor, or manually add them to the class constructor's prototype
object outside the class definition in the same fashion as for ordinary constructor functions. (I am not arguing the merits or otherwise of setting up inherited properties for JavaScript classes).
Static methods declared within the class body are added as properties of the class constructor function. Avoid using static class method names that compete with standard function properties and methods inherited from Function.prototype
such as call
, apply
or length
.
Less sugary is that class declarations and methods are always executed in strict mode, and a feature that gets little attention: the .prototype
property of class constructor functions is read only: you can't set it to some other object you've created for some special purpose.
Some interesting stuff happens when you extend a class:
the
prototype
object property of the extended class constructor is automatically prototyped on theprototype
object of the class being extended. This is not particularly new and the effect can be duplicated usingObject.create
.the extended class constructor function (object) is automatically prototyped on the constructor function of the class being extended, not
Function
. While it may be possible to replicate the effect on an ordinary constructor function usingObject.setPrototypeOf
or evenchildClass.__proto__ = parentClass
, this would be an extremely unusual coding practice and is often advised against in JavaScript documentation.
There are other differences such as class objects not being hoisted in the manner of named functions declared using the function
keyword.
I believe it could be naive to think that Class declarations and expressions will remain unaltered in all future versions of ECMA Script and it will be interesting to see if and when developments occur. Arguably it has become a fad to associate "syntactical sugar" with classes introduced in ES6 (ECMA-262 standard version 6) but personally I try to avoid repeating it.