Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow async functions to return union type T | Promise<T> #33595

Open
5 tasks done
tncbbthositg opened this issue Sep 25, 2019 · 15 comments
Open
5 tasks done

Allow async functions to return union type T | Promise<T> #33595

tncbbthositg opened this issue Sep 25, 2019 · 15 comments
Labels
Awaiting More Feedback This means we'd like to hear from more people who would be helped by this feature Suggestion An idea for TypeScript

Comments

@tncbbthositg
Copy link

tncbbthositg commented Sep 25, 2019

Search Terms

"the return type of an async function or method must be the global Promise type"
"typescript promise union type"

Suggestion

You can explicitly declare a function's return type as a union that includes a Promise<T>.
While this works when manually managing promises, it results in a compiler error when using the async/await syntax.

For example, a function can have a return type T | Promise<T>. As the developer of an abstraction layer, this allows you to have an abstract return type that can be handled in a typesafe way but doesn't dictate implementation to consumers.

This improves developer ergonomics for consumers of a library without reducing the safety of the type system by allowing developers to change implementation as the system evolves while still meeting the requirements of the abstraction layer.

This only works, currently, if the developer explicitly manages the promises. A developer may start with something like this:

type ActionResponse<T> = T | Promise<T>;

function getCurrentUsername(): ActionResponse<string> {
  return 'Constant Username';
}

async function logResponse<T>(response: ActionResponse<T>): Promise<void> {
  const responseValue = await response;
  console.log(responseValue);
}

logResponse(getCurrentUsername());
// Constant Username

Then, if the consumer of logResponse switches to a promise based method, there's no need to change the explicit return type:

function getCurrentUsername(): ActionResponse<string> {
  // return 'Constant Username';
  return Promise.resolve('Username from Database');
}

// Username from Database

However, if the consumer of logResponse prefers to use async/await instead of manually managing promises, this no longer works, yielding a compiler error instead:

The return type of an async function or method must be the global Promise type.

One workaround is to always return promises even when dealing non-async code:

async function getCurrentUsername(): Promise<string> {
  return 'Constant Username';
  // return Promise.resolve('Username from Database');
}

Another workaround is to use an implicit return type:

async function getCurrentUsername() {
  return Promise.resolve('Username from Database');
}

These do get around the issue for sure, but they impose restrictions on consumers of the abstraction layer causing it to leak into implementation.

It seems valuable for the behavior to be consistent between using async/await and using Promise directly.

Use Cases

This feature would be useful for developers who are building abstraction layers and would like to provide an abstract return type that could include promises. Some likely examples are middlewares, IoC containers, ORMs, etc.

In my particular case, it's with inversify-express-utils where the action invoked can be either async or not and the resulting behavior doesn't change.

Examples

// this type
type ActionResponse<T> = T | Promise<T>;

// supports this function
function getCurrentUsername(): ActionResponse<string> {
  return 'Constant Username';
}

// as it evolves over time into this function
async function getCurrentUsername(): ActionResponse<string> {
  return Promise.resolve('Username from Database');
}

// and is handled transparently by functions like this
async function logResponse<T>(response: ActionResponse<T>): Promise<void> {
  const responseValue = await response;
  console.log(responseValue);
}

logResponse(getCurrentUsername());

Checklist

My suggestion meets these guidelines:

  • This wouldn't be a breaking change in existing TypeScript/JavaScript code
  • This wouldn't change the runtime behavior of existing JavaScript code
  • This could be implemented without emitting different JS based on the types of the expressions
  • This isn't a runtime feature (e.g. library functionality, non-ECMAScript syntax with JavaScript output, etc.)
  • This feature would agree with the rest of TypeScript's Design Goals.
@RyanCavanaugh
Copy link
Member

I don't understand the need for this. Why does the second getCurrentUsername have the async modifier? It works perfectly without it.

You only need the async modifier if you use the await operator in the function body, in which case the function never returns a bare T (even an immediate return triggers an event loop tick, per ES spec) and it makes more sense to just write Promise<string> as the return type.

