Skip to content

feat(eslint-plugin): [await-thenable] check for-await loop iteree #10008

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

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
84 changes: 82 additions & 2 deletions packages/eslint-plugin/docs/rules/await-thenable.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,9 @@ import TabItem from '@theme/TabItem';
> See **https://typescript-eslint.io/rules/await-thenable** 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.
The [`await` keyword](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/await) is generally used to retrieve the result of calling a Thenable's `then` method.

If the `await` keyword is used on a value that is not a Thenable, the value is directly resolved immediately.
If the `await` keyword is used on a value that is not a Thenable, the value is directly resolved, but will still pause execution until the next microtask.
While doing so is valid JavaScript, it is often a programmer error, such as forgetting to add parenthesis to call a function that returns a Promise.

## Examples
Expand Down Expand Up @@ -40,6 +40,86 @@ await createValue();
</TabItem>
</Tabs>

## Async Iteration (`for await...of` Loops)

This rule also inspects [`for await...of` statements](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/for-await...of), and reports if the value being iterated over is not async-iterable.

:::info[Why does the rule report on `for await...of` loops used on an array of Promises?]

While `for await...of` can be used with synchronous iterables, and it will await each promise produced by the iterable, it is inadvisable to do so.
There are some tiny nuances that you may want to consider.

The biggest difference between using `for await...of` and using `for...of` (plus awaiting each result yourself) is error handling.
When an error occurs within the loop body, `for await...of` does _not_ close the original sync iterable, while `for...of` does.
For detailed examples of this, see the [MDN documentation on using `for await...of` with sync-iterables](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/for-await...of#iterating_over_sync_iterables_and_generators).

Also consider whether you need sequential awaiting at all. Using `for await...of` may obscure potential opportunities for concurrent processing, such as those reported by [`no-await-in-loop`](https://eslint.org/docs/latest/rules/no-await-in-loop). Consider instead using one of the [promise concurrency methods](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise#promise_concurrency) for better performance.

:::

### Examples

<Tabs>
<TabItem value="❌ Incorrect">

```ts
async function syncIterable() {
const arrayOfValues = [1, 2, 3];
for await (const value of arrayOfValues) {
console.log(value);
}
}

async function syncIterableOfPromises() {
const arrayOfPromises = [
Promise.resolve(1),
Promise.resolve(2),
Promise.resolve(3),
];
for await (const promisedValue of arrayOfPromises) {
console.log(promisedValue);
}
}
```

</TabItem>
<TabItem value="✅ Correct">

```ts
async function syncIterable() {
const arrayOfValues = [1, 2, 3];
for (const value of arrayOfValues) {
console.log(value);
}
}

async function syncIterableOfPromises() {
const arrayOfPromises = [
Promise.resolve(1),
Promise.resolve(2),
Promise.resolve(3),
];
for (const promisedValue of await Promise.all(arrayOfPromises)) {
console.log(promisedValue);
}
}

async function validUseOfForAwaitOnAsyncIterable() {
async function* yieldThingsAsynchronously() {
yield 1;
await new Promise(resolve => setTimeout(resolve, 1000));
yield 2;
}

for await (const promisedValue of yieldThingsAsynchronously()) {
console.log(promisedValue);
}
}
```

</TabItem>
</Tabs>

## When Not To Use It

If you want to allow code to `await` non-Promise values.
Expand Down
48 changes: 46 additions & 2 deletions packages/eslint-plugin/src/rules/await-thenable.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { TSESLint } from '@typescript-eslint/utils';
import type { TSESLint, TSESTree } from '@typescript-eslint/utils';
import * as tsutils from 'ts-api-utils';

import {
Expand All @@ -10,8 +10,15 @@ import {
nullThrows,
NullThrowsReasons,
} from '../util';
import { getForStatementHeadLoc } from '../util/getForStatementHeadLoc';

export default createRule({
type MessageId =
| 'await'
| 'forAwaitOfNonThenable'
| 'removeAwait'
| 'convertToOrdinaryFor';

export default createRule<[], MessageId>({
name: 'await-thenable',
meta: {
docs: {
Expand All @@ -22,7 +29,10 @@ export default createRule({
hasSuggestions: true,
messages: {
await: 'Unexpected `await` of a non-Promise (non-"Thenable") value.',
forAwaitOfNonThenable:
'Unexpected `for await...of` of a value that is not async iterable.',
removeAwait: 'Remove unnecessary `await`.',
convertToOrdinaryFor: 'Convert to an ordinary `for...of` loop.',
},
schema: [],
type: 'problem',
Expand Down Expand Up @@ -62,6 +72,40 @@ export default createRule({
});
}
},

'ForOfStatement[await=true]'(node: TSESTree.ForOfStatement): void {
const type = services.getTypeAtLocation(node.right);
if (isTypeAnyType(type)) {
return;
}

const asyncIteratorSymbol = tsutils.getWellKnownSymbolPropertyOfType(
type,
'asyncIterator',
checker,
);

if (asyncIteratorSymbol == null) {
context.report({
loc: getForStatementHeadLoc(context.sourceCode, node),
messageId: 'forAwaitOfNonThenable',
suggest: [
// Note that this suggestion causes broken code for sync iterables
Copy link
Member

Choose a reason for hiding this comment

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

Can we only offer this suggestion if the iterator result is not thenable?

Copy link
Member Author

Choose a reason for hiding this comment

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

I don't think so because we'd need getIterationTypesOfIterable() or getIteratedTypeOrElementType or similar from TS none of which is exposed on the checker.

If we check arrays and tuples specifically, i.e. known iterables, this is silly since

for (const promisedValue of await Promise.all(promises)) {
}

is a much better suggestion in those cases (since awaiting in the loop is generally problematic for exception handling given a non-lazy array of promises)

Whereas with lazy sync-iterables of promises, which I don't know if we can detect, it may be better to do

for (const promise of yieldPromises()) {
    const promisedValue = await promise;
}

I guess the only thing that's unambiguous here is that if you have an array or tuple of non-thenables, this suggestion is good. Maybe that's the only situation where we give a suggestion?

// of promises, since the loop variable is not awaited.
{
messageId: 'convertToOrdinaryFor',
fix(fixer): TSESLint.RuleFix {
const awaitToken = nullThrows(
context.sourceCode.getFirstToken(node, isAwaitKeyword),
NullThrowsReasons.MissingToken('await', 'for await loop'),
);
return fixer.remove(awaitToken);
},
},
],
});
}
},
};
},
});

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

90 changes: 90 additions & 0 deletions packages/eslint-plugin/tests/rules/await-thenable.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,28 @@ const doSomething = async (
await callback?.();
};
`,
{
code: `
async function* asyncYieldNumbers() {
yield 1;
yield 2;
yield 3;
}
for await (const value of asyncYieldNumbers()) {
console.log(value);
}
`,
},
{
code: `
declare const anee: any;
async function forAwait() {
for await (const value of anee) {
console.log(value);
}
}
`,
},
],

invalid: [
Expand Down Expand Up @@ -378,5 +400,73 @@ declare const obj: { a: { b: { c?: () => void } } } | undefined;
},
],
},
{
code: `
function* yieldNumbers() {
yield 1;
yield 2;
yield 3;
}
for await (const value of yieldNumbers()) {
console.log(value);
}
`,
errors: [
{
messageId: 'forAwaitOfNonThenable',
line: 7,
endLine: 7,
column: 1,
endColumn: 42,
suggestions: [
{
messageId: 'convertToOrdinaryFor',
output: `
function* yieldNumbers() {
yield 1;
yield 2;
yield 3;
}
for (const value of yieldNumbers()) {
console.log(value);
}
`,
},
],
},
],
},
{
code: `
function* yieldNumberPromises() {
yield Promise.resolve(1);
yield Promise.resolve(2);
yield Promise.resolve(3);
}
for await (const value of yieldNumberPromises()) {
console.log(value);
}
`,
errors: [
{
messageId: 'forAwaitOfNonThenable',
suggestions: [
{
messageId: 'convertToOrdinaryFor',
output: `
function* yieldNumberPromises() {
yield Promise.resolve(1);
yield Promise.resolve(2);
yield Promise.resolve(3);
}
for (const value of yieldNumberPromises()) {
console.log(value);
}
`,
},
],
},
],
},
],
});
Loading
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy