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:

  1. Syntactic sugar for the standard ES5 pseudoclassical inheritance pattern.
  2. Syntactic sugar for improvements to the pseudoclassical inheritance pattern available but impractical or uncommon in ES5.
  3. Syntactic sugar for improvements to the pseudoclassical inheritance pattern not available in ES5, but which can be implemented in ES6 without the class syntax.
  4. 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:

  1. The internal [[Construct]] property of the constructor refers to the code block attached to the class’ constructor() method.
  2. 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:

  1. The prototype property of the child constructor inherits from the prototype property of the parent constructor.
  2. The super() call amounts to calling the parent constructor with this bound to the current context.
    • This is only a rough approximation of the functionality provided by super(), which would also set the implicit new.target parameter and trigger the internal [[Construct]] method (instead of the [[Call]] method). The super() call will get fully ‘desugared’ in section 3.
  3. The super[method]() calls amount to calling the method on the parent’s prototype object with this 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.

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:

  1. All code inside the class declaration or class expression runs in strict mode.
  2. The class’s static methods are defined on the constructor itself.
  3. All class methods (static or not) are non-enumerable.
  4. 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:

  1. The super[method]() calls inside static methods amount to calling the method on the parent’s constructor with this 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 the class syntax and are listed in section 4.

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:

  1. 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)
  2. 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 a TypeError.
  3. Class constructors must be called with the internal [[Construct]] method, a TypeError is thrown if they are called as ordinary functions with the internal [[Call]] method.
  4. 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 through super.prop or super[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.

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 by Object.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 the instanceof 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:

  1. The child constructor inherits from the parent constructor (i.e. derived classes inherit static members).
  2. Calling super() in the derived class constructor amounts to calling the parent constructor’s internal [[Construct]] method with the current new.target value and binding the this 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 the this context, so we had to return a different that 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:

  1. 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 our Bird.isBird() method.

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:

  1. super() is only valid syntax in derived class constructors and may only be called once.
  2. Trying to access this in a derived class constructor before super() is called results in a ReferenceError.
  3. super() must be called in a derived class constructor if no object is explicitly returned from it.
  4. eval and arguments are not valid class identifiers (while they are valid function identifiers in non strict mode).
  5. Derived classes set up a default constructor() method if none is provided (corresponding to constructor( ...args ) { super( ...args ); }).
  6. 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.

Leave a Comment