6

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());
};
6
  • 1
    I dont see the need of adding an await in the query initialization statement. QueryBuilder class initialization operation doesnt return any promise, hence the error. Commented Jul 10, 2020 at 11:27
  • And you should still be able to chain the operations even after removing the await from the query instantiation statement. Commented Jul 10, 2020 at 11:28
  • 2
    @DhruvShah this.query.toArray() presumably returns a Promise and hence, you have to await its result. The chaining itself is also not the problem. The point of OP's code is to actually execute the async query and await its result when used with await. Commented Jul 10, 2020 at 11:52
  • 3
    For goodness' sake, don't give your class a .then method if it doesn't conform to the Promises/A+ spec (it doesn't). You don't want to set off another flame war. Commented Jul 10, 2020 at 18:54
  • 2
    @vmtran No, what I said is not at all specific to Typescript. Yes, you can implement a .then function in such a way that it is not Promisea/A+ compliant but async is still able to handle it, but it's a horrible practice. If someone actually tried to use your .then method directly, they would wind up with a Zalgo on their hands. They'd also wind up with an error if they understandably tried to chain off of it. Commented Jul 11, 2020 at 16:02

1 Answer 1

4

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();
Sign up to request clarification or add additional context in comments.

17 Comments

I think you answered a different question than the one that the OP asked ;) In JavaScript, you can await "any value" (e.g. await 42) and when that value has a callable then member, it will execute that and return its resolved value instead. OP's code works in JavaScript, but not in TypeScript. The question is why doesn't it work in TypeScript. Why "must [it] not contain a callable 'then' member"?
@str: The first part of the error ("Type of 'await' operand must either be a valid promise") was a red herring. I thought TS would enforce that literally but some experimentation revealed it did not. Turns out it's because the class has a callable then member which is the second part of the error.
Yes but why "must [it] not contain a callable 'then' member" in TypeScript while that is perfectly valid (and not uncommon) in JavaScript? OP wants to await the query execution, not the query itself.
This is an interesting situation because ES async/await operates on thenables, not just promises.
The issue is that.. in short, if I have a promise called a. And I call a.then(..); a.then(...); in sequence, so not chained.. the expectation of promises is that I get the same result back, and not re-do the operation.
|

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.