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