Techniques for instantiating classes

[2019-11-11] dev, javascript, oop
(Ad, please don’t block)

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 problem: initializing a property asynchronously  

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.

Solution: Promise-based constructor  

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:

  • The download may fail and produce a rejection.
  • An exception may be thrown in the body of the first .then() callback.

In either case, the errors become rejections of the Promise that is returned from the constructor.

Pros and cons:

  • A benefit of this approach is that we can only access the instance once it is fully initialized. And there is no other way of creating instances of DataContainer.
  • A disadvantage is that it may be surprising to have a constructor return a Promise instead of an instance.

Using an immediately-invoked asynchronous arrow function  

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;
  })();
}

Solution: static factory method  

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:

  • A benefit of this approach is that the constructor becomes simple.
  • A disadvantage of this approach is that it’s now possible to create instances that are incorrectly set up, via new DataContainer().

Improvement: private constructor via secret token  

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:

  • Benefit: safe and straightforward.
  • Disadvantage: slightly verbose.

Improvement: constructor throws, factory method borrows the class prototype  

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:

  • Benefit: elegant; instanceof works.
  • Disadvantages:
    • Creating instances is not completely prevented. To be fair, though, the work-around via Object.create() can also be used for our previous solutions.
    • We can’t use private fields and private methods in DataContainer, because those are only set up correctly for instances that were created via the constructor.

Improvement: instances are inactive by default, activated by factory method  

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.

Variant: separate factory function  

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:

  • The stand-alone function can’t access private members of DataContainer.
  • I prefer the way DataContainer.create() looks.

Subclassing a Promise-based constructor (advanced)  

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.

Conclusion  

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.

Further reading