JS Tip of the Day: Mixing class and function Constructors

Mixing class and function Constructors
Version: ES2015 (class, Reflect)
Level: Advanced

For the most part, when creating your own constructor functions, you’re going to want to prefer the class syntax. It’s cleaner syntax and ability to easily extend builtins properly gives it an advantage over older, function-based constructors. However, there may be times when you’ll need to work with both, whether it be integrating with an older codebase, one of those obscure times when you can’t use class (e.g. the constructor needing to be callable as a function without new), or some other unexpected circumstance.

The good news is, class syntax works pretty well with other constructors no matter where they come from. If you need a class to extend a constructor defined with function, that should just work.

Consider a Dog constructor extending an Animal constructor. Both will have a data property and a method that can be used to show that a Dog instance gets correctly instantiated going through both constructors and inheriting from both prototypes.

function Animal (name) {
    this.name = name;
}

Animal.prototype.logName = function () {
    console.log(this.name);
}

class Dog extends Animal {
    constructor (name, breed) {
        super(name);
        this.breed = breed;
    }
    logBreed () {
        console.log(this.breed);
    }
}

let dog = new Dog('Fido', 'Pointer');
dog.logName(); // Fido
dog.logBreed(); // Pointer

As expected, the output is correct. This shouldn’t be too surprising as class definitions are functions too and they were designed to be backwards compatible

But class definitions aren’t exactly like other functions. They’re different enough that they lack compatibility going the other way around - a function constructor inheriting from a class.

class Animal {
    constructor (name) {
        this.name = name;
    }
    logName () {
        console.log(this.name);
    }
}

function Dog (name, breed) {
    Animal.call(this, name); // super()
    this.breed = breed;
}

Dog.prototype = Object.create(Animal.prototype);
Dog.prototype.constructor = Dog;
Dog.prototype.logBreed = function () {
    console.log(this.breed);
}

let dog = new Dog('Fido', 'Pointer');
// TypeError: Class constructor Animal cannot be invoked without 'new'

In its attempt to call the superclass constructor, Dog will fail here, throwing an error. This is because Animal can’t be called like a normal function. Being a class constructor, it can only be called with new.

So how do function constructors call their version of super() with class constructors? The simple answer is: they don’t. Another approach is needed.

Luckily, we’re saved by the fact that implicit constructor returns (returning this) can be overridden. This would allow us to, instead of passing the value of this up to the superclass to get updated, create an instance of the superclass to represent the new this and update that with the properties of the current constructor. Then, instead of allowing the implicit this to be returned, return the updated instance of the superclass explicitly.

function Dog (name, breed) {
   let self = new Animal(name); // self replaces this
   self.breed = breed;
   return self;
}

While this creates a new instance with both the Animal and Dog data properties, it doesn’t have the Dog’s methods because as an Animal instance, self doesn’t inherit from Dog. That can be fixed easily enough with a call to Object.setPrototypeOf().

function Dog (name, breed) {
   let self = new Animal(name);
   Object.setPrototypeOf(self, Dog.prototype);
   self.breed = breed;
   return self;
}

This will get the job done, but it can be cleaned up a little using Reflect.construct() and new.target. Reflect.construct() is a function which creates instances through new and optionally allows you to set the prototype based on an additional constructor. new.target represents the constructor used when new was called. This can be used in super constructors to know whether or not they were called with new or some other subclass was. If new (or Reflect.construct()) was not used to invoke a function, new.target becomes undefined. new.target can be used with Reflect.construct() to reduce the first to lines in the constructor to just one.

function Dog (name, breed) {
   let self = Reflect.construct(Animal, [name], new.target);
   self.breed = breed;
   return self;
}

The complete, working code then becomes.

class Animal {
    constructor (name) {
        this.name = name;
    }
    logName () {
        console.log(this.name);
    }
}

function Dog (name, breed) {
    let self = Reflect.construct(Animal, [name], new.target);
    self.breed = breed;
    return self;
}

Dog.prototype = Object.create(Animal.prototype);
Dog.prototype.constructor = Dog;
Dog.prototype.logBreed = function () {
    console.log(this.breed);
}

let dog = new Dog('Fido', 'Pointer');
dog.logName(); // Fido
dog.logBreed(); // Pointer

While this is great, and everything is working as it should, our story doesn’t end here. So far we’ve only managed to take care of the superclasses. But we also don’t want to ignore the subclasses.

If you haven’t guessed already, subclassing with class constructors still just work, this especially thanks to the use of new.target in Dog.

class ShowDog extends Dog {
    constructor (name, breed, hairStyle) {
        super(name, breed); // automatically passes new.target onto Dog
        this.hairStyle = hairStyle;
    }
    logHairStyle () {
      	console.log(this.hairStyle);
    }
}

let dog = new ShowDog('Rufus', 'Poodle', 'Continental');
dog.logName(); // Rufus
dog.logBreed(); // Poodle
dog.logHairStyle(); // Continental

With function constructors, things are, once again, a little more complicated. First, Dog, despite being a function constructor itself, is now passing an instance back from its constructor rather than relying on the existing this. This means a subclass function constructor would need to take that returned value and use that in place of its own this that it would normally pass through Dog.

function ShowDog (name, breed, hairStyle) {
    let self = Dog(name, breed);
    self.hairStyle = hairStyle;
    return self;
}

But now there’s another problem. Since Dog is being called as a function (which is allowed), it’s new.target will be undefined calling Reflect.construct() inside Dog to fail. This can be corrected by calling Dog in ShowDog with new, but then the new.target value would be Dog instead of the expected ShowDog. To fix this, the subclass would now also need to use Reflect.construct() to make sure it can pass the correct new.target along to the Dog constructor.

function ShowDog (name, breed, hairStyle) {
    let self = Reflect.construct(Dog, [name, breed], new.target);
    self.hairStyle = hairStyle;
    return self;
}

And with that, everything should now now work.

function ShowDog (name, breed, hairStyle) {
    let self = Reflect.construct(Dog, [name, breed], new.target);
    self.hairStyle = hairStyle;
    return self;
}
ShowDog.prototype = Object.create(Dog.prototype);
ShowDog.prototype.constructor = ShowDog;

ShowDog.prototype.logHairStyle = function () {
	console.log(this.hairStyle);
};

let dog = new ShowDog('Rufus', 'Poodle', 'Continental');
dog.logName(); // Rufus
dog.logBreed(); // Poodle
dog.logHairStyle(); // Continental

What’s nice about the approach with Reflect.construct() is that it will work for any kind of superclass, even exotic builtins. The downside is that you’re creating two instances for each constructor, one from Reflect.construct(), and the other automatic instance getting assigned to this that gets handily ignored. This is another reason to prefer class where possible.

More info:


More tips: JavaScript Tips of the Day