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

1

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());
};
Sassoon answered 10/7, 2020 at 11:22 Comment(6)
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.Hols
And you should still be able to chain the operations even after removing the await from the query instantiation statement.Hols
@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.Holzer
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.Input
@Input it may be right in the world of Typescript but I guess it's accepted in Javascript though. The thenable object is documented by Mozilla (see link) so it should be 100% valid. Btw thanks for your reply.Sassoon
@Sassoon 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.Input
O
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();
Orten answered 10/7, 2020 at 11:35 Comment(17)
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"?Holzer
@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.Orten
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.Holzer
This is an interesting situation because ES async/await operates on thenables, not just promises.Embryogeny
Thanks for asking these questions btw. They are really food for thought. Here's some more experimentation. You can look at it while I write something up.Orten
I've added another case awaiting on an object that isn't a thenable.Orten
I've updated with an expansion on what I understand from why this is happening. Will update with a solution later.Orten
Thanks for this great answer. It will be even greater if you can produce some workaroundsSassoon
@vmtran: I've updated with a description of the solution and a demo showing an example implementation.Orten
@Orten thanks, btw I also added my solution on the top of this topic. Can you take a look at it and give me some advices?Sassoon
@vmtran: I've added comments on your solution in an update to my answer under the heading comments on your solutionOrten
An unexpected side-effect of this code is that multiple calls to then() will re-run the process multiple times. Maybe that's fine here, but just pointing this out as this is not how promises normally behave.Glassful
Anyway, this was my solution: evertpot.com/await-fluent-interfacesGlassful
@Evert: I'm not sure multiple calls to then() will re-run the process multiple times because the then method of the CustomThenable returns a native promise, not the instance of CustomThenable. Further chained thens will be from the native promise and will behave as normal. Unless I am misunderstanding your comment entirely, could you clarify or provide an example please? Thank you for sharing your solution as well!Orten
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.Glassful
@Evert: Ahh that is a good point. I want to update this answer to resolve that issue. I was thinking of either: working out my own solution (which may have a similar or be the same implementation as the one you've worked out), change this answer to a community wiki and you can update this answer, update with a note regarding its caveat and link to your post or you can post your own answer and I'll update this answer with a link to your own. What do you think?Orten
Either way ^_^. Glad my comment was helpful =)Glassful

© 2022 - 2024 — McMap. All rights reserved.