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.
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.
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.
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);
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.
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
.
↩︎
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. ↩︎