Search code examples
javascripttypescriptasync-awaitpromisees6-promise

How to await a value being set asynchronously?


Anticipated FAQ:

  • Yes, I know what a Promise is.
  • No, I can't simply move the init logic to the constructor. It needs to be called in the initMethod because the initMethod is a hook that needs to be called at a certain time.

Sorry, it's just that I saw some similar questions marked as "duplicate", so I wanted to put these FAQ at the top.

Question

My issue is the following race condition:

class Service {

  private x: string | null = null;

  initMethod() {
    this.x = 'hello';
  }

  async methodA() {
    return this.x.length;
  }
}

const service = new Service();
setTimeout(() => service.initMethod(), 1000);
service.methodA().catch(console.log);
TypeError: Cannot read properties of null (reading 'length')
    at Service.methodA (<anonymous>:15:19)
    at <anonymous>:20:9
    at dn (<anonymous>:16:5449)

I need something like a Promise whose settled value can be set from another part of the code. Something like:

class Service {

  private x: SettablePromise<string> = new SettablePromise();

  initMethod() {
    this.x.set('hello');
  }

  async methodA() {
    return (await this.x).length;
  }
}

const service = new Service();
setTimeout(() => service.initMethod(), 1000);
service.methodA().catch(console.log);

The best I can come up with is to make a class that polls a value until it turns non-null. I'm hoping there's something smarter. I don't want to have to fine-tune a poll interval.

Edits

Sorry for the initial confusing example. The race condition is that methodA can be called before initMethod.

There was also an unnecessary async in the initMethod. I just made it async because the real method it was based on is async.


Solution

  • In the following example, you can run the init before or after the async method call. Either will work -

    const s = new Service()
    // init
    s.init()
    // then query
    s.query("SELECT * FROM evil").then(console.log)
    
    const s = new Service()
    // query first
    s.query("SELECT * FROM evil").then(console.log)
    // then init
    s.init()
    

    deferred

    The solution begins with a generic deferred value that allows us to externally resolve or reject a promise -

    function deferred() {
      let resolve, reject
      const promise = new Promise((res,rej) => {
        resolve = res
        reject = rej
      })
      return { promise, resolve, reject }
    }
    

    service

    Now we will write Service which has a resource deferred value. The init method will resolve the resource at some point in time. The asynchronous method query will await the resource before it proceeds -

    class Service {
      resource = deferred() // deferred resource
      async init() {
        this.resource.resolve(await connect()) // resolve resource
      }
      async query(input) {
        const db = await this.resource.promise // await resource
        return db.query(input)
      }
    }
    

    connect

    This is just some example operation that we run in the init method. It returns an object with a query method that mocks a database call -

    async function connect() {
      await sleep(2000) // fake delay
      return {
        query: (input) => {
          console.log("running query:", input)
          return ["hello", "world"] // mock data result
        }
      }
    }
    
    function sleep(ms) {
      return new Promise(r => setTimeout(r, ms))
    }
    

    demo

    function deferred() {
      let resolve, reject
      const promise = new Promise((res,rej) => {
        resolve = res
        reject = rej
      })
      return { promise, resolve, reject }
    }
    
    class Service {
      resource = deferred()
      async init() {
        this.resource.resolve(await connect())
      }
      async query(input) {
        const db = await this.resource.promise
        return db.query(input)
      }
    }
    
    async function connect() {
      await sleep(2000)
      return {
        query: (input) => {
          console.log("running query:", input)
          return ["hello", "world"]
        }
      }
    }
    
    function sleep(ms) {
      return new Promise(r => setTimeout(r, ms))
    }
    
    const s = new Service()
    s.query("SELECT * FROM evil").then(console.log)
    s.init()

    always handle rejections

    If init fails to connect to the database, we need to reflect that with the resource, otherwise our program will hang indefinitely -

    class Service {
      resource = deferred()
      async init() {
        try {
          const db = await timeout(connect(), 5000) // timeout
          this.resource.resolve(db)
        }
        catch (err) {
          this.resource.reject(err) // reject error
        }
      }
      async query(input) {
        const db = await timeout(this.resource.promise, 5000) // timeout
        return db.query(input)
      }
    }
    
    function timeout(p, ms) {
      return Promise.race([
        p,
        sleep(ms).then(() => { throw Error("timeout") }),
      ])
    }