@tncbbthositg
Copy link
Author

Hey @RyanCavanaugh, thanks for responding.

It's hard to reproduce my use case with a trivial example. Specifically, I'm using inversify-express-utils and TypeORM. In some cases, I'll start with a controller action like this:

function getCurrentUser(): User {
  return userFromSession();
}

Eventually, we need to get the user data from the database:

function getCurrentUser(): Promise<User> {
  const sessionUser = userFromSession();
  return User.find({ id: sessionUser.id });
}

I can make that change without changing the return type if they both return T | Promise<T>. In my case, specifically, that type includes a few other generic types so developers can return one of several various HTTP results supported by the framework.

So, up until this point, everything works swimmingly if you start your actions with this definition:

function getCurrentUser(): ActionResponse<User> {
  const sessionUser = userFromSession();
  return User.find({ id: sessionUser.id });
}

Then, when you realize you need to do something with a promise before returning, you could change the method like so:

function getCurrentUser(): ActionResponse<User> {
  const sessionUser = userFromSession();
  const user = User.find({ id: sessionUser.id });

  return user.then(user => /* do something with user */);
}

But, if you prefer the async/await syntax, you can't change your method to this:

async function getCurrentUser(): ActionResponse<User> {
  const sessionUser = userFromSession();
  const user = await User.find({ id: sessionUser.id });

  return /* do something with user */;
}

One neat thing about inversify-express-utils is that it doesn't really care what you return; it just does the right thing. I'd like to specify that my actions will return an ActionResult<T>. But, I can't do that when I use the async keyword because, even though my method returns a Promise<T> and a Promise<T> is valid in my return type, the compiler won't allow it.

The way I've worked around it is to make all controller actions async always. Then I don't have to change anything as the action evolves. But, it seems like it'd be cool if the behavior was consistent between manually chaining promises and using the async/await syntactic sugar (is it fair to call it syntactic sugar? . . . I'm not sure).

It seems like it would also be consistent with a function:

type ParameterValue = string | number;

function foo(): ParameterValue {
  return 'foo';
}

foo will always return a string, but it's helpful to assert that whatever foo returns now and in the future, you can pass it to anything that takes a ParameterValue.

Does that make sense?

Thanks again!

@RyanCavanaugh RyanCavanaugh added Awaiting More Feedback This means we'd like to hear from more people who would be helped by this feature Suggestion An idea for TypeScript labels Sep 25, 2019
@RyanCavanaugh
Copy link
Member

Thanks, that's a nice explanation!

@yortus
Copy link
Contributor

yortus commented Sep 26, 2019

Some previous discussion/examples about this: #6686 (comment) and onward

Other related issues: #5911, #5945

@falsandtru
Copy link
Contributor

falsandtru commented Sep 26, 2019

  1. I can find no reason not to change ActionResponse<T> = T | Promise<T> to ActionResponse<T> = Promise<T>.
  2. You shouldn't expose ambiguous return types such as ActionResponse<T> = T | Promise<T>.
  3. Expressing a return type of an async function as Non-Promise type is wrong with the actual return type and breaks consistency and reliability of return types. Nobody would want to check if this function has async keyword or not every time to know the actual return type in code reading.

@tncbbthositg
Copy link
Author

tncbbthositg commented Sep 26, 2019

@falsandtru, thanks very much for your thoughts! I find them very valuable.

TL;DR

I think after writing way too many words here, I've refined my opinion down to this:
I believe that it is valuable to explicitly declare that, "this method returns something TheFramework™ can handle;" however, I don't think it's valuable in every case to be required to specify exactly which kind of something it is if TheFramework™ doesn't care.

Or, maybe even less verbose:
I believe that it is valuable to be able to explicitly declare abstract return types.

Original Response :)

  1. I can find no reason not to change ActionResponse<T> = T | Promise<T> to ActionResponse<T> = Promise<T>.

I agree that "All non-trivial abstractions, to some degree, are leaky." I could easily change it to ActionResponse<T> = Promise<T>. In fact, that's what I've done.

