rekmarks.com

Creational async side effects in TypeScript

In TypeScript and JavaScript, we sometimes define classes that have to perform some asynchronous work as part of their creation. This can range from the trivial, such as notifying some other part of the application that it exists, to the more involved, such as retrieving state from the network or disk. Ideally, we want this work to be completed by the time we get a reference to the created object. However, constructors must be synchronous,[1] so we can't simply await the work. Here follows three different patterns for dealing with the creational async side effects of your TypeScript and JavaScript classes.

1. A doSideEffects() method

Included mostly for completeness, the most naive approach is simply to add a method like doSideEffects(), and call that immediately after constructing the object:

// Foo.ts
class Foo {
  /**
   * Don't forget to call doSideEffects()!
   */
  constructor() {
    // only synchronous things
  }

  /**
   * Don't forget to call this after construction!
   */
  async doSideEffects() {
    await sideEffect();
  }
}

// use-foo.ts
const foo = new Foo();
await foo.doSideEffects();

This is my least favored way of solving the problem. For one thing, now you have to make two separate calls to initialize a single object. For another, it's too easy to forget to call the side effect method:

// lots of stuff
const foo = new Foo();
// oops!
// more lots of stuff

This is the kind of thing that leads to discoveries of the form "Turns out we weren't actually doing the thing we were supposed to be doing for several months." TypeScript won't complain if you forget to call the side effect method. If you want to catch it at runtime, you have to maintain some kind of state like #didSideEffects: boolean and check it in every method you depend on the side effects. This is bug-prone and ugly, and the world is ugly enough. Don't do this.

2. Put it in the constructor anyway

Also problematic, but better than the first option, the laziest approach is to simply stick the side effects in the constructor, asynchronicity be damned:

class Foo {
  constructor() {
    // Do not forget to add a catch handler
    sideEffect().catch(console.error);
  }
}

This is appropriate for more trivial use cases, where the failure of the side effect to complete does not cause anything else to enter an invalid state. For example, sending a metrics event or eagerly dispatching a notification are probably decent candidates for this pattern. As with any promise, you must add a catch handler to preclude unhandled promise rejections.

The main drawback of this approach is that it can be surprising. Consider this code:

const foo = new Foo();

Naively, would you expect that line to run off and talk to the network or read from disk or perform some similar shenanigans? I'm willing to wager that you wouldn't. Following the principle of least surprise, you should probably avoid putting async side effects in your constructors, even if doing so is "safe". There's no telling what problems you can cause by violating basic assumptions such as "constructing an object doesn't produce any network requests".

You should definitely avoid this pattern if the failure of the side effect causes your object to be unusable. In that case, you again have to maintain some kind of #didSideEffects state and check it in a bunch of places. Thankfully, a better option is available.

3. Static async factory method

Finally, we get to the good stuff. If your class has any side effects, big or small, you can probably put them in a static factory function. For added safety, you can also make the constructor private:

// Foo.ts
class Foo {
  private constructor(args: FooArgs) {
    // Only synchronous things
  }

  static async make(args: FooArgs) {
    const foo = new Foo(args);
    await sideEffects();
    return foo;
  }
}

// use-foo.ts
// const foo = new Foo(args); // Error!
const foo = await Foo.make(args);

Note that the constructor is only private at compile time, and anyone who gets a reference to your class at runtime can just call the constructor. Still, this is sufficient protection against honest misbehavior.

The only drawback of this pattern is that it can make testing a bit difficult, depending on the nature of your side effects. But, you can get around that issue with this alternative construction:[2]

// Foo.ts
// Export the class, but only import it in tests.
// Note that the type Foo should still be public.
export class Foo {
  constructor(args: FooArgs) {
    // Only synchronous things
  }
}

// Export this publicly
export async function makeFoo(args: FooArgs) {
  const foo = new Foo(args);
  await sideEffects();
  return foo;
}

// use-foo.ts
const foo = await makeFoo(args);

Conclusion

For creational async side effects in your objects and classes, prefer async factory functions, and do your best to avoid putting them constructors. If you can think of a good use for a separate side effect method, you are more creative than I am.


  1. Constructors can return promises in plain JavaScript, but TypeScript pretends like such evil is not possible, and insists that the type of new Foo() is always Foo. ↩︎

  2. You could even create a an internal class InternalFoo with a public constructor that's used in tests, and then a public class Foo with a private constructor and public factory method, but that's probably a bit much. ↩︎