vmtran
vmtran

Reputation: 61

Method chaining with async in Typescript (just found out a solution)

I am writing a module aims at preparing a query before calling it to database. The code in vanilla javascript worked pretty well, but when I tried to write it in Typescript, I got the error: Type of 'await' operand must either be a valid promise or must not contain a callable 'then' member

My code in Javascript:

class QueryBuilder {
  constructor(query) {
    this.query = query;
  }

  sort(keyOrList, direction) {
    this.query = this.query.sort(keyOrList);
    return this;
  }

  skip(value) {
    this.query = this.query.skip(value);
    return this;
  }

  limit(value) {
    this.query = this.query.limit(value);
    return this;
  }

  then(cb) {
    cb(this.query.toArray());
  }
}

Code in Typescript:

class QueryBuilder {
  public query: Cursor;
  constructor(query: Cursor) {
    this.query = query;
  }

  public sort(keyOrList: string | object[] | object, direction: any) {
    this.query = this.query.sort(keyOrList);
    return this;
  }

  public skip(value: number) {
    this.query = this.query.skip(value);
    return this;
  }

  public limit(value: number) {
    this.query = this.query.limit(value);
    return this;
  }

  public then(cb: Function) {
    cb(this.query.toArray());
  }
}

How I called these methods:

const query = await new QueryBuilder(Model.find())
    .limit(5)
    .skip(5)

Hope someone can help me with this. Thanks in advance.

*Updated: I extended QueryBuilder class from the buitin Promise, then overrided then method by QueryBuilder.prototype.then.The code is now executable but I didn't truly understand the super(executor) in the constructor. It's required an executor(resolve: (value?: T | PromiseLike<T> | undefined) => void, reject: (reason?: any) => void): void so I just simply created a dumb executor. How does it affect to the code?

class QueryBuilder<T> extends Promise<T> {
  public query: Cursor;
  constructor(query: Cursor) {
    super((resolve: any, reject: any) => {
      resolve("ok");
    });
    this.query = query;
  }

  public sort(keyOrList: string | object[] | object, direction?: any) {
    this.query = this.query.sort(keyOrList);
    return this;
  }

  public skip(value: number) {
    this.query = this.query.skip(value);
    return this;
  }

  public limit(value: number) {
    this.query = this.query.limit(value);
    return this;
  }
}

QueryBuilder.prototype.then = function (resolve: any, reject: any) {
  return resolve(this.query.toArray());
};

Upvotes: 6

Views: 2303

Answers (1)

Wing
Wing

Reputation: 9731

Problem

What is TypeScript doing?

The TypeScript algorithm for evaluating the type of the operand for await goes something like this (it's a very simplified explanation)[reference]:

  1. Is the type a promise? If yes go to step 1 with the type that is promised. If no go to step 2.
  2. Is the type a thenable? If no return the type. If yes throw a type error saying "Type of 'await' operand must either be a valid promise or must not contain a callable 'then' member (ts1320)".

What is TypeScript doing in your example?

Now knowing this, we can see what TypeScript is doing when checking your code.

The operand to await is:

new QueryBuilder(Model.find())
.limit(5)
.skip(5)
  1. The call to skip doesn't return a promise. We go to step 2 (Note: neither does the call to limit or the instantiation of QueryBuilder).
  2. skip returns the instance of QueryBuilder which has a callable then member. This results in the type error: "Type of 'await' operand must either be a valid promise or must not contain a callable 'then' member (ts1320)".

Your class definition with a callable 'then' member:

class QueryBuilder {
  public query: Cursor;
  constructor(query: Cursor) {
      this.query = query;
  }
  
  ...
  
  public then(cb: Function) {
      cb(this.query.toArray());
  }
}

Why does TypeScript error?

Now we understand how TypeScript threw the type error. But why does it throw this error? JavaScript lets you await on anything.

[rv] = await expression;

expression: A Promise or any value to wait for.
rv: Returns the fulfilled value of the promise, or the value itself if it's not a Promise.

MDN documentation on await

Why does TypeScript say "Type of 'await' operand [if it's not a valid promise] must not contain a callable 'then' member"? Why does it not let you await on a thenable? MDN even gives an example where you await on a thenable.

async function f2() {
  const thenable = {
    then: function(resolve, _reject) {
      resolve('resolved!')
    }
  };
  console.log(await thenable); // resolved!
}

f2();

MDN example awaiting a thenable

TypeScript's source code is helpfully commented. It reads:

The type was not a promise, so it could not be unwrapped any further. As long as the type does not have a callable "then" property, it is safe to return the type; otherwise, an error is reported and we return undefined.

An example of a non-promise "thenable" might be:

await { then(): void {} }

The "thenable" does not match the minimal definition for a promise. When a Promise/A+-compatible or ES6 promise tries to adopt this value, the promise will never settle. We treat this as an error to help flag an early indicator of a runtime problem. If the user wants to return this value from an async function, they would need to wrap it in some other value. If they want it to be treated as a promise, they can cast to <any>.

Reference

From reading this, my understanding is TypeScript does not await on non-promise thenables because it cannot guarantee the implementation matches the minimum spec as defined by Promises/A+ thus assumes it is an error.

Comments on your solution

In the solution that you've tried and added to the context of your question you've defined the QueryBuilder class to extend off of the native promise and then you've overridden the then member. While this seems to have the effect you want there are some problems:

Your class instantiation has unreasonable behaviour

As a result of extending a class you need to call its parent constructor before you can reference the this context. The type for parent class' constructor is:

(resolve: (value?: T | PromiseLike<T>) => void, reject: (reason?: any) => void) => void

And as you've found you need to pass in something that satisfies that contract for no other reason than to get it to work. Also after instantiation your class returns a promise resolved with an arbitrary value. Code that doesn't matter and is unreasonable is a source of confusion, unexpected behaviour and is a potential for bugs.

You've broken the type contract defined by the promise interface

The interface defines other methods such as catch. A consumer of the class may attempt to use the catch method and while the contract allows them to, the behaviour is not as expected. This leads on to the next point.

You're not using TypeScript to its advantage

You mentioned in your question:

The code in vanilla JavaScript worked pretty well, but when I tried to write it in TypeScript, I got the error

Types exist for a reason, one being they reduce the possibility of bugs by enforcing the contract between interfaces. If you try to work around them it leads to confusion, unexpected behaviour and an increased risk of bugs. Why use TypeScript in the first place?

Solution

Now that we understand what the error it is and why it is happening we can figure a solution. The solution will require the implementation of the then member to meet the minimum spec in Promises/A+ as we have determined this to be the cause of the error. We only care about the spec for the then interface, as opposed to its implementation details:

  • 2.2.1 Both onFulfilled and onRejected are optional arguments
  • 2.2.7 then must return a promise

The TypeScript definition for then is also useful to reference (note: I've made some changes for readability):

/**
 * Attaches callbacks for the resolution and/or rejection of the Promise.
 * @param onfulfilled The callback to execute when the Promise is resolved.
 * @param onrejected The callback to execute when the Promise is rejected.
 * @returns A Promise for the completion of which ever callback is executed.
 */
then<
  TResult1 = T,
  TResult2 = never
>(
  onfulfilled?:
    | ((value: T) => TResult1 | PromiseLike<TResult1>)
    | undefined
    | null,
  onrejected?:
    | ((reason: any) => TResult2 | PromiseLike<TResult2>)
    | undefined
    | null
): Promise<TResult1 | TResult2>;

The implementation itself will likely follow this algorithm:

  1. Execute your custom logic
  2. If step 1 was successful resolve with the result of your custom logic, otherwise reject with the error

Here is a demo of an example implementation that should get you started on your own implementation.

class CustomThenable {
  async foo() {
    return await 'something';
  }

  async then(
    onFulfilled?: ((value: string) => any | PromiseLike<string>) | undefined | null,
  ): Promise<string | never> {
    const foo = await this.foo();
    if (onFulfilled) { return await onFulfilled(foo) }
    return foo;
  }
}

async function main() {
  const foo = await new CustomThenable();
  console.log(foo);
  const bar = await new CustomThenable().then((arg) => console.log(arg));
  console.log(bar);
}

main();

Upvotes: 4

Related Questions