-
-
Notifications
You must be signed in to change notification settings - Fork 2.8k
feat(eslint-plugin): [thenable-in-promise-aggregators] disallow non-Thenables to promise aggregators #8094
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
Conversation
Thanks for the PR, @Tjstretchalot! typescript-eslint is a 100% community driven project, and we are incredibly grateful that you are contributing to that community. The core maintainers work on this in their personal time, so please understand that it may not be possible for them to review your work immediately. Thanks again! 🙏 Please, if you or your company is finding typescript-eslint valuable, help us sustain the project by sponsoring it transparently on https://opencollective.com/typescript-eslint. |
✅ Deploy Preview for typescript-eslint ready!
To edit notification comments on pull requests, go to your Netlify site configuration. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nice start! Thanks for working on it!
Just requesting some changes:
packages/eslint-plugin/docs/rules/thenable-in-promise-aggregators.md
Outdated
Show resolved
Hide resolved
packages/eslint-plugin/src/rules/thenable-in-promise-aggregators.ts
Outdated
Show resolved
Hide resolved
packages/eslint-plugin/src/rules/thenable-in-promise-aggregators.ts
Outdated
Show resolved
Hide resolved
packages/eslint-plugin/src/rules/thenable-in-promise-aggregators.ts
Outdated
Show resolved
Hide resolved
packages/eslint-plugin/tests/rules/thenable-in-promise-aggregators.test.ts
Outdated
Show resolved
Hide resolved
packages/eslint-plugin/src/rules/thenable-in-promise-aggregators.ts
Outdated
Show resolved
Hide resolved
packages/eslint-plugin/src/rules/thenable-in-promise-aggregators.ts
Outdated
Show resolved
Hide resolved
packages/eslint-plugin/src/rules/thenable-in-promise-aggregators.ts
Outdated
Show resolved
Hide resolved
packages/eslint-plugin/tests/rules/thenable-in-promise-aggregators.test.ts
Outdated
Show resolved
Hide resolved
I believe I covered all the comments. I left conversations open when there may be ambiguity, but since it's easy to miss (I almost missed 5 due to the hidden comments), I'll summarize still pending thoughts here that I'm seeking advice on the best way to resolve:
{
code: `
class Foo extends Promise<number> {}
await Foo.all([0]);
`,
errors: [
{
line: 3,
messageId: 'inArray',
},
],
}
|
packages/eslint-plugin/src/rules/thenable-in-promise-aggregators.ts
Outdated
Show resolved
Hide resolved
const elementType = services.getTypeAtLocation(element); | ||
if (isTypeAnyType(elementType) || isTypeUnknownType(elementType)) { | ||
continue; | ||
} | ||
|
||
const originalNode = services.esTreeNodeToTSNodeMap.get(element); | ||
if (tsutils.isThenableType(checker, originalNode, elementType)) { | ||
continue; | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
this logic (if isTypeAnyType(sometype) || isTypeUnknownType(sometype)
or tsutils.isThenableType(checker, originalNode, sometype)
) is used three times in this rule. Maybe we should move it to the function to avoid duplicated code?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hmm; I refactored around a bit, though I still have 3 calls like this. However, I don't think something like this would add anything besides some mental overhead:
function isTypeAnyOrUnknownType(type: ts.Type): boolean {
return isTypeAnyType(type) || isTypeUnknownType(type);
}
For this to be useful from my current perspective there would need to be a more salient name for the method that is a more useful concept than checking for any or unknown
and makes the implementation obvious (since it's so short). I looked elsewhere around the codebase and any types are not universally treated the same as unknown types, e.g., I could imagine a flag on this check later, perhaps two flags (one for any and one for unknown), and depending on exactly how those flags work it might make sense to break it out then, but more likely even if you made those flags it would still not be helpful to have a function like this given you would want different errors each which explanations of the flag to use to disable that feature, ect.
packages/eslint-plugin/src/rules/thenable-in-promise-aggregators.ts
Outdated
Show resolved
Hide resolved
packages/eslint-plugin/src/rules/thenable-in-promise-aggregators.ts
Outdated
Show resolved
Hide resolved
packages/eslint-plugin/src/rules/thenable-in-promise-aggregators.ts
Outdated
Show resolved
Hide resolved
packages/eslint-plugin/tests/rules/thenable-in-promise-aggregators.test.ts
Outdated
Show resolved
Hide resolved
packages/eslint-plugin/src/rules/thenable-in-promise-aggregators.ts
Outdated
Show resolved
Hide resolved
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Was the decision to create a new rule discussed anywhere? I thought it would be a new option for await-thenable
, like promiseAggregators: "all" | "any" | "ignore"
.
Happy new year! Rebased onto the current state of the main branch @Josh-Cena There was no discussion AFAIK, I initially made this as a custom eslint rule for myself and so it was easier to translate it over as its own ESLint rule. I'm impartial to including it directly in await-thenable. The only main question I have is how to handle the defaults for the flags? Right now it's in what I think is the ideal case: included when enabling If we add it as configuration for I imagine this came up any other time typescript-eslint wanted to add flags, so I just need to be pointed in the right direction for how to handle them. It would be a little annoying for my use-case, though certainly not the end of the world, if I had to remember to specifically enable these flags on every project to make use of the rule, especially since I would likely only notice it's disabled after the issue it attempts to resolve bit me! |
Yeah, good point about different options. I think this fits in #7318. |
meta: { | ||
docs: { | ||
description: | ||
'Disallow passing non-Thenable values to promise aggregators', |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[Docs] Interesting, we don't have a standard for whether we refer to Promises with an upper-case P or lower-case p... Outside the scope of this PR, maybe it'd be a good followup to file an issue about standardizing this in docs?
Not a blocker or request for changes 😄 just ruminating.
if ( | ||
callee.property.type !== AST_NODE_TYPES.Literal || | ||
typeof callee.property.value !== 'string' || | ||
!aggregateFunctionNames.includes(callee.property.value) | ||
) { | ||
return; | ||
} | ||
} else if (!aggregateFunctionNames.includes(callee.property.name)) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[Refactor] This "get the name one way if it's computed or another if it's static" need comes up a lot in rules. We should have a shared utility for it. getStaticStringValue
should do the trick, or getStaticValue
. One of those 😄.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There is already a function getStaticStringValue
in eslint-plugin/src/util
but it's about finding what the source representation of a value would be, e.g., turning a regex into /this/
or null into the string null
, etc.
To avoid confusion with that I've broken out a getMemberName
function you can look at and see if it might be worth breaking out into a shared utility
packages/eslint-plugin/src/rules/thenable-in-promise-aggregators.ts
Outdated
Show resolved
Hide resolved
packages/eslint-plugin/src/rules/thenable-in-promise-aggregators.ts
Outdated
Show resolved
Hide resolved
packages/eslint-plugin/src/rules/thenable-in-promise-aggregators.ts
Outdated
Show resolved
Hide resolved
packages/eslint-plugin/src/rules/thenable-in-promise-aggregators.ts
Outdated
Show resolved
Hide resolved
` | ||
async function test() { | ||
const intersectionArr: (Promise<number> & number)[]; | ||
await Promise.all(intersectionArr); | ||
} | ||
`, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah I think unions should be allowed. It's a nice convenience and intentionally part of the Promise aggregator design to allow this.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is looking great, thanks for working so much on this rule! I think @auvred reviewing earlier has made my job a lot easier 😄 thanks for that too!
I think I responded to all the pending conversations but I'm not sure. There was a lot to go through 😅. If there's anything I missed, please do start a new comment thread in a file and I'll take a look.
Requesting changes on some more testing and on reusing a shared utility for a static key value. The other points aren't blocking IMO as we can file them as followup issues.
requiresTypeChecking: true, | ||
}, | ||
messages: { | ||
inArray: |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[Question] Is there a use case for adding an option to allow users to provide an array/tuple where only some of the elements are thenables? Like Promise.all([callAsyncThing(), "other", callAsyncThing()])
?
I can't think of one 🤔 so maybe this is a non-actionable comment?
Btw, sorry if I missed this in previous discussion - I looked through and couldn't find anything.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is the main case I'm specifically looking to lint, and is the most common case where mistakes are made. There is no advantage to doing this from the perspective of control flow (i.e., how long the returned promise takes to fulfill), however, it does change the return value. However, whenever you really do want to provide a non-Thenable to change the control flow, you can always wrap it in Promise.resolve
to get exactly the same behavior while making it way more clear how the aggregator will behave. This is still true even when considering if the returned promise is fulfilled synchronously vs asynchronously: these promise aggregators only fulfill synchronously when passed an empty array.
I would argue especially for all of these promise aggregators, the most important thing is making the control flow clear. Any non-Thenable value passed to a promise aggregator will be treated as Promise.resolve(value)
according to the spec; see e.g., PerformPromiseAll. Thus given that a value in the list is known to be non-Thenable, there is a static conversion available that will make the control flow easier to understand, or you can be explicit and wrap the value in Promise.resolve, which jumps out at the reader and thus also makes the control flow easier to understand.
Given:
declare const aPromise: Promise<number>;
declare const bPromise: Promise<number;
declare const cNumber: number;
// all
Promise.all([aPromise, bPromise, cNumber])
// equivalent to
Promise.all([aPromise, bPromise, Promise.resolve(cNumber)])
// which resolves in the same amount of time as
Promise.all([aPromise, bPromise])
// previously working, now causes lint error
const [a, b, c] = await Promise.all([aPromise, bPromise, cNumber]);
// suggested change
const [a, b] = await Promise.all([aPromise, bPromise]);
const c = cNumber;
// allSettled
Promise.allSettled([aPromise, bPromise, cNumber]);
// equivalent to
Promise.allSettled([aPromise, bPromise, Promise.resolve(cNumber)]);
// resolves in the same time as
Promise.allSettled([aPromise, bPromise]);
// previously working, now causes lint error
const [aPromise2, bPromise2, cPromise] = await Promise.allSettled([aPromise, bPromise, cNumber]);
// suggested change
const [aPromise2, bPromise2] = await Promise.allSettled([aPromise, bPromise]);
const cPromise = Promise.resolve(cNumber);
// race
await Promise.race([aPromise, bPromise, cNumber]);
// equivalent to
await Promise.race([aPromise, bPromise, Promise.resolve(cNumber)]);
// always resolves immediately (but never synchronously, as Promise.race is never synchronous)
// previously working, now causes lint error
// this is a if a is immediately resolved, b if b is immediately resolved, otherwise c
const aOrBOrC = await Promise.race([aPromise, bPromise, cNumber]);
// suggested change if you really did want the above behavior (most of the time you do NOT)
const aOrBOrC = await Promise.race([aPromise, bPromise, Promise.resolve(cNumber)]);
// suggested change if you actually intended to wait for something to happen
const aOrB = await Promise.race([aPromise, bPromise])
// note that most of the time this comes up, this is whats actually happening:
declare const aPromise: Promise<number>;
declare const bPromise: Promise<number>;
declare const cWrappedPromise: { promise: Promise<number> };
// lint error that catches a real subtle bug
const aOrBOrC = await Promise.race([aPromise, bPromise, cWrappedPromise]);
// correct code
const aOrBOrC = await Promise.race([aPromise, bPromise, cWrappedPromise.promise]);
// any is the same as race for the above purposes, the difference is how rejected values are handled
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[Non-Actionable] I realized a situation where this option would be helpful! [playground link]
declare function fetch(url: string): Promise<object>;
function example() {
return Promise.all([
fetch("/"),
"in the middle",
fetch("/")
]);
}
If I was the developer here using Promise.all
directly to avoid having to make & manage a [object, string, object]
tuple, I'd be annoyed at the rule asking me to explicitly Promise.resolve("in the middle")
. There's no benefit in this situation beyond consistency - at the cost of simplicity.
But, I get what you're saying that this is really the main point of the rule. I think it's fine for now to leave as-is. Since it's only enabled in the strict config, it should just show up for users who have opted into the more strict opinions.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Please do add an option that allows mixed arrays. I often parallel a bunch of tasks and sometimes tasks shift between being sync and async (for example, because it no longer dynamically imports a library, or it no longer reads a config file). I really don't want to break the symmetry in this case.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In that case, +1ing Josh's request 🙂
packages/eslint-plugin/tests/rules/thenable-in-promise-aggregators.test.ts
Show resolved
Hide resolved
packages/eslint-plugin/tests/rules/thenable-in-promise-aggregators.test.ts
Show resolved
Hide resolved
packages/eslint-plugin/tests/rules/thenable-in-promise-aggregators.test.ts
Show resolved
Hide resolved
(the force-pushes are just rebasing with main to keep it in sync) |
packages/eslint-plugin/docs/rules/thenable-in-promise-aggregators.md
Outdated
Show resolved
Hide resolved
packages/eslint-plugin/docs/rules/thenable-in-promise-aggregators.md
Outdated
Show resolved
Hide resolved
packages/eslint-plugin/tests/rules/thenable-in-promise-aggregators.test.ts
Show resolved
Hide resolved
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is looking great, thanks! 🚀
Just requesting changes on the use cases I commented in threads.
@@ -105,7 +117,7 @@ export function isBuiltinTypeAliasLike( | |||
export function isBuiltinSymbolLike( | |||
program: ts.Program, | |||
type: ts.Type, | |||
symbolName: string, | |||
predicate: (symbolName: string) => boolean, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[Refactor] Every one of the uses for this function does >=1 ===
check. How about using a Set
instead to make it more clear what's happening?
predicate: (symbolName: string) => boolean, | |
symbolNames: Set<string> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
By the way, these functions are already included in several public releases. Shouldn't we keep the already published signatures to avoid breaking changes? Perhaps we can add this new functionality as overload/union? Although these packages are not documented yet, not sure, just asking 😄
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Predicates are more composable than sets and offer easier ergonomics; for sets; (v) => symbolNameSet.has(v)
is just as readable as symbolNameSet
, and for looking up a single value (symbolName) => symbolName === 'asdf'
is in my opinion much easier to interpret than new Set(['foo'])
which offers no context for what the set is for, so you end up needing either a comment or breaking it out into a constant. Finally, predicates can be more consistently applied; e.g., we already have predicates for isBuiltinSymbolLikeRecurser
, so switching between predicates and sets for arguments requires constantly deciding which ones "tend to be used like sets". Furthermore, predicates portray the intent of the argument more clearly: a set has many functions besides just contains-checking, including iteration. Providing a predicate makes it clear the only part the library function needs is the yes/no answer on each element.
For @auvred 's comment; happy to add the overload/union; adds a little clutter though, so will wait for more discussion since I'm not familiar enough with the project to have any opinion for backwards compatibility vs tidiness for those helpers
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
backwards compatibility
Yup, this'll need to retain backwards compatibility. I haven't heard of anybody telling us they use it, but a Sourcegraph query for TypeScript files referring to it shows a few public repos that do.
(v) => symbolNameSet.has(v)
is just as readable assymbolNameSet
I think this is subjective 😄 I find the latter more intuitive. In my mind I'm kind of implicitly applying the context from "is built-in symbol like" to the last parameter. But that's just me.
offers no context for what the set is for, so you end up needing either a comment or breaking it out into a constant
This is equally applicable to both a predicate and a set. For both, a descriptive parameter name like allowedSymbolNames
or isAllowedSymbolName
fixes the context issue.
predicates portray the intent of the argument more clearly
a set has many functions besides ...
I suppose Pick<Set<String>, 'has'>
would be the most precise thing to take in. All we really want in any of this is to know whether a name is what we're looking for.
One flaw of functions is it leaks the implementation detail of "this gets called up to once per symbol name", so folks can do weird things like:
isBuiltinSymbolLike(program, type, (name) => {
someRegistry.add(name);
if (someAttribute(name)) {
context.report({ data: { name }, messageId: "someMessage", node: someNode });
}
});
Just pointing this out as a counterpoint to the idea that a predicate adds more practical or semantic safety. I don't think we have to worry about anybody doing such an odd thing.
Anyway, the issue of whether we use a set or predicate is not particularly important to me. It's a small implementation detail and if you feel strongly about it I don't mind just shrugging and seeing what happens.
+1 in general to pushing back on my stylistic suggestions 😄 I like this!
requiresTypeChecking: true, | ||
}, | ||
messages: { | ||
inArray: |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[Non-Actionable] I realized a situation where this option would be helpful! [playground link]
declare function fetch(url: string): Promise<object>;
function example() {
return Promise.all([
fetch("/"),
"in the middle",
fetch("/")
]);
}
If I was the developer here using Promise.all
directly to avoid having to make & manage a [object, string, object]
tuple, I'd be annoyed at the rule asking me to explicitly Promise.resolve("in the middle")
. There's no benefit in this situation beyond consistency - at the cost of simplicity.
But, I get what you're saying that this is really the main point of the rule. I think it's fine for now to leave as-is. Since it's only enabled in the strict config, it should just show up for users who have opted into the more strict opinions.
node: | ||
| TSESTree.MemberExpressionComputedName | ||
| TSESTree.MemberExpressionNonComputedName, | ||
predicate: (name: string) => boolean, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[Refactor] Similar to the isBuiltinSymbolLike
comment, this is only ever used with a Set<string>
. I think expanding the use case to predicates is just a bit more complexity than we need.
predicate: (name: string) => boolean, | |
memberNames: Set<string> |
For some reason I can't respond to some these individually, at least as far as I can tell, only emote to them. Breaking out here instead Sure, will add default blocking of unknown with an option to unblock. I agree that wouldn't use unknown without a type guard, but the use-case is logically consistent, as it is a type refinement, though one too subtle to be picked up by current typescript (i.e., However, I do think this is a bit inconsistent since await-thenable allows unknown (first time trying to use the playground, might have made a mistake, but this is definitely clear in the source code) Responded in #8094 (comment) - if my argument for predicates isn't convincing will switch it over with an unambigious blocking remark |
Also adds tests for rejected promises, calling via template literals, and `never` argument values
Codecov ReportAttention: Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #8094 +/- ##
==========================================
- Coverage 87.72% 87.68% -0.04%
==========================================
Files 397 398 +1
Lines 13971 14057 +86
Branches 4065 4089 +24
==========================================
+ Hits 12256 12326 +70
- Misses 1403 1418 +15
- Partials 312 313 +1
Flags with carried forward coverage won't be shown. Click here to find out more.
|
Updated to latest main branch and ensured this doesn't change the library functions in a backwards incompatible way (cc: @auvred #8094 (comment)). Initially I did it via overloads so I could mark the string one deprecated, but that fails linting with As #8301 appears to be resolved with wontfix I'll leave unknown behavior as is. cc: @JoshuaKGoldberg for updating labels, deciding if predicate->set change is blocking |
Super, thanks! Putting back on the queue to review :) |
if (!checker.isArrayType(type) && !checker.isTupleType(type)) { | ||
const indexType = checker.getIndexTypeOfType(type, ts.IndexKind.Number); | ||
if (indexType === undefined) { | ||
return false; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[Testing] Missing unit test coverage for this and a few other lines. If it's a case where we know the value is definitely not nullish (e.g. an array type having a numeric index) then a !
is reasonable.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Just a unit test coverage request that's blocking from me right now 🙂 great stuff!
Edit: oh, and the request for an option around mixed arrays.
(I'll re-review more deeply once the comments are all resolved)
👋 Hey @Tjstretchalot! Just checking in, is this still something you have time for? No worries if not - I just don't want to leave it hanging. |
Hey @JoshuaKGoldberg - apologies for this; work got very busy rather quickly and had to reduce some other stuff to make space. It'll hopefully get better in April, but if someone else wants to pick it up or you want to close until then let me know |
Thanks, good to know. I'll remove |
Closing as it's been a bit - thanks for getting started on it! If you do end up having time to continue @Tjstretchalot feel free to reopen this PR if it's unlocked, or send a new one otherwise. In the meantime, if anybody else wants to work on the linked issue, go right ahead. Just be sure to add a co-author attribution if you use any code from this PR. Cheers! |
PR Checklist
Promise.all
,Promise.allSettled
,Promise.race
) #1804Overview
Adds a new rule,
thenable-in-promise-aggregators
, which verifies that array arguments ofPromise.all
,Promise.allSettled
, andPromise.race
are all Thenable. The purpose behind this PR is discussed in the issue; for me, I often wrap promises to make them cancelable, but it still makes sense to name the object e.g.fooPromise
, and when mixed with regular promises (or even without), I will typePromise.race([stdPromise, fooPromise])
, which will look correct and appear to behave correctly for a long time, before getting non-responsive if one of the promises is unexpectedly slow.