Async Constructor Pattern in JavaScript

Async Constructor Pattern in JavaScript

In this post I’ll show three design patterns that deal with async initialization and demonstrate them on a real-world example.

This is a little API pattern I came across while building a JS utility library that is using WebAssembly under the hood. Instantiating a WebAssembly instance is an async operation, so there is no way around propagating this to the caller1. Ideally, we would like to do this right in the constructor, but that is not possible (for good reasons).

Overview

A pattern that I’ve seen is to have an asynchronous init function that does most of the initialization, but I think that’s a bad idea:

  • Am I going to call init() more than once? In fact, what if I do?
  • Didn’t I already express my intent of initializing by saying new?
  • Bonus: Can I write it as a one-liner?

For these reasons, what I prefer is the following:

const b64 = await new Base64Encoder().initialized;

It might not be obvious, but this is valid JavaScript — no extra parenthesis required. But how does this work? Let’s look at the class definition.

I’m using (not yet finalized) private fields syntax for brevity, for production code I recommend using the WeakMap pattern instead.

class Base64Encoder {
  #instancePromise;
  #instance = null;

  // No async constructors possible, but we can use an IIAFE for async code.
  // Note that the return type of any async function is a promise, which we can store.
  constructor() {
    this.#instancePromise = (async () => {
      const response = await fetch('./base64.wasm');
      if (!response.ok) throw Error('...');
      const arrayBuffer = await response.arrayBuffer();
      return WebAssembly.instantiate(arrayBuffer); // no await
    })();
  }

  // Again, no async getters are possible, but we can return a promise.
  // Using a getter to ensure `readonly`-ness
  get initialized() {
    return this.#instancePromise.then(({ instance }) => {
      this.#instance = instance; // store the result
      return this; // this is what makes the one-liner possible!
    });
  }

  encode(data) {
    if (!this.#instance) throw Error("Didn't you forget something?");
    // Do something with `#instance`...
  }
}

This achieves our design objectives:

  • The initialization starts right away
  • We can’t call the initialize code more than once
  • We can create a new object in one line with a de-facto async constructor.

This is made possible by two key components:

  • A constructor that assigns a promise to a local variable (via an IIAFE) and
  • a getter that returns a promise that resolves to this. The getter ensures that the property is readonly.

A lot is going on during initialization. Looking at it again with added parenthesis, the order should be clearer:

const b64 = await ((new Base64Encoder()).initialized);
  1. A new object instance is created that starts the (async) initialization immediately
  2. The initialized getter returns a promise that eventually resolves to the newly created instance.
  3. The event loop takes over
  4. ???
  5. The initialization code finishes, “unblocking” the user code

While I wouldn’t recommend it due to error-proneness, initialization can also be split in two:

const b64 = new Base64Encoder();
// ...
await b64.initialized;
// ...
const str = b64.encode(/*...*/)

Async Method Design

The design above has one major problem: If the caller forgets to await the initialized promise, calls to the API methods will fail. Worse: It might work depending on the timing, effectively introducing a race condition EDIT: Nope, this is taken care of by the assignment to the #instance field.

There is a another solution that does not require any kind of initialization — at least none that the caller is aware of — but it requires that the methods themselves are async, even though they wouldn’t have to be otherwise:

class Base64Encoder {
  #instancePromise;

  constructor() {
    this.#instancePromise = (async () => {
      const response = await fetch('./base64.wasm');
      if (!response.ok) throw Error('...');
      const arrayBuffer = await response.arrayBuffer();
      return WebAssembly.instantiate(arrayBuffer);
    })();
  }

  async encode() { //!!4
    const { instance } = await this.#instancePromise;
    // Do something with `instance`...
  }
}

The main idea here is that we await the initialization promise at the beginning of every method call. It’s important to understand that the initialization code only runs once. Once a promise is resolved, we can await it as many times as we want, and it will resolve instantly (but there might be some minor overhead associated with the creation of a Promise behind the scenes).

Now the API pattern as changed to

const b64 = new Base64Encoder()
const encoded = await b64.encode(/*...*/)

Lazy-Initialization

Even though I stated in the beginning that immediate initialization is a design goal, one could reasonably prefer to delay initialization to a later point. The aforementioned async init() method will work in that case, but we could also delay it to the last possible moment: The first method call. Expanding on the async method design from before, we only have to make a minimal change:

