Skip to content

Commit 5b2ebcd

Browse files
feat(eslint-plugin): [await-thenable] report unnecessary await using statements (#10209)
* [await-thenable] report unnecessary await using statements * continue, not return * await using without init * disable lint error * exempt code ticks from upper-case heading test Co-authored-by: Josh Goldberg ✨ <git@joshuakgoldberg.com> --------- Co-authored-by: Josh Goldberg ✨ <git@joshuakgoldberg.com>
1 parent 22f7f25 commit 5b2ebcd

File tree

8 files changed

+375
-45
lines changed

8 files changed

+375
-45
lines changed

packages/eslint-plugin/docs/rules/await-thenable.mdx

+57-1
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ This rule also inspects [`for await...of` statements](https://developer.mozilla.
4949
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.
5050
There are some tiny nuances that you may want to consider.
5151

52-
The biggest difference between using `for await...of` and using `for...of` (plus awaiting each result yourself) is error handling.
52+
The biggest difference between using `for await...of` and using `for...of` (apart from awaiting each result yourself) is error handling.
5353
When an error occurs within the loop body, `for await...of` does _not_ close the original sync iterable, while `for...of` does.
5454
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).
5555

@@ -120,6 +120,62 @@ async function validUseOfForAwaitOnAsyncIterable() {
120120
</TabItem>
121121
</Tabs>
122122

123+
## Explicit Resource Management (`await using` Statements)
124+
125+
This rule also inspects [`await using` statements](https://www.typescriptlang.org/docs/handbook/release-notes/typescript-5-2.html#using-declarations-and-explicit-resource-management).
126+
If the disposable being used is not async-disposable, an `await using` statement is unnecessary.
127+
128+
### Examples
129+
130+
<Tabs>
131+
<TabItem value="❌ Incorrect">
132+
133+
```ts
134+
function makeSyncDisposable(): Disposable {
135+
return {
136+
[Symbol.dispose](): void {
137+
// Dispose of the resource
138+
},
139+
};
140+
}
141+
142+
async function shouldNotAwait() {
143+
await using resource = makeSyncDisposable();
144+
}
145+
```
146+
147+
</TabItem>
148+
<TabItem value="✅ Correct">
149+
150+
```ts
151+
function makeSyncDisposable(): Disposable {
152+
return {
153+
[Symbol.dispose](): void {
154+
// Dispose of the resource
155+
},
156+
};
157+
}
158+
159+
async function shouldNotAwait() {
160+
using resource = makeSyncDisposable();
161+
}
162+
163+
function makeAsyncDisposable(): AsyncDisposable {
164+
return {
165+
async [Symbol.asyncDispose](): Promise<void> {
166+
// Dispose of the resource asynchronously
167+
},
168+
};
169+
}
170+
171+
async function shouldAwait() {
172+
await using resource = makeAsyncDisposable();
173+
}
174+
```
175+
176+
</TabItem>
177+
</Tabs>
178+
123179
## When Not To Use It
124180

125181
If you want to allow code to `await` non-Promise values.

packages/eslint-plugin/src/rules/await-thenable.ts

+69-14
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import * as tsutils from 'ts-api-utils';
44

55
import {
66
createRule,
7+
getFixOrSuggest,
78
getParserServices,
89
isAwaitKeyword,
910
isTypeAnyType,
@@ -15,8 +16,9 @@ import { getForStatementHeadLoc } from '../util/getForStatementHeadLoc';
1516

1617
type MessageId =
1718
| 'await'
19+
| 'awaitUsingOfNonAsyncDisposable'
1820
| 'convertToOrdinaryFor'
19-
| 'forAwaitOfNonThenable'
21+
| 'forAwaitOfNonAsyncIterable'
2022
| 'removeAwait';
2123

2224
export default createRule<[], MessageId>({
@@ -31,8 +33,10 @@ export default createRule<[], MessageId>({
3133
hasSuggestions: true,
3234
messages: {
3335
await: 'Unexpected `await` of a non-Promise (non-"Thenable") value.',
36+
awaitUsingOfNonAsyncDisposable:
37+
'Unexpected `await using` of a value that is not async disposable.',
3438
convertToOrdinaryFor: 'Convert to an ordinary `for...of` loop.',
35-
forAwaitOfNonThenable:
39+
forAwaitOfNonAsyncIterable:
3640
'Unexpected `for await...of` of a value that is not async iterable.',
3741
removeAwait: 'Remove unnecessary `await`.',
3842
},
@@ -80,21 +84,21 @@ export default createRule<[], MessageId>({
8084
return;
8185
}
8286

83-
const asyncIteratorSymbol = tsutils
87+
const hasAsyncIteratorSymbol = tsutils
8488
.unionTypeParts(type)
85-
.map(t =>
86-
tsutils.getWellKnownSymbolPropertyOfType(
87-
t,
88-
'asyncIterator',
89-
checker,
90-
),
91-
)
92-
.find(symbol => symbol != null);
93-
94-
if (asyncIteratorSymbol == null) {
89+
.some(
90+
typePart =>
91+
tsutils.getWellKnownSymbolPropertyOfType(
92+
typePart,
93+
'asyncIterator',
94+
checker,
95+
) != null,
96+
);
97+
98+
if (!hasAsyncIteratorSymbol) {
9599
context.report({
96100
loc: getForStatementHeadLoc(context.sourceCode, node),
97-
messageId: 'forAwaitOfNonThenable',
101+
messageId: 'forAwaitOfNonAsyncIterable',
98102
suggest: [
99103
// Note that this suggestion causes broken code for sync iterables
100104
// of promises, since the loop variable is not awaited.
@@ -112,6 +116,57 @@ export default createRule<[], MessageId>({
112116
});
113117
}
114118
},
119+
120+
'VariableDeclaration[kind="await using"]'(
121+
node: TSESTree.VariableDeclaration,
122+
): void {
123+
for (const declarator of node.declarations) {
124+
const init = declarator.init;
125+
if (init == null) {
126+
continue;
127+
}
128+
const type = services.getTypeAtLocation(init);
129+
if (isTypeAnyType(type)) {
130+
continue;
131+
}
132+
133+
const hasAsyncDisposeSymbol = tsutils
134+
.unionTypeParts(type)
135+
.some(
136+
typePart =>
137+
tsutils.getWellKnownSymbolPropertyOfType(
138+
typePart,
139+
'asyncDispose',
140+
checker,
141+
) != null,
142+
);
143+
144+
if (!hasAsyncDisposeSymbol) {
145+
context.report({
146+
node: init,
147+
messageId: 'awaitUsingOfNonAsyncDisposable',
148+
// let the user figure out what to do if there's
149+
// await using a = b, c = d, e = f;
150+
// it's rare and not worth the complexity to handle.
151+
...getFixOrSuggest({
152+
fixOrSuggest:
153+
node.declarations.length === 1 ? 'suggest' : 'none',
154+
155+
suggestion: {
156+
messageId: 'removeAwait',
157+
fix(fixer): TSESLint.RuleFix {
158+
const awaitToken = nullThrows(
159+
context.sourceCode.getFirstToken(node, isAwaitKeyword),
160+
NullThrowsReasons.MissingToken('await', 'await using'),
161+
);
162+
return fixer.remove(awaitToken);
163+
},
164+
},
165+
}),
166+
});
167+
}
168+
}
169+
},
115170
};
116171
},
117172
});

packages/eslint-plugin/src/rules/prefer-optional-chain-utils/analyzeChain.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -413,11 +413,11 @@ function getReportDescriptor(
413413
},
414414
messageId: 'preferOptionalChain',
415415
...getFixOrSuggest({
416+
fixOrSuggest: useSuggestionFixer ? 'suggest' : 'fix',
416417
suggestion: {
417418
fix,
418419
messageId: 'optionalChainSuggest',
419420
},
420-
useFix: !useSuggestionFixer,
421421
}),
422422
};
423423

packages/eslint-plugin/src/rules/return-await.ts

+19-20
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import * as ts from 'typescript';
66

77
import {
88
createRule,
9+
getFixOrSuggest,
910
getParserServices,
1011
isAwaitExpression,
1112
isAwaitKeyword,
@@ -44,6 +45,7 @@ export default createRule({
4445
requiresTypeChecking: true,
4546
},
4647
fixable: 'code',
48+
// eslint-disable-next-line eslint-plugin/require-meta-has-suggestions -- suggestions are exposed through a helper.
4749
hasSuggestions: true,
4850
messages: {
4951
disallowedPromiseAwait:
@@ -340,14 +342,17 @@ export default createRule({
340342
context.report({
341343
node,
342344
messageId: 'requiredPromiseAwait',
343-
...fixOrSuggest(useAutoFix, {
344-
messageId: 'requiredPromiseAwaitSuggestion',
345-
fix: fixer =>
346-
insertAwait(
347-
fixer,
348-
node,
349-
isHigherPrecedenceThanAwait(expression),
350-
),
345+
...getFixOrSuggest({
346+
fixOrSuggest: useAutoFix ? 'fix' : 'suggest',
347+
suggestion: {
348+
messageId: 'requiredPromiseAwaitSuggestion',
349+
fix: fixer =>
350+
insertAwait(
351+
fixer,
352+
node,
353+
isHigherPrecedenceThanAwait(expression),
354+
),
355+
},
351356
}),
352357
});
353358
}
@@ -359,9 +364,12 @@ export default createRule({
359364
context.report({
360365
node,
361366
messageId: 'disallowedPromiseAwait',
362-
...fixOrSuggest(useAutoFix, {
363-
messageId: 'disallowedPromiseAwaitSuggestion',
364-
fix: fixer => removeAwait(fixer, node),
367+
...getFixOrSuggest({
368+
fixOrSuggest: useAutoFix ? 'fix' : 'suggest',
369+
suggestion: {
370+
messageId: 'disallowedPromiseAwaitSuggestion',
371+
fix: fixer => removeAwait(fixer, node),
372+
},
365373
}),
366374
});
367375
}
@@ -446,12 +454,3 @@ function getConfiguration(option: Option): RuleConfiguration {
446454
};
447455
}
448456
}
449-
450-
function fixOrSuggest<MessageId extends string>(
451-
useFix: boolean,
452-
suggestion: TSESLint.SuggestionReportDescriptor<MessageId>,
453-
):
454-
| { fix: TSESLint.ReportFixFunction }
455-
| { suggest: TSESLint.SuggestionReportDescriptor<MessageId>[] } {
456-
return useFix ? { fix: suggestion.fix } : { suggest: [suggestion] };
457-
}
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,21 @@
11
import type { TSESLint } from '@typescript-eslint/utils';
22

33
export function getFixOrSuggest<MessageId extends string>({
4+
fixOrSuggest,
45
suggestion,
5-
useFix,
66
}: {
7+
fixOrSuggest: 'fix' | 'none' | 'suggest';
78
suggestion: TSESLint.SuggestionReportDescriptor<MessageId>;
8-
useFix: boolean;
99
}):
1010
| { fix: TSESLint.ReportFixFunction }
11-
| { suggest: TSESLint.SuggestionReportDescriptor<MessageId>[] } {
12-
return useFix ? { fix: suggestion.fix } : { suggest: [suggestion] };
11+
| { suggest: TSESLint.SuggestionReportDescriptor<MessageId>[] }
12+
| undefined {
13+
switch (fixOrSuggest) {
14+
case 'fix':
15+
return { fix: suggestion.fix };
16+
case 'none':
17+
return undefined;
18+
case 'suggest':
19+
return { suggest: [suggestion] };
20+
}
1321
}

packages/eslint-plugin/tests/docs-eslint-output-snapshots/await-thenable.shot

+47
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/eslint-plugin/tests/docs.test.ts

+4-3
Original file line numberDiff line numberDiff line change
@@ -252,9 +252,10 @@ describe('Validating rule docs', () => {
252252
// Get all H2 headings objects as the other levels are variable by design.
253253
const headings = tokens.filter(tokenIsH2);
254254

255-
headings.forEach(heading =>
256-
expect(heading.text).toBe(titleCase(heading.text)),
257-
);
255+
headings.forEach(heading => {
256+
const nonCodeText = heading.text.replace(/`[^`]*`/g, '');
257+
expect(nonCodeText).toBe(titleCase(nonCodeText));
258+
});
258259
});
259260

260261
const headings = tokens.filter(tokenIsHeading);

0 commit comments

Comments
 (0)