Uh oh!
There was an error while loading.Please reload this page.
- Notifications
You must be signed in to change notification settings - Fork2.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 ourterms of service andprivacy statement. We’ll occasionally send you account related emails.
Already on GitHub?Sign in to your account
Uh oh!
There was an error while loading.Please reload this page.
feat(eslint-plugin): [thenable-in-promise-aggregators] disallow non-Thenables to promise aggregators#8094
Changes fromall commits
5302e723c79e83ac00bbf453b5c3c2a10e2e3e2a4a056dfe103c4fc900909d3da403fffada233d918f429859e6d2cfbf763e0e44220e15457a3cc01304356ed58111d609db97698e579402871882447b7c5aef502bbe8faea65212File filter
Filter by extension
Conversations
Uh oh!
There was an error while loading.Please reload this page.
Jump to
Uh oh!
There was an error while loading.Please reload this page.
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,48 @@ | ||
| --- | ||
| description: 'Disallow passing non-Thenable values to promise aggregators.' | ||
| --- | ||
| > 🛑 This file is source code, not the primary documentation location! 🛑 | ||
| > | ||
| > See **https://typescript-eslint.io/rules/thenable-in-promise-aggregators** for documentation. | ||
| A "Thenable" value is an object which has a `then` method, such as a Promise. | ||
| The `await` keyword is generally used to retrieve the result of calling a Thenable's `then` method. | ||
| When multiple Thenables are running at the same time, it is sometimes desirable to wait until any one of them resolves (`Promise.race`), all of them resolve or any of them reject (`Promise.all`), or all of them resolve or reject (`Promise.allSettled`). | ||
| Each of these functions accept an iterable of promises as input and return a single Promise. | ||
| If a non-Thenable is passed, it is ignored. | ||
| While doing so is valid JavaScript, it is often a programmer error, such as forgetting to unwrap a wrapped promise, or using the `await` keyword on the individual promises, which defeats the purpose of using one of these Promise aggregators. | ||
| ## Examples | ||
| <!--tabs--> | ||
| ### ❌ Incorrect | ||
| ```ts | ||
| await Promise.race(['value1', 'value2']); | ||
| await Promise.race([ | ||
| await new Promise(resolve => setTimeout(resolve, 3000)), | ||
| await new Promise(resolve => setTimeout(resolve, 6000)), | ||
| ]); | ||
| ``` | ||
| ### ✅ Correct | ||
| ```ts | ||
| await Promise.race([Promise.resolve('value1'), Promise.resolve('value2')]); | ||
| await Promise.race([ | ||
| new Promise(resolve => setTimeout(resolve, 3000)), | ||
| new Promise(resolve => setTimeout(resolve, 6000)), | ||
| ]); | ||
| ``` | ||
| ## When Not To Use It | ||
| If you want to allow code to use `Promise.race`, `Promise.all`, or `Promise.allSettled` on arrays of non-Thenable values. | ||
| This is generally not preferred but can sometimes be useful for visual consistency. | ||
| You might consider using [ESLint disable comments](https://eslint.org/docs/latest/use/configure/rules#using-configuration-comments-1) for those specific situations instead of completely disabling this rule. |
JoshuaKGoldberg marked this conversation as resolved. Show resolvedHide resolvedUh oh!There was an error while loading.Please reload this page.
Tjstretchalot marked this conversation as resolved. Show resolvedHide resolvedUh oh!There was an error while loading.Please reload this page. |
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -0,0 +1,215 @@ | ||||||
| import { | ||||||
| isBuiltinSymbolLike, | ||||||
| isTypeAnyType, | ||||||
| isTypeUnknownType, | ||||||
| } from '@typescript-eslint/type-utils'; | ||||||
| import type { TSESTree } from '@typescript-eslint/utils'; | ||||||
| import { AST_NODE_TYPES } from '@typescript-eslint/utils'; | ||||||
| import * as tsutils from 'ts-api-utils'; | ||||||
| import * as ts from 'typescript'; | ||||||
| import { createRule, getParserServices } from '../util'; | ||||||
| const aggregateFunctionNames = new Set(['all', 'race', 'allSettled', 'any']); | ||||||
| export default createRule({ | ||||||
| name: 'thenable-in-promise-aggregators', | ||||||
| meta: { | ||||||
| docs: { | ||||||
| description: | ||||||
| 'Disallow passing non-Thenable values to promise aggregators', | ||||||
Member There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. | ||||||
| recommended: 'strict', | ||||||
| requiresTypeChecking: true, | ||||||
| }, | ||||||
| messages: { | ||||||
| inArray: | ||||||
Member There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 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. Author
| ||||||
| 'Unexpected non-Thenable value in array passed to promise aggregator.', | ||||||
| arrayArg: | ||||||
| 'Unexpected array of non-Thenable values passed to promise aggregator.', | ||||||
| emptyArrayElement: | ||||||
| 'Unexpected empty element in array passed to promise aggregator (do you have an extra comma?).', | ||||||
| }, | ||||||
| schema: [], | ||||||
| type: 'problem', | ||||||
| }, | ||||||
| defaultOptions: [], | ||||||
| create(context) { | ||||||
| const services = getParserServices(context); | ||||||
| const checker = services.program.getTypeChecker(); | ||||||
| function skipChainExpression<T extends TSESTree.Node>( | ||||||
Tjstretchalot marked this conversation as resolved. Show resolvedHide resolvedUh oh!There was an error while loading.Please reload this page. | ||||||
| node: T, | ||||||
| ): T | TSESTree.ChainElement { | ||||||
| return node.type === AST_NODE_TYPES.ChainExpression | ||||||
| ? node.expression | ||||||
| : node; | ||||||
| } | ||||||
| function isPartiallyLikeType( | ||||||
| type: ts.Type, | ||||||
| predicate: (type: ts.Type) => boolean, | ||||||
| ): boolean { | ||||||
| if (isTypeAnyType(type) || isTypeUnknownType(type)) { | ||||||
Tjstretchalot marked this conversation as resolved. Show resolvedHide resolvedUh oh!There was an error while loading.Please reload this page. | ||||||
| return true; | ||||||
| } | ||||||
| if (type.isIntersection() || type.isUnion()) { | ||||||
| return type.types.some(t => isPartiallyLikeType(t, predicate)); | ||||||
| } | ||||||
| return predicate(type); | ||||||
| } | ||||||
| function isIndexableWithSomeElementsLike( | ||||||
| type: ts.Type, | ||||||
| predicate: (type: ts.Type) => boolean, | ||||||
| ): boolean { | ||||||
| if (isTypeAnyType(type) || isTypeUnknownType(type)) { | ||||||
| return true; | ||||||
| } | ||||||
| if (type.isIntersection() || type.isUnion()) { | ||||||
| return type.types.some(t => | ||||||
| isIndexableWithSomeElementsLike(t, predicate), | ||||||
| ); | ||||||
| } | ||||||
| if (!checker.isArrayType(type) && !checker.isTupleType(type)) { | ||||||
| const indexType = checker.getIndexTypeOfType(type, ts.IndexKind.Number); | ||||||
| if (indexType === undefined) { | ||||||
| return false; | ||||||
Member There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 weknow the value is definitely not nullish (e.g. an array type having a numeric index) then a | ||||||
| } | ||||||
| return isPartiallyLikeType(indexType, predicate); | ||||||
| } | ||||||
| const typeArgs = type.typeArguments; | ||||||
| if (typeArgs === undefined) { | ||||||
| throw new Error( | ||||||
| 'Expected to find type arguments for an array or tuple.', | ||||||
| ); | ||||||
| } | ||||||
| return typeArgs.some(t => isPartiallyLikeType(t, predicate)); | ||||||
| } | ||||||
| function isStringLiteralMatching( | ||||||
| type: ts.Type, | ||||||
| predicate: (value: string) => boolean, | ||||||
| ): boolean { | ||||||
| if (type.isIntersection()) { | ||||||
| return type.types.some(t => isStringLiteralMatching(t, predicate)); | ||||||
| } | ||||||
| if (type.isUnion()) { | ||||||
| return type.types.every(t => isStringLiteralMatching(t, predicate)); | ||||||
| } | ||||||
| if (!type.isStringLiteral()) { | ||||||
| return false; | ||||||
| } | ||||||
| return predicate(type.value); | ||||||
| } | ||||||
| function isMemberName( | ||||||
| node: | ||||||
| | TSESTree.MemberExpressionComputedName | ||||||
| | TSESTree.MemberExpressionNonComputedName, | ||||||
| predicate: (name: string) => boolean, | ||||||
Member There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others.Learn more. [Refactor] Similar to the Suggested change
| ||||||
| ): boolean { | ||||||
| if (!node.computed) { | ||||||
| return predicate(node.property.name); | ||||||
| } | ||||||
| if (node.property.type !== AST_NODE_TYPES.Literal) { | ||||||
| const typeOfProperty = services.getTypeAtLocation(node.property); | ||||||
| return isStringLiteralMatching(typeOfProperty, predicate); | ||||||
| } | ||||||
| const { value } = node.property; | ||||||
| if (typeof value !== 'string') { | ||||||
| return false; | ||||||
| } | ||||||
| return predicate(value); | ||||||
| } | ||||||
| return { | ||||||
| CallExpression(node: TSESTree.CallExpression): void { | ||||||
| const callee = skipChainExpression(node.callee); | ||||||
| if (callee.type !== AST_NODE_TYPES.MemberExpression) { | ||||||
| return; | ||||||
| } | ||||||
| if (!isMemberName(callee, n => aggregateFunctionNames.has(n))) { | ||||||
| return; | ||||||
| } | ||||||
| const args = node.arguments; | ||||||
| if (args.length < 1) { | ||||||
| return; | ||||||
| } | ||||||
| const calleeType = services.getTypeAtLocation(callee.object); | ||||||
| if ( | ||||||
| !isBuiltinSymbolLike( | ||||||
| services.program, | ||||||
| calleeType, | ||||||
| name => name === 'PromiseConstructor' || name === 'Promise', | ||||||
| ) | ||||||
| ) { | ||||||
| return; | ||||||
| } | ||||||
| const arg = args[0]; | ||||||
| if (arg.type === AST_NODE_TYPES.ArrayExpression) { | ||||||
| const { elements } = arg; | ||||||
| if (elements.length === 0) { | ||||||
| return; | ||||||
| } | ||||||
Tjstretchalot marked this conversation as resolved. Show resolvedHide resolvedUh oh!There was an error while loading.Please reload this page. | ||||||
| for (const element of elements) { | ||||||
| if (element == null) { | ||||||
| context.report({ | ||||||
| messageId: 'emptyArrayElement', | ||||||
| node: arg, | ||||||
| }); | ||||||
| return; | ||||||
| } | ||||||
| const elementType = services.getTypeAtLocation(element); | ||||||
| if (isTypeAnyType(elementType) || isTypeUnknownType(elementType)) { | ||||||
| continue; | ||||||
| } | ||||||
| const originalNode = services.esTreeNodeToTSNodeMap.get(element); | ||||||
| if (tsutils.isThenableType(checker, originalNode, elementType)) { | ||||||
| continue; | ||||||
| } | ||||||
Comment on lines +180 to +188 Member There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others.Learn more. this logic (if Author There was a problem hiding this comment. Choose a reason for hiding this commentThe 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: functionisTypeAnyOrUnknownType(type:ts.Type):boolean{returnisTypeAnyType(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 | ||||||
| context.report({ | ||||||
| messageId: 'inArray', | ||||||
| node: element, | ||||||
| }); | ||||||
| } | ||||||
| return; | ||||||
| } | ||||||
| const argType = services.getTypeAtLocation(arg); | ||||||
| const originalNode = services.esTreeNodeToTSNodeMap.get(arg); | ||||||
| if ( | ||||||
| isIndexableWithSomeElementsLike(argType, elementType => { | ||||||
| return tsutils.isThenableType(checker, originalNode, elementType); | ||||||
| }) | ||||||
| ) { | ||||||
| return; | ||||||
| } | ||||||
| context.report({ | ||||||
| messageId: 'arrayArg', | ||||||
| node: arg, | ||||||
| }); | ||||||
| }, | ||||||
| }; | ||||||
| }, | ||||||
| }); | ||||||
Uh oh!
There was an error while loading.Please reload this page.