The downside is that now an implementation detail of an abstraction layer is leaking into my codebase dictating a coding convention that's unnecessary and difficult to discover.

What I mean is, one reason not to change ActionResponse<T> is because I'm trying not to let an abstraction layer leak into my codebase. Whether or not that's a good reason is a different discussion.

Which is why I'd imagine what you mean is something more like, "I personally can't think of a good reason not to change your implementation right now." . I do think that trying to plug leaks in abstraction layers is a pretty good reason, but you're right that it may not be worth it.

I haven't looked at the implementation of the async keyword, but it seems like the intent is to provide a more ergonomic syntax for dealing with promises. This is from the ECMA-262 specification:

Async functions improve the asynchronous programming experience by providing syntax for promise‑returning functions.

Right now, in typescript, I have to chose one of the following options:

  1. Plug the leaky abstraction, keep the ergonomics of async/await, but use implicit return types
  2. Plug the leaky abstraction, keep explicit return types, lose the ergonomics of async/await
  3. Keep the ergonomics of async/await, keep explicit return types, let the abstraction leak

Just like this function's return type is valid (albeit an oversimplified example that doesn't express the value in returning union types):

function getName(): string | number {
    return 'Lulu';
}

I think it would be consistent (and therefore valuable) for this function's return type to also be valid:

async function getName(): string | Promise<string> {
    return 'Lulu';
}
  1. You shouldn't expose ambiguous return types such as ActionResponse<T> = T | Promise<T>.

I'm not sure that I'd say union types are ambiguous, even as return types. I do think that when abused, union types can be harder to reason about. But, I do feel like union types are pretty explicit.

Another personal opinion is leaking in here, but I do like union types as a language feature of TypeScript. I think the type disjunction with union types is valuable, and I also like using type guards for type refinement.

Here's a good example of returning a union type (from the TypeScript documentation on union types):

interface Bird {
    fly();
    layEggs();
}

interface Fish {
    swim();
    layEggs();
}

function getSmallPet(): Fish | Bird {
    // ...
}

let pet = getSmallPet();
pet.layEggs(); // okay
pet.swim();    // errors
  1. Expressing a return type of an async function as Non-Promise type is wrong with the actual return type and breaks consistency and reliability of return types.

Just to be clear, I don't think you should be allowed to say:

async getFoo(): string {
  // ...
}

I agree, that's probably wrong. Even though the compiler could infer the return type is implicitly wrapped in a promise, I don't care for it.

I feel like if you want implicit typing, use implicit typing . . . but I don't think you should mix the explicitness of the return type with the implicitness of wrapping that type with a promise when the async keyword is added.

But, that's an entirely different conversation altogether.

Considering this function:

async getFoo(): string | Promise<string> {
  // ...
}

getFoo is still returning a Promise<string>. The compiler doesn't have to do any tricks, implicitly type anything, make any inferences, nothing. What I mean is that the function above does not return a non-promise. What it returns is a type that can be a promise (and in this function, will always be a promise).

This just allows consumers not to care if it's a promise or not. That allows an abstraction layer to say, "hey, if you pass a callback to me, make sure that it returns a Result<T>. It can be async, it can be a Promise<T>, or it can just be T, I don't care . . . I'll take care of it."

This puts the onus of understanding on the abstraction layer and reduces the cognitive overhead of consumers of the abstraction. This kind of thing is useful in DI frameworks, ORMs, test frameworks, etc.

Nobody would want to check if this function has async keyword or not every time to know the actual return type in code reading.

I certainly can only speak for myself; I don't know what everyone wants to read. For me, I don't really associate the async keyword with the return type per se. I typically only expect to see the async keyword in conjunction with the await keyword.

In fact, this function may very well return a promise without the async keyword being present.

@yortus
Copy link
Contributor

yortus commented Sep 26, 2019

Or more briefly, TypeScript has a structural type system and pretty consistently applies it, except with async function return types, where it does a nominal, not structural, check.