class Base64Encoder {
  #instancePromise = null;

  async encode() { //!!11
    this.#instancePromise = this.#instancePromise || (async () => {
      const response = await fetch('./base64.wasm');
      if (!response.ok) throw Error('...');
      const arrayBuffer = await response.arrayBuffer();
      return WebAssembly.instantiate(arrayBuffer);
    })();

    const { instance } = await this.#instancePromise;
    // Do something with `instance`...
  }
}

Ruby developers have seen this pattern before in the form of the ||= operator. The result is the same: We assign to #instancePromise only once, and repeated invocations of encode are waiting for the same promise to resolve.

Conclusion

In this post I’ve showed three design patterns that deal with async initialization and demonstrated them on a real-world example. I’ve shown which pattern I prefer. While writing this post, I’ve stumbled on several more ideas that you can find below. There, you’ll also find your favorite pattern from that book I didn’t read.

Appendix

Why use classes?

I’m not a fan of classes but I will use them where they make sense, which is the case here because:

  • Node docs recommend isolating state for its upcoming ES modules support, for reasons explained in the linked article.
  • For the WASM Base64 example above, it’s a good idea to give the user a hand in managing memory consumption, if only indirectly.

    Specifically, the implementation might need to grow the WebAssembly memory to fit the data. Without attaching the WebAssembly instance to an object, the memory could never be garbage collected. We could discard the instance ourselves after each call, but that would prevent legitimate cases where the caller might want to use the same instance multiple times. I suspect this is also the reason why TextEncoder and TextDecoder are designed as classes and not functions.

Master Design

The following code combines all 3 approaches outline above + the manual call to init() that I’ve mentioned in passing. While I said earlier that I don’t like the init pattern, note that the implementation below is protected against multiple invocations.

class Base64Encoder {
  #instancePromise = null;
  #instance = null;

  async init() {
    this.#instancePromise = this.#instancePromise || (async () => {
      const response = await fetch('./base64.wasm');
      if (!response.ok) throw Error('...');
      const arrayBuffer = await response.arrayBuffer();
      return WebAssembly.instantiate(arrayBuffer);
    })();
    const { instance } = await this.#instancePromise;
    this.#instance = instance;
  }

  get initialized() {
    return this.init().then(() => this);
  }

  encode() {
    if (!this.#instance) throw Error("Didn't you forget something?");
    // ...
  }

  #promises = {
    async encode(data) {
      await this.init();
      const { instance } = await this.#instancePromise;
      // ...
    },
  };

  get promises() {
    return this.#promises;
  }
}

Typestate Pattern

When using TypeScript (but works with vanilla JS too), the initial design can be extended to include an additional class called Base64EncoderInitialized or similar that implements the encode method, while removing it from the original. Adapting the initialized promise so that it resolves to an instance of the new class gives us the static guarantee that encode can only be invoked after initialization is completed.

get initialized() {
  return this.#instancePromise.then(({ instance }) => new Base64EncoderInitialized(instance))
}

Factory Functions

Instead of working around constructor and getter limitations, we can just define a static async function that does the initialization work and returns the new instance. We can spice it up by using a private symbol to prevent callers from creating uninitialized instances via the constructor (JS’ version of private constructors):

const CREATE = Symbol('create');

class Base64Encoder {
  static async create() {
    const obj = new Base64Encoder(CREATE);
    // Do async initialization here
    return obj;
  }

  constructor(token) {
    if (token !== CREATE) {
      throw Error("Base64Encoder can't be created via constructor, use Base64Encoder.create instead");
    }
  }
}

// Usage
const base64 = await Base64Encoder.create();
  1. See this article for more. While the article explains the subject matter well, I dislike that the author takes aim at async functions specifically, which is misplaced. The problem existed in node from Day 1: Once an API uses callbacks, all downstream code has to effectively adopt callbacks as well. To put things in perspective, back then nobody was complaining about the color of functions, everyone was busy talking about callback hell, which was the immediate and much larger problem. It is now effectively solved by async functions. ↩︎


© 2024 Florian Klampfer

Powered by Hydejack v9.2.0