Movatterモバイル変換


[0]ホーム

URL:


Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Sign up
Appearance settings

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

Closed
Closed
Show file tree
Hide file tree
Changes fromall commits
Commits
Show all changes
26 commits
Select commitHold shift + click to select a range
5302e72
feat(eslint-plugin): [thenable-in-promise-aggregators] resolve #1804
TjstretchalotDec 18, 2023
3c79e83
Working tests/spelling/lint
TjstretchalotDec 18, 2023
ac00bbf
Remove accidental linebreak mid-sentence
TjstretchalotDec 20, 2023
453b5c3
Remove from recommended
TjstretchalotDec 20, 2023
c2a10e2
Support Promise aliases
TjstretchalotDec 20, 2023
e3e2a4a
Support tuple args
TjstretchalotDec 20, 2023
056dfe1
Handle empty arrays / omitted elements in array
TjstretchalotDec 20, 2023
03c4fc9
Assert impossible missing type args
TjstretchalotDec 20, 2023
00909d3
Test array of any/unknown in valid
TjstretchalotDec 20, 2023
da403ff
Remove unnecessary suggestions line
TjstretchalotDec 20, 2023
fada233
yarn lint --fix
TjstretchalotDec 20, 2023
d918f42
Remove unnecessary aliasing on messageId
TjstretchalotDec 20, 2023
9859e6d
Split dual-purpose test cases
TjstretchalotDec 20, 2023
2cfbf76
Tests for [] syntax instead of dot syntax
TjstretchalotDec 20, 2023
3e0e442
Explicit tests for union types
TjstretchalotDec 20, 2023
20e1545
Use shorter valid testcase syntax
TjstretchalotDec 20, 2023
7a3cc01
Fix including = in declare const
TjstretchalotDec 20, 2023
304356e
Use latest helpers from #8011
TjstretchalotDec 24, 2023
d58111d
Carefully handle complicated promise-likes, remove nonArrayArg
TjstretchalotDec 24, 2023
609db97
Rename callerType calleeType for consistency
TjstretchalotDec 24, 2023
698e579
Add any to list of promise aggregators
TjstretchalotDec 24, 2023
4028718
Update to pull in type util functions
TjstretchalotJan 10, 2024
82447b7
emptyArrayElement, refactoring, typed member names
TjstretchalotJan 12, 2024
c5aef50
Minor documentation cleanup
TjstretchalotJan 24, 2024
2bbe8fa
Avoid backward incompat lib signature change
TjstretchalotFeb 2, 2024
ea65212
Satisfy linter
TjstretchalotFeb 2, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
View file
Open in desktop
Original file line numberDiff line numberDiff 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.
1 change: 1 addition & 0 deletionspackages/eslint-plugin/src/configs/all.ts
View file
Open in desktop
Original file line numberDiff line numberDiff line change
Expand Up@@ -146,6 +146,7 @@ export = {
'@typescript-eslint/sort-type-constituents': 'error',
'@typescript-eslint/strict-boolean-expressions': 'error',
'@typescript-eslint/switch-exhaustiveness-check': 'error',
'@typescript-eslint/thenable-in-promise-aggregators': 'error',
'@typescript-eslint/triple-slash-reference': 'error',
'@typescript-eslint/typedef': 'error',
'@typescript-eslint/unbound-method': 'error',
Expand Down
View file
Open in desktop
Original file line numberDiff line numberDiff line change
Expand Up@@ -57,6 +57,7 @@ export = {
'@typescript-eslint/return-await': 'off',
'@typescript-eslint/strict-boolean-expressions': 'off',
'@typescript-eslint/switch-exhaustiveness-check': 'off',
'@typescript-eslint/thenable-in-promise-aggregators': 'off',
'@typescript-eslint/unbound-method': 'off',
},
};
View file
Open in desktop
Original file line numberDiff line numberDiff line change
Expand Up@@ -71,6 +71,7 @@ export = {
'@typescript-eslint/require-await': 'error',
'@typescript-eslint/restrict-plus-operands': 'error',
'@typescript-eslint/restrict-template-expressions': 'error',
'@typescript-eslint/thenable-in-promise-aggregators': 'error',
'@typescript-eslint/triple-slash-reference': 'error',
'@typescript-eslint/unbound-method': 'error',
'@typescript-eslint/unified-signatures': 'error',
Expand Down
2 changes: 2 additions & 0 deletionspackages/eslint-plugin/src/rules/index.ts
View file
Open in desktop
Original file line numberDiff line numberDiff line change
Expand Up@@ -132,6 +132,7 @@ import spaceBeforeFunctionParen from './space-before-function-paren';
import spaceInfixOps from './space-infix-ops';
import strictBooleanExpressions from './strict-boolean-expressions';
import switchExhaustivenessCheck from './switch-exhaustiveness-check';
import thenableInPromiseAggregators from './thenable-in-promise-aggregators';
import tripleSlashReference from './triple-slash-reference';
import typeAnnotationSpacing from './type-annotation-spacing';
import typedef from './typedef';
Expand DownExpand Up@@ -273,6 +274,7 @@ export default {
'space-infix-ops': spaceInfixOps,
'strict-boolean-expressions': strictBooleanExpressions,
'switch-exhaustiveness-check': switchExhaustivenessCheck,
'thenable-in-promise-aggregators': thenableInPromiseAggregators,
'triple-slash-reference': tripleSlashReference,
'type-annotation-spacing': typeAnnotationSpacing,
typedef: typedef,
Expand Down
View file
Open in desktop
Original file line numberDiff line numberDiff 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',

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.

Tjstretchalot reacted with thumbs up emoji
recommended: 'strict',
requiresTypeChecking: true,
},
messages: {
inArray:

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? LikePromise.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.

Copy link
Author

@TjstretchalotTjstretchalotJan 12, 2024
edited
Loading

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 inPromise.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 asPromise.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:

declareconstaPromise:Promise<number>;declareconstbPromise:Promise<number;declareconstcNumber:number;
// allPromise.all([aPromise,bPromise,cNumber])// equivalent toPromise.all([aPromise,bPromise,Promise.resolve(cNumber)])// which resolves in the same amount of time asPromise.all([aPromise,bPromise])// previously working, now causes lint errorconst[a,b,c]=awaitPromise.all([aPromise,bPromise,cNumber]);// suggested changeconst[a,b]=awaitPromise.all([aPromise,bPromise]);constc=cNumber;
// allSettledPromise.allSettled([aPromise,bPromise,cNumber]);// equivalent toPromise.allSettled([aPromise,bPromise,Promise.resolve(cNumber)]);// resolves in the same time asPromise.allSettled([aPromise,bPromise]);// previously working, now causes lint errorconst[aPromise2,bPromise2,cPromise]=awaitPromise.allSettled([aPromise,bPromise,cNumber]);// suggested changeconst[aPromise2,bPromise2]=awaitPromise.allSettled([aPromise,bPromise]);constcPromise=Promise.resolve(cNumber);
// raceawaitPromise.race([aPromise,bPromise,cNumber]);// equivalent toawaitPromise.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 cconstaOrBOrC=awaitPromise.race([aPromise,bPromise,cNumber]);// suggested change if you really did want the above behavior (most of the time you do NOT)constaOrBOrC=awaitPromise.race([aPromise,bPromise,Promise.resolve(cNumber)]);// suggested change if you actually intended to wait for something to happenconstaOrB=awaitPromise.race([aPromise,bPromise])// note that most of the time this comes up, this is whats actually happening:declareconstaPromise:Promise<number>;declareconstbPromise:Promise<number>;declareconstcWrappedPromise:{promise:Promise<number>};// lint error that catches a real subtle bugconstaOrBOrC=awaitPromise.race([aPromise,bPromise,cWrappedPromise]);// correct codeconstaOrBOrC=awaitPromise.race([aPromise,bPromise,cWrappedPromise.promise]);
// any is the same as race for the above purposes, the difference is how rejected values are handled

Copy link
Member

@JoshuaKGoldbergJoshuaKGoldbergJan 24, 2024
edited
Loading

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]

declarefunctionfetch(url:string):Promise<object>;functionexample(){returnPromise.all([fetch("/"),"in the middle",fetch("/")]);}

If I was the developer here usingPromise.all directly to avoid having to make & manage a[object, string, object] tuple, I'd be annoyed at the rule asking me to explicitlyPromise.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.

Tjstretchalot reacted with thumbs up emoji
Copy link
Member

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.

JoshuaKGoldberg reacted with thumbs up emoji

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 🙂

'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>(
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)) {
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;

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 weknow the value is definitely not nullish (e.g. an array type having a numeric index) then a! is reasonable.

}

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,

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others.Learn more.

[Refactor] Similar to theisBuiltinSymbolLike comment, this is only ever used with aSet<string>. I think expanding the use case to predicates is just a bit more complexity than we need.

Suggested change
predicate:(name:string)=>boolean,
memberNames:Set<string>

): 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;
}

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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others.Learn more.

this logic (ifisTypeAnyType(sometype) || isTypeUnknownType(sometype) ortsutils.isThenableType(checker, originalNode, sometype)) is used three times in this rule. Maybe we should move it to the function to avoid duplicated code?

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:

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 thanchecking 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.


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,
});
},
};
},
});
Loading

[8]ページ先頭

©2009-2025 Movatter.jp