Skip to content

Enhancement: [no-floating-promises] Check PromiseLike in addition to Promise by default #9869

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

Closed
4 tasks done
JoshuaKGoldberg opened this issue Aug 23, 2024 · 11 comments
Closed
4 tasks done
Labels
enhancement: plugin rule option New rule option for an existing eslint-plugin rule evaluating community engagement we're looking for community engagement on this issue to show that this problem is widely important locked due to age Please open a new issue if you'd like to say more. See https://typescript-eslint.io/contributing. package: eslint-plugin Issues related to @typescript-eslint/eslint-plugin

Comments

@JoshuaKGoldberg
Copy link
Member

Before You File a Proposal Please Confirm You Have Done The Following...

My proposal is suitable for this project

  • I believe my proposal would be useful to the broader TypeScript community (meaning it is not a niche proposal).

Link to the rule's documentation

https://typescript-eslint.io/rules/no-floating-promises

Description

As of #8433 -> #9263, no-floating-promises has a checkThenables option (enabled by default as of v8) that stops it from flagging anything that's not explicitly a Promise type. That means the rule won't flag something that's explicitly marked as type PromiseLike (rather than Promise) without explicitly enabling checkThenables.

Coming out of conversation with @jakebailey: PromiseLike does semantically indicate a value is intended to be used like a Promise. That's different from something that coincidentally has a .then() which can be used with await (🤯).

Proposal: should we have the rule also check for PromiseLike, in addition to Promise?

Fail

declare function createThenable(): PromiseLike<void>;

createThenable();

Pass

declare function createThenable(): { then(a: () => void, b: () => void): void }

createThenable();

Additional Info

I'm in favor of this personally, for the stated reasoning.

In terms of whether this would be a breaking change: it almost feels like a bugfix rather than a feature? PromiseLike is quite rare in real-world usage. If this is accepted, I'd also be in favor of landing it in a minor version rather than a major.

💖

@JoshuaKGoldberg JoshuaKGoldberg added package: eslint-plugin Issues related to @typescript-eslint/eslint-plugin triage Waiting for team members to take a look enhancement: plugin rule option New rule option for an existing eslint-plugin rule labels Aug 23, 2024
@jakebailey
Copy link
Collaborator

For reference, now that checker.getAwaitedType is officially documented in TS 5.6 (present since 4.0), I wanted to simplify the rule to just use this:

function isPromiseLike(checker: ts.TypeChecker, type: ts.Type): boolean {
    const awaited = checker.getAwaitedType(type);
    return !!awaited && awaited !== type;
}

That would have caught all cases where TS thinks something is awaitable (unwrapping nested promises and so on), which would have fixed this issue.

checkThenables sort of gets in the way of that ☹️

@kirkwaiblinger
Copy link
Member

+1

@bradzacher
Copy link
Member

Doesn't the fastify API use PromiseLike - as in that was the entire problem we wanted to avoid flagging?

@JoshuaKGoldberg
Copy link
Member Author

It does, #8433 (comment) -> https://github.com/fastify/fastify/blob/dda569960ef2d0961a887ead0e63f4e8671a920e/types/instance.d.ts#L173. But that's just a convenience type - they could always copy just the relevant bits out of it.

I believe @mcollina is out this week and maybe the next, we should ping again the first week of September.

@mcollina
Copy link

I'm happy for whatever solution that allows us to declare a fire-and-forget promise in a way that does not require a line-by-line or type-by-type configuration and very little user intervention. Setting a config would be fine, even if it would be better if this is enabled by default.

From a practical perspective, any kind of solution that would allow this would work. Note that fire-and-forget promises are part of the language and their design.

On thenables, my opinion is that a Thenable does not imply behavior, just that it has a .then() method and it is therefore awaitable. It does not imply any Promise semantics, and the spec in fact "wraps" them in a promise internally. Therefore typescript-eslint should not assume anything more about them, specifically, that they must be awaited to avoid errors.

The important part is that you can declare a .then() method into any object, and then you would need to immediately see a lot of errors from typescript-eslint.

@kirkwaiblinger
Copy link
Member

Is there a known benefit to making this change in terms of use cases where the rule would flag, rather than implementation simplification on our side?

While the idea of flagging PromiseLikes sounds perfectly sensible, if it just runs into the fastify edge case again, without offering some other benefit, inaction may be better than action 🤔.

@kirkwaiblinger
Copy link
Member

