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
5302e72
3c79e83
ac00bbf
453b5c3
c2a10e2
e3e2a4a
056dfe1
03c4fc9
00909d3
da403ff
fada233
d918f42
9859e6d
2cfbf76
3e0e442
20e1545
7a3cc01
304356e
d58111d
609db97
698e579
4028718
82447b7
c5aef50
2bbe8fa
ea65212
File 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', | ||||||
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: | ||||||
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; | ||||||
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, | ||||||
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 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 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.