-
-
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
feat(eslint-plugin): [thenable-in-promise-aggregators] disallow non-Thenables to promise aggregators #8094
Changes from all 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
Jump to
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. |
Tjstretchalot marked this conversation as resolved.
Show resolved
Hide resolved
|
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. 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 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 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 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 commentThe 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 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 commentThe 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 commentThe reason will be displayed to describe this comment to others. Learn more. In that case, +1ing Josh's request 🙂 |
||||||
'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 resolved
Hide resolved
|
||||||
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 resolved
Hide resolved
|
||||||
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 we know 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 resolved
Hide resolved
|
||||||
|
||||||
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: 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 |
||||||
|
||||||
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.