In this blog post, we examine several approaches for creating instances of classes: Constructors, factory functions, etc. We do so by solving one concrete problem several times. The focus of this post is on classes, which is why alternatives to classes are ignored.
The following container class is supposed to receive the contents of its property .data
asynchronously. This is our first attempt:
class DataContainer {
#data; // (A)
constructor() {
Promise.resolve('downloaded')
.then(data => this.#data = data); // (B)
}
getData() {
return 'DATA: '+this.#data; // (C)
}
}
Key issue of this code: Property .data
is initially undefined
.
const dc = new DataContainer();
assert.equal(dc.getData(), 'DATA: undefined');
setTimeout(() => assert.equal(
dc.getData(), 'DATA: downloaded'), 0);
In line A, we declare the private field .#data
that we use in line B and line C.
The Promise inside the constructor of DataContainer
is settled asynchronously, which is why we can only see the final value of .data
if we finish the current task and start a new one, via setTimeout()
. In other words, the instance of DataContainer
is not completely initialized, yet, when we first see it.
What if we delay access to the instance of DataContainer
until it is fully initialized? We can achieve that by returning a Promise from the constructor. By default, a constructor returns a new instance of the class that it is part of. We can override that if we explicitly return an object:
class DataContainer {
#data;
constructor() {
return Promise.resolve('downloaded')
.then(data => {
this.#data = data;
return this; // (A)
});
}
getData() {
return 'DATA: '+this.#data;
}
}
new DataContainer()
.then(dc => assert.equal( // (B)
dc.getData(), 'DATA: downloaded'));
Now we have to wait until we can access our instance (line B). It is passed on to us one the data was “downloaded” (line A). There are two possible sources of errors in this code:
.then()
callback.In either case, the errors become rejections of the Promise that is returned from the constructor.
Pros and cons:
DataContainer
.Instead of using the Promise API directly to create the Promise that is returned from the constructor, we can also use an asynchronous arrow function that we invoke immediately:
constructor() {
return (async () => {
this.#data = await Promise.resolve('downloaded');
return this;
})();
}
Our next attempt is to implement a static factory method. That is, DataContainer
now has the static method .create()
which returns Promises for instances of DataContainer
:
class DataContainer {
#data;
static async create() {
const data = await Promise.resolve('downloaded');
return new this(data);
}
constructor(data) {
this.#data = data;
}
getData() {
return 'DATA: '+this.#data;
}
}
DataContainer.create()
.then(dc => assert.equal(
dc.getData(), 'DATA: downloaded'));
Pros and cons:
new DataContainer()
.If we want to ensure that instances are always correctly set up, we must ensure that only DataContainer.create()
can invoke the constructor of DataContainer
. We can achieve that via a secret token:
const secretToken = Symbol('secretToken');
class DataContainer {
#data;
static async create() {
const data = await Promise.resolve('downloaded');
return new this(secretToken, data);
}
constructor(token, data) {
if (token !== secretToken) {
throw new Error('Constructor is private');
}
this.#data = data;
}
getData() {
return 'DATA: '+this.#data;
}
}
DataContainer.create()
.then(dc => assert.equal(
dc.getData(), 'DATA: downloaded'));
Assuming that secretToken
and DataContainer
reside in the same module, outside parties don’t have access to secretToken
and therefore can’t create instances of DataContainer
.
Pros and cons:
The following variant of our solution disables the constructor of DataContainer
and uses a trick to create instances of it another way (line A):
class DataContainer {
static async create() {
const data = await Promise.resolve('downloaded');
return Object.create(this.prototype)._init(data); // (A)
}
constructor() {
throw new Error('Constructor is private');
}
_init(data) {
this._data = data;
return this;
}
getData() {
return 'DATA: '+this._data;
}
}
DataContainer.create()
.then(dc => {
assert.equal(dc instanceof DataContainer, true); // (B)
assert.equal(
dc.getData(), 'DATA: downloaded');
});
Internally, an instance of DataContainer
is any object whose prototype is DataContainer.prototype
. That’s why we can create instances via Object.create()
(line A) and that’s why instanceof
works in line B.
Pros and cons:
instanceof
works.Object.create()
can also be used for our previous solutions.DataContainer
, because those are only set up correctly for instances that were created via the constructor.Another, more verbose variant is that, by default, instances are switched off via the flag .#active
. The initialization method .#init()
that switches them on cannot be accessed externally, but Data.container()
can invoke it:
class DataContainer {
#data;
static async create() {
const data = await Promise.resolve('downloaded');
return new this().#init(data);
}
#active = false;
constructor() {
}
#init(data) {
this.#active = true;
this.#data = data;
return this;
}
getData() {
this.#check();
return 'DATA: '+this.#data;
}
#check() {
if (!this.#active) {
throw new Error('Not created by factory');
}
}
}
DataContainer.create()
.then(dc => assert.equal(
dc.getData(), 'DATA: downloaded'));
The flag .#active
is enforced via the private method .#check()
which must be invoked at the beginning of every method.
The major downside of this solution is its verbosity. There is also a risk of forgetting to invoke .#check()
in each method.
For completeness sake, I’ll show another variant: Instead of using a static method as a factory you can also use a separate stand-alone function.
const secretToken = Symbol('secretToken');
class DataContainer {
#data;
constructor(token, data) {
if (token !== secretToken) {
throw new Error('Constructor is private');
}
this.#data = data;
}
getData() {
return 'DATA: '+this.#data;
}
}
async function createDataContainer() {
const data = await Promise.resolve('downloaded');
return new DataContainer(secretToken, data);
}
createDataContainer()
.then(dc => assert.equal(
dc.getData(), 'DATA: downloaded'));
Stand-alone functions as factories are occasionally useful, but in this case, I prefer a static method:
DataContainer
.DataContainer.create()
looks.In general, subclassing is something to use sparingly.
With a separate factory function, it is relatively easy to extend DataContainer
.
Alas, extending the class with the Promise-based constructor leads to severe limitations. In the following example, we subclass DataContainer
. The subclass SubDataContainer
has its own private field .#moreData
that it initializes asynchronously by hooking into the Promise returned by the constructor of its superclass.
class DataContainer {
#data;
constructor() {
return Promise.resolve('downloaded')
.then(data => {
this.#data = data;
return this; // (A)
});
}
getData() {
return 'DATA: '+this.#data;
}
}
class SubDataContainer extends DataContainer {
#moreData;
constructor() {
super();
const promise = this;
return promise
.then(_this => {
return Promise.resolve('more')
.then(moreData => {
_this.#moreData = moreData;
return _this;
});
});
}
getData() {
return super.getData() + ', ' + this.#moreData;
}
}
Alas, we can’t instantiate this class:
assert.rejects(
() => new SubDataContainer(),
{
name: 'TypeError',
message: 'Cannot write private member #moreData ' +
'to an object whose class did not declare it',
}
);
Why the failure? A constructor always adds its private fields to its this
. However, here, this
in the subconstructor is the Promise returned by the superconstructor (and not the instance of SubDataContainer
delivered via the Promise).
However, this approach still works if SubDataContainer
does not have any private fields.
For the scenario examined in this blog post, I prefer either a Promise-based constructor or a static factory method plus a private constructor via a secret token.
However, the other techniques presented here can still be useful in other scenarios.