Creating Classes using Constructor Functions
Defining classes in JavaScript with class
is concise and powerful. But there may be times where you would need to bypass the class
syntax and use normal function constructors instead. The following outlines how class
-based constructors are wired up and how you would replicate their behavior with non-class
constructor functions.
Standard Base Classes
The class
syntax is responsible for handling a lot of the class-related boilerplate for you automatically. But when you're defining a class that's not extending another class, there's not much to do. These base classes implicitly inherit from Object just as those defined with standard constructor functions do. The only consideration that needs to be made is moving definitions from the class body to either the constructor (fields) or the constructor's prototype
(methods).
class version
class MyClass {
myField = 1
myMethod () {
return this.myProperty
}
constructor (myProperty) {
this.myProperty = myProperty
}
}
constructor version
function MyClass (myProperty) {
this.myField = 1
this.myProperty = myProperty
}
MyClass.prototype.myMethod = function () {
return this.myProperty
}
Subclasses
Once you introduce subclassing, there is a lot more to consider. Not only will inheritance need to be set up manually, but construction becomes much more complicated.
superclass (used by both)
class MySuperClass {
mySuperField = 2
mySuperMethod () {
return this.mySuperProperty
}
constructor (mySuperProperty) {
this.mySuperProperty = mySuperProperty
}
}
class version
class MyClass extends MySuperClass {
myField = 1
myMethod () {
return this.myProperty
}
mySuperMethod () {
return super.mySuperMethod()
}
constructor (mySuperProperty, myProperty) {
super(mySuperProperty)
this.myProperty = myProperty
}
}
constructor version
function MyClass (mySuperProperty, myProperty) {
const _super = Object.getPrototypeOf(MyClass)
const _this = Reflect.construct(_super, [mySuperProperty], new.target)
_this.myField = 1
_this.myProperty = myProperty
return _this
}
Object.setPrototypeOf(MyClass, MySuperClass)
Object.setPrototypeOf(MyClass.prototype, MySuperClass.prototype)
MyClass.prototype.myMethod = function () {
return this.myProperty
}
MyClass.prototype.mySuperMethod = function mySuperMethod () {
const _super = Object.getPrototypeOf(mySuperMethod._homeObject)
return _super.mySuperMethod.call(this)
}
MyClass.prototype.mySuperMethod._homeObject = MyClass.prototype
There's a lot going on here, so let's look at some of the different parts in a little more detail.
Setting Up Inheritance
We're going to skip ahead a little and start with the inheritance setup after the constructor since the constructor depends on what is happening here. The inheritance is defined with the following two lines:
// extends
Object.setPrototypeOf(MyClass, MySuperClass)
Object.setPrototypeOf(MyClass.prototype, MySuperClass.prototype)
The first line sets up inheritance between constructors. Constructor inheritance allows the static
members to be inherited as well as determines what to use for super
in the constructor.
The second line sets up instance inheritance by having the subclass's prototype
inherit from the superclass's. This allows object instances created by MyClass to inherit from the methods defined by MySuperClass.
The Constructor
The constructor is where things get messy.
// constructor()
function MyClass (mySuperProperty, myProperty) {
const _super = Object.getPrototypeOf(MyClass)
const _this = Reflect.construct(_super, [mySuperProperty], new.target)
_this.myField = 1
_this.myProperty = myProperty
return _this
}
It's complexity is in part because class
construction is handled differently than it is with standard function constructors. When creating an instance with function constructors, the instance is created immediately and assigned to this
before any user code runs. With class
constructors, instance construction is dependent on super()
. this
isn't even available until super()
is called and, in fact, what this
is, is determined by what is created by super()
.
To get the equivalent of super()
, the superclass is first obtained through the constructor inheritance set up earlier using Object.getPrototypeOf()
. Then Reflect.construct()
is used to replicate the actual super()
call using that superclass constructor.
// super()
const _super = Object.getPrototypeOf(MyClass)
const _this = Reflect.construct(_super, [mySuperProperty], new.target)
Because super()
is responsible for instance creation in class
constructors, we capture the return value of Reflect.construct()
and assign it to a _this
variable. The value in _this
now represents our new class instance. Doing this does not stop the creation of the new instance automatically created and assigned to this
when the constructor was first invoked. That still exists, but because it was not run through superclass initialization, we are going to ignore it. Instead, this _this
from the superclass is our instance, and any instance members we have will need to be assigned to it rather than this
.
_this.myField = 1
_this.myProperty = myProperty
Additionally, because we want the constructor to produce an instance that isn't the value in this
we need to return _this
explicitly, overriding the implicit this
return.
return _this
Use of Reflect.construct
Commonly, when using function constructors, the superclass constructor is called against the value of this
to initialize it with the super constructor code.
function SuperClass () {}
function SubClass () {
SuperClass.call(this) // super
}
Instead, of doing this, we're using Reflect.construct()
, for a few reasons. First, Reflect.construct()
allows us to use the class
-based order of instance creation where creation occurs at the base class first with the instance getting passed down through subclasses after.
Secondly, class
-defined constructors cannot be called without new
. Trying to do so would result in an error. Using Reflect.construct()
bypasses this restriction.
class SuperClass {}
function SubClass () {
SuperClass.call(this) // Error
}
Most importantly, using Reflect.construct()
will perform proper initialization for superclass. This means being able to properly extend exotic types like Array or making sure things like private fields are set up for the instance in class
superclasses (more on this later).
super
in Methods
super
in method calls differ from the super()
used in construction. In methods, super
is used to look up methods in the superclass's prototype
. In class
-defined methods, this lookup happens dynamically using an internal slot defined for the method called [[HomeObject]]
. It points to the object within which the function was originally defined, or more specifically the class's prototype
object. We don't have access to that internal slot, so we add it manually as a property called _homeObject
in the method function.
// super.method()
MyClass.prototype.mySuperMethod = function mySuperMethod () {
const _super = Object.getPrototypeOf(mySuperMethod._homeObject)
return _super.mySuperMethod.call(this)
}
MyClass.prototype.mySuperMethod._homeObject = MyClass.prototype
With the _homeObject
pointing to the current class's prototype
, the superclass's prototype can be obtained with Object.getPrototypeOf()
. From that we can make the superclass method call making sure to explicitly set this
through the use of call()
.
Private Fields
Unfortunately, constructor functions do not directly support private fields. Private fields can only be defined in class
definitions. However, there are a couple of options to get class
-based private fields in constructor functions.
Inheriting Private Fields
While a function constructor can't have private fields, it can inherit from a class
that does. Any method that would need to access a private field would also need to be defined in that class, but the function constructor would have access to those inherently through inheritance.
class version
class MyClass {
#myPrivateField = 1
myMethod () {
return this.#myPrivateField
}
}
constructor version
class MyPrivateProvider {
#myPrivateField = 1
myMethod () {
return this.#myPrivateField
}
}
function MyClass () {
const _super = Object.getPrototypeOf(MyClass)
return Reflect.construct(_super, [], new.target)
}
Object.setPrototypeOf(MyClass, MyPrivateProvider)
Object.setPrototypeOf(MyClass.prototype, MyPrivateProvider.prototype)
Though MyClass isn't able to define a private field, because it extended a class that did, instances it creates will have access to that field through the inherited myMethod
method.
new MyClass().myMethod() // 1
Redirecting Initialization
Using a more obscure approach, we can take advantage of the fact that class
definitions rely on super()
for defining this
and create a class
that allows us to specify what instance this
should be by having a super()
call that returns the object we provide to it. Once the class
has its this
(the specified object), it will get initialized with that class's private fields. And we can do this without including the class in the inheritance hierarchy meaning the constructor would be free to extend anything else.
Because methods from the class with the private definitions is not inherited, some extra work will be needed to copy it's methods (which are able to access the private fields) into the constructor function's prototype
.
class version
class MyClass {
#myPrivateField = 1
myMethod () {
return this.#myPrivateField
}
}
constructor version
function SetThis (target) {
return target
}
class MyPrivateProvider extends SetThis {
#myPrivateField = 1
myMethod () {
return this.#myPrivateField
}
constructor (target) {
super(target)
}
}
function MyClass () {
new MyPrivateProvider(this)
}
Reflect.ownKeys(MyPrivateProvider.prototype)
.forEach(method => {
if (method !== 'constructor')
MyClass.prototype[method] = MyPrivateProvider.prototype[method]
})
In this particular case, we're able to continue to MyClass's constructed this
value because we're not inheriting from another class; MyClass is a simple base class. The MyPrivateProvider class only exists to initialize an object with private variables, not to serve as a superclass.
The first step is passing MyClass's this
into the MyPrivateProvider constructor. We don't care what it returns (which will ultimately be the same this
). We're only using the constructor as a way to modify an existing value.
function MyClass () {
new MyPrivateProvider(this)
}
MyPrivateProvider has the private fields and necessary methods for accessing those fields as part of its own definition. Private fields will get initialized on the constructor's this
value after super()
is called - super()
being what defines what this
is. Because super()
does this, we use the special SetThis superclass to set the this
of MyPrivateProvider to whatever it passes it, which in this case would be the this
from MyClass.
function SetThis (target) {
return target
}
This means when the MyPrivateProvider initializes its this
with private variables, its actually initializing the MyClass instance it was given as target
.
constructor (target) {
super(target) // super is SetThis, target becomes this
}
The initialization of this
here in MyPrivateProvider does not include setting up the inheritance connections for the instance. That would have happened in the base class, SetThis, which we overrode by returning target
(the this
in SetThis would have had inheritance set to inherit from MyPrivateProvider). So in order for the MyClass instance to gain access to the MyPrivateProvider methods that can access the private fields defined there, they need to be copied over into MyClass.
Reflect.ownKeys(MyPrivateProvider.prototype)
.forEach(method => {
if (method !== 'constructor')
MyClass.prototype[method] = MyPrivateProvider.prototype[method]
})
There is one small catch to this approach. If you remember from earlier, super
in methods refers to a [[HomeObject]]
slot that points to its prototype of origin. This means that any uses of super
in MyPrivateProvider would refer to methods in SetThis, not MyClass's superclass, if it had one.