Usually when you specify parameter and return type annotations on a function in TypeScript, the caller can pass in anything that is assignment-compatible with the parameter types, and the function can return anything that is assignment compatible with the return type. Likewise with variable assignments, property assignments, etc etc. Async function return types are a rare exception to this rule.

Even generator functions, which just like async functions always return a known system type, can be annotated with assignment-compatible return types which are checked structurally.

IMO there is an inconsistency here. The consequences are arguably not huge, but have been reported in a number of issues over the years so do affect at least some users. I can't really see the downsides of removing this inconsistency.

@larsrh
Copy link

larsrh commented Mar 24, 2020

Or more briefly, TypeScript has a structural type system and pretty consistently applies it, except with async function return types, where it does a nominal, not structural, check.

I think this hits the nail on the head. This nominal behaviour also prevents libraries from providing richer Promise types, i.e. true subtypes of the global Promise (use case: tracking what effects happen in the promise).

@emilgoldsmith
Copy link

I seem to have found a workaround that seems to work. I pass this type to express.

Haven't done deep diagnostics on it but it made all my TS compilation errors + eslint no-misused-promises errors go away, so thought I'd share.

type SyncRouteHandler = (req: Request, res: Response) => void;
type AsyncRouteHandler = (req: Request, res: Response) => Promise<void>;

export type RouteHandler = SyncRouteHandler | AsyncRouteHandler;

@parzhitsky
Copy link

To me, this doesn't make sense.

The async keyword doesn't make the function asynchronous, it makes the function wrap the returned value in a promise (it's a syntactic sugar construct); so, if a non-async function was returning T, its async counterpart returns Promise<T>. Therefore, signatures such as:

