iTranslated by AI

The content below is an AI-generated translation. This is an experimental feature, and may contain errors. View original article
🗒

[TS] Making Class Constructors Awaitable

に公開

Introduction

Have you ever wanted to perform asynchronous processing in a TypeScript constructor and await the instance directly?
I haven't particularly.

// Example: Connection to DB
const db = await new Db(url)

I tried to see if this could be achieved by inheriting from Promise.

PromiseLikeBase

First, I'll define a generic class for the boilerplate that arises when writing a "class whose constructor return value is Awaitable."

Implementation

class PromiseLikeBase<T> extends Promise<T> {
  #promise: Promise<T>
  constructor(tGetter: () => Promise<T>) {
    const {
      promise: tPromise,
      resolve: tResolve,
      reject: tReject,
    } = Promise.withResolvers<T>()
    super((resolve, reject) => tPromise.then(resolve).catch(reject))

    this.#promise = tGetter()
    this.#promise.then(tResolve)
    this.#promise.catch(tReject)
  }

  then: Promise<T>['then'] = (...args) => this.#promise.then(...args)
  catch: Promise<T>['catch'] = (...args) => this.#promise.catch(...args)
  finally: Promise<T>['finally'] = (...args) => this.#promise.finally(...args)
}

Explanation

constructor

In the constructor, the given asynchronous function is executed and assigned to this.#promise. Furthermore, the timing of its own resolution is synchronized with this.#promise.
Since I cannot access this before calling super, I am using Promise.withResolvers. If that restriction didn't exist, it would look like this:

constructor(tGetter: () => Promise<T>) {
  this.#promise = tGetter()
  super((resolve, reject) => this.#promise.then(resolve).catch(reject))
}

then / catch / finally

I've made then/catch/finally call the methods of this.#promise.
This is because calling super.then<R1, R2> results in:

(resolve: (value: T) => void, reject: (reason: any) => void) => void

being passed as the first argument of the constructor, causing it to be treated as tGetter and resulting in strange behavior.

While it's not impossible to make it work without re-implementing the methods by branching inside the constructor, it would result in code that is subjectively unfavorable, such as forcing types or needing to take unnecessary arguments.

// On the side of the class inheriting PromiseLikeBase
// Have it called like `super(() => someAsyncFunc(arg1), arg1)`
constructor(tGetter: () => Promise<T>, firstArg: any) {
  // When called via then, the string representation of the first argument's function becomes `function () { [native code] }`
  if (firstArg?.toString().includes('native code')) {
    super(firstArg)
    return
  }
  // Omitted
}

DB Connection (Mock)

I'll use this to write code simulating a connection to a database.

Implementation

async function sleep(ms: number) {
  await new Promise((resolve) => setTimeout(resolve, ms))
}

class _Db {
  async execute(query: string) { // mock
    await sleep(1000)
    console.log({ query })
  }
}

class Db extends PromiseLikeBase<_Db> {
  static async #connect(url: string) { // mock
    await sleep(1000)
    console.log({ url })
    return new _Db()
  }
  constructor(url: string) {
    super(() => Db.#connect(url))
  }
}

const db = await new Db('test://url') // -> { url: "test://url" }
db.execute('SELECT * FROM table;') // -> { query: "SELECT * FROM table;" }

Explanation

I was able to directly await the constructor like await new Db('test://url') and call methods on the returned value.

I also considered a way to reuse the same class before and after awaiting, but since it seemed annoying to have then and others show up in the completion suggestions, I decided to keep them separate.

Conclusion

I didn't feel the need to go this far just to await a constructor directly, so I think it's better to just stick to creating a static factory method.


Side Note

Using Symbol.species allows for a simpler implementation of PromiseLikeBase, but since the deprecation of Symbol.species is being considered, I didn't use it for the main sample code.

class PromiseLikeBase<T> extends Promise<T> {
  constructor(tGetter: () => Promise<T>) {
    super((resolve, reject) => tGetter().then(resolve).catch(reject))
  }

  static get [Symbol.species]() {
    return Promise
  }
}

Discussion