(i take back my +1 and am back to undecided, since I didn't recall that the fastify case was PromiseLike, thanks @bradzacher for pointing that out 👍 )

@JoshuaKGoldberg JoshuaKGoldberg added evaluating community engagement we're looking for community engagement on this issue to show that this problem is widely important and removed triage Waiting for team members to take a look labels Aug 24, 2024
@JoshuaKGoldberg
Copy link
Member Author

👍 I too would like to see a use case for checking PromiseLike too.

@Josh-Cena
Copy link
Member

I'm also +0. Either treat all thenables as promises or don't.

@bradzacher
Copy link
Member

I personally would love for us to do this and simplify our logic to better align things with TS -- but it's just a hard sell for us to make this switch whilst there are known usecases in the community where we don't want to report on a "thenable" thing (eg fastify, node:test).

With that being said -- we could probably apply this change to the following without anyone being upset

no-misused-promises:

function isAlwaysThenable(checker: ts.TypeChecker, node: ts.Node): boolean {
const type = checker.getTypeAtLocation(node);
for (const subType of tsutils.unionTypeParts(checker.getApparentType(type))) {
const thenProp = subType.getProperty('then');
// If one of the alternates has no then property, it is not thenable in all
// cases.
if (thenProp === undefined) {
return false;
}
// We walk through each variation of the then property. Since we know it
// exists at this point, we just need at least one of the alternates to
// be of the right form to consider it thenable.
const thenType = checker.getTypeOfSymbolAtLocation(thenProp, node);
let hasThenableSignature = false;
for (const subType of tsutils.unionTypeParts(thenType)) {
for (const signature of subType.getCallSignatures()) {
if (
signature.parameters.length !== 0 &&
isFunctionParam(checker, signature.parameters[0], node)
) {
hasThenableSignature = true;
break;
}
}
// We only need to find one variant of the then property that has a
// function signature for it to be thenable.
if (hasThenableSignature) {
break;
}
}
// If no flavors of the then property are thenable, we don't consider the
// overall type to be thenable
if (!hasThenableSignature) {
return false;
}
}
// If all variants are considered thenable (i.e. haven't returned false), we
// consider the overall type thenable
return true;
}

await-thenable:

if (!tsutils.isThenableType(checker, originalNode.expression, type)) {

@JoshuaKGoldberg
Copy link
Member Author

JoshuaKGoldberg commented Jan 16, 2025

Chatted internally with the team. tl;dr: we're going to wontfix this for no-floating-promises and file a separate issue for other rules. Although we'd prefer to be more strict and catch more issues by default, we don't want to be disruptive to frameworks using PromiseLikes.

For visibility, summarizing our perspective and conversations such as #8433 (comment) after the ---...


We're not super enthused about intentionally building in such an escape hatch to the rule. The userland convention most of the time for Promise -and, to almost the same extent, the PromiseLike type- is that it's a task whose result the calling code should care about. Which means it should be awaited. This convention was settled on well before typescript-eslint and is still in use by most users.

The tricky thing is, there are a few frameworks -Fastify, node:test, RTK- that intentionally go against that convention. These frameworks have a select few APIs that intentionally use Promises as "fire-and-forget" indicators. Users don't need to await those function calls; they can just choose to do so if it's useful to them. There's no language-level delineation in JavaScript or TypeScript between a standard Promise vs. a "fire-and-forget" one. So the frameworks just type their code as Promise.

This presents a problem for linters such as typescript-eslint. We definitely can't turn no-floating-promises off by default because most of the time it's extremely useful. Fun fact: it's the single most requested rule that gets brought up in our support conversations with companies and with native speed linters about type-checked linting. But, since there isn't a way to delineate between standard vs. a "fire-and-forget" task, that means these framework "fire-and-forget" ones get falsely reported on by default. Which brings up the request: how do we make no-floating-promises by default report standard tasks, but not "fire-and-forget" tasks?"

My 🌶 hot take is that the best situation would have been for frameworks not to co-opt the Promise types to mean a "fire-and-forget" task. It contradicts existing user conventions and thus causes inconvenience for users trying to integrate the frameworks with typed linting. My recommendation is to use some other API design, such as an .on listener or returning an object whose property is the task.

But, assuming the API must use "fire-and-forget" Promises -either for legacy reasons or because I'm just blatantly wrong in that recommendation- then there are some strategies folks can take to avoid lint reports with no-floating-promises:


Separately: I've also filed #10666 and commented #10666 (comment) for the occasional request for typescript-eslint to provide its own SafePromise type.

@JoshuaKGoldberg JoshuaKGoldberg closed this as not planned Won't fix, can't repro, duplicate, stale Jan 16, 2025
@github-actions github-actions bot added the locked due to age Please open a new issue if you'd like to say more. See https://typescript-eslint.io/contributing. label Jan 24, 2025
@github-actions github-actions bot locked as resolved and limited conversation to collaborators Jan 24, 2025
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
enhancement: plugin rule option New rule option for an existing eslint-plugin rule evaluating community engagement we're looking for community engagement on this issue to show that this problem is widely important locked due to age Please open a new issue if you'd like to say more. See https://typescript-eslint.io/contributing. package: eslint-plugin Issues related to @typescript-eslint/eslint-plugin
Projects
None yet
Development

No branches or pull requests

6 participants