async function doStuff(): string {

… are simply incorrect, because it's actually Promise<string>.

You can, however, define a non-async function that returns either synchronously or asynchronously:

function doStuff(): string | Promise<string> {

… for example, depending on some external state:

function getUsername(): string | Promise<string> {
  if (cache.user != null)
    return cache.user.name;

  return db.getUser().then((user) => (cache.user = user).name);
}

… but making it async would make it always asynchronous (due to the promise wrapping), and thus defeat the purpose.

@tncbbthositg
Copy link
Author

@parzhitsky, thanks for your feedback. I wholeheartedly agree with you that an async function should always return a Promise. I just wanted to clarify that I don't think that's related to what I was bringing up here.

This particular issue isn't about what an async function returns; it's about what an async function can declare that it returns. In this particular case, it only really matters in the abstract. I think it would be odd to declare a function like this:

async function getValue() : string | Promise<string> { ... }

But, I don't think it would be strange to declare a function like this:

async function getValue(): ApiResponse<string>

or

async function getValue(): StateInitializer<string>

And, I don't think it's unreasonable for the author of a library to say, "I can handle either a promise or a non-promise . . . so give me either of those and I'll do the right thing with it," and export a type like:

export type ApiResponse<T> = T | Promise<T>;

// or

export type StateInitializer<T> = T | Promise<T>;

I think that the question really boils down to this:

Is it reasonable for a function to accept a T | Promise<T> as an argument? There are certainly libraries that do that today, but that doesn't mean it's reasonable. :)

function doWork<T>(value: T | Promise<T>) { ... }
function saveSomething<T>(value: T | Promise<T>) { ... }
function logSomething<T>(value: T | Promise<T>) { ... }

If it is reasonable for a function to accept a T | Promise<T>, then is it reasonable for the author of that function to export that a type declaring this union?

export type Action<T> = T | Promise<T>;
export type DataProvider<T> = T | Promise<T>;
export type Loggable<T> = T | Promise<T>;

If those are both reasonable, is it also reasonable for a consumer of this function to declare functions that explicitly return this abstracted type?

function handleSomeBehavior(): Action<string> { ... }
function getSomeData(): DataProvider<string> { ... }
function getSomeData(): Loggable<string> { ... }

Finally, if it's reasonable to explicitly return an abstracted type, is it reasonable to want to use the async keyword?

I don't know these answers. I do know that it's a use case I encountered. I just wanted to be able to say, "this function returns a thing I can pass to this other function" and TypeScript wouldn't let me. :)

Does that make any sense at all? It's really hard to explain this with trivial examples. It's too easy to say, "well, just change your trivial example." I was using a pretty popular library at the time that had a function that could take a T | Promise<T> and I couldn't declare an async function that explicitly returned the input type.

@parzhitsky
Copy link

Okay, so, if I understand correctly, you are saying that this is wrong (or weird at least):

async function doStuff(): string {

… and so is this:

async function doStuff(): string | Promise<string> {

But there are situations when you can’t really control the structure of the type union, since you don’t create it, like here:

import type ApiResponse from "some-library/types";
import { onlyAcceptsApiResponse } from "some-library";

declare function doStuff(): ApiResponse<string>;

onlyAcceptsApiResponse(doStuff());

… and if ApiResponse<string> in the example above turns out to resolve to string | Promise<string>, you cannot use async keyword anymore, and forced to fallback to .thens and .catches if working with promises.

But you also cannot do this:

type UnwrapPromise<Value> =
  Value extends PromiseLike<infer Bare> ? UnwrapPromise<Bare> : Value;

type PromiseOnly<Value> =
  Promise<UnwrapPromise<Value>>;

async function doStuff(): PromiseOnly<ApiResponse<string>> {

… because, while it works, it also requires for you to a) know the implementation of ApiResponse (which you shouldn’t know), and b) navigate your way through unreasonable TypeScript compiler errors that don’t bring any value.

Is this correct?

If it is, then I agree with the proposal. Not only that, I would actually go a bit further, and propose to remove the distinction between “bare” and promise-wrapped values in return signatures of async functions altogether. Meaning that these signatures:

async function doStuff(): string {
async function doStuff(): Promise<string> {

… should be considered indistinguishably identical.

@tncbbthositg
Copy link
Author

… because, while it works, it also requires for you to a) know the implementation of ApiResponse (which you shouldn’t know), and b) navigate your way through unreasonable TypeScript compiler errors that don’t bring any value.

Yes, this is correct!!

I think that this response by @yortus really succinctly describes it: #33595 (comment)

Thanks @parzhitsky for joining the discussion!

@ProdigySim
Copy link

I hit a somewhat related issue (that I can't find a more relevant issue for) with types that extend Promise. Specifically, with Prisma2 PrismaPromise types.

declare const prisma: unique symbol
type PrismaPromise<A> = Promise<A> & {[prisma]: true}

Prisma's API exposes methods like $transaction(...args: PrismaPromise<unknown>[]): Promise<void> which require PrismaPromise.

If you write some async function which eventually returns a PrismaPromise, this is valid, and at runtime the Prisma Promise will be returned.

// This function actually returns a "PrismaPromise" at runtime,
// but there's no way to tell TypeScript that!
async function updateItem(itemId: string, data: unknown) {
   await validateChanges(itemId, data);
   return prisma2.item.update(itemId, data);
}

// Typescript will error here, despite this being valid at runtime.
prisma2.$transaction(updateItem('foo', {}), updateItem('bar', {}));

It would be nice to be able to have typescript infer the Promise type returned, or at least allow us to decorate the method with : PrismaPromise<Item> manually. But this annotation is not allowed.

@Maxim-Mazurok
Copy link

In my case I wanted to declare an optional function that may be async or not and had no trouble doing so:

abstract class Reporter {
  afterAll?(): void | Promise<void>;
}

class ChartReporter extends Reporter {
  async afterAll() {
    await saveToFile();
  }
}

class CLIReporter extends Reporter {
  afterAll() {
    console.log("done");
  }
}

// Main program:
for (const reporter of reporters) {
  await reporter.afterAll?.();
}

(full source), hope this helps someone.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Awaiting More Feedback This means we'd like to hear from more people who would be helped by this feature Suggestion An idea for TypeScript
Projects
None yet
Development

No branches or pull requests

9 participants