Skip to content

Commit 6a1c177

Browse files
feat(eslint-plugin): [no-misused-promises] check subtype methods against heritage type methods (typescript-eslint#8765)
* Implement checking of subtypes for no-misused-promises (currently works for subtypes implementing interfaces, and interfaces extending classes, but not classes extending classes) * Finished working logic for no-misused-promises checksVoidReturn.subtypes * Cleanup * Refactor and improve (better more concise approach), and add more test cases * Handle type aliases and add type alias test cases * Added expected {{ baseTypeName }} values to test cases * Added test cases for handling class expressions, and fixed code to handle them * Fix no-misused-promises schema snapshot to account for new subtypes option * Updated copy (base type => heritage type) and added documentation for new subtypes option * Update copy in test cases (baseTypeName => heritageTypeName) * Refactoring * Copy change again: subtypes => heritageTypes * Fix location of heritageTypes examples in no-misused-promises mdx doc * Refactor out getHeritageTypes function * Update no-misused-promises doc to specify that it checks named methods * Add test cases for ignoring unnamed methods (signatures) and add explanatory comments to the code that ignores unnamed methods * Add combination test cases which add coverage for when the member is not found in the heritage type * Rename subtypes => heritageTypes in region comments * Adjust no-misused-promises doc to be more explicit about heritageTypes ignoring signatures / only checking named methods * Remove `#region`s from no-misused-promises test file * Update (use jest to generate) no-misused-promises rules snapshot * refactor: map(...).flat() => flatMap(...) * docs: restructure no-misused-promises doc - checksVoidReturn sub-options each get their own subsection - option descriptions go under Options subsection instead of Examples * chore: remove my unit-test-labeling comments * rename `heritageTypes` suboption to `inheritedMethods` * Style nitty nit - not worrying about strict-boolean-expressions, condensing undefined and 0 check into one --------- Co-authored-by: Josh Goldberg <git@joshuakgoldberg.com>
1 parent fc3ba92 commit 6a1c177

File tree

5 files changed

+1660
-545
lines changed

5 files changed

+1660
-545
lines changed

packages/eslint-plugin/docs/rules/no-misused-promises.mdx

Lines changed: 98 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -37,10 +37,89 @@ If you don't want to check conditionals, you can configure the rule with `"check
3737

3838
Doing so prevents the rule from looking at code like `if (somePromise)`.
3939

40-
Examples of code for this rule with `checksConditionals: true`:
40+
### `checksVoidReturn`
41+
42+
Likewise, if you don't want to check functions that return promises where a void return is
43+
expected, your configuration will look like this:
44+
45+
```json
46+
{
47+
"@typescript-eslint/no-misused-promises": [
48+
"error",
49+
{
50+
"checksVoidReturn": false
51+
}
52+
]
53+
}
54+
```
55+
56+
You can disable selective parts of the `checksVoidReturn` option by providing an object that disables specific checks. For example, if you don't mind that passing a `() => Promise<void>` to a `() => void` parameter or JSX attribute can lead to a floating unhandled Promise:
57+
58+
```json
59+
{
60+
"@typescript-eslint/no-misused-promises": [
61+
"error",
62+
{
63+
"checksVoidReturn": {
64+
"arguments": false,
65+
"attributes": false
66+
}
67+
}
68+
]
69+
}
70+
```
71+
72+
The following sub-options are supported:
73+
74+
#### `arguments`
75+
76+
Disables checking an asynchronous function passed as argument where the parameter type expects a function that returns `void`.
77+
78+
#### `attributes`
79+
80+
Disables checking an asynchronous function passed as a JSX attribute expected to be a function that returns `void`.
81+
82+
#### `inheritedMethods`
83+
84+
Disables checking an asynchronous method in a type that extends or implements another type expecting that method to return `void`.
85+
86+
:::note
87+
For now, `no-misused-promises` only checks _named_ methods against extended/implemented types: that is, call/construct/index signatures are ignored. Call signatures are not required in TypeScript to be consistent with one another, and construct signatures cannot be `async` in the first place. Index signature checking may be implemented in the future.
88+
:::
89+
90+
#### `properties`
91+
92+
Disables checking an asynchronous function passed as an object property expected to be a function that returns `void`.
93+
94+
#### `returns`
95+
96+
Disables checking an asynchronous function returned in a function whose return type is a function that returns `void`.
97+
98+
#### `variables`
99+
100+
Disables checking an asynchronous function used as a variable whose return type is a function that returns `void`.
101+
102+
### `checksSpreads`
103+
104+
If you don't want to check object spreads, you can add this configuration:
105+
106+
```json
107+
{
108+
"@typescript-eslint/no-misused-promises": [
109+
"error",
110+
{
111+
"checksSpreads": false
112+
}
113+
]
114+
}
115+
```
41116

42117
## Examples
43118

119+
### `checksConditionals`
120+
121+
Examples of code for this rule with `checksConditionals: true`:
122+
44123
<Tabs>
45124
<TabItem value="❌ Incorrect">
46125

@@ -81,45 +160,6 @@ while (await promise) {
81160

82161
### `checksVoidReturn`
83162

84-
Likewise, if you don't want to check functions that return promises where a void return is
85-
expected, your configuration will look like this:
86-
87-
```json
88-
{
89-
"@typescript-eslint/no-misused-promises": [
90-
"error",
91-
{
92-
"checksVoidReturn": false
93-
}
94-
]
95-
}
96-
```
97-
98-
You can disable selective parts of the `checksVoidReturn` option by providing an object that disables specific checks.
99-
The following options are supported:
100-
101-
- `arguments`: Disables checking an asynchronous function passed as argument where the parameter type expects a function that returns `void`
102-
- `attributes`: Disables checking an asynchronous function passed as a JSX attribute expected to be a function that returns `void`
103-
- `properties`: Disables checking an asynchronous function passed as an object property expected to be a function that returns `void`
104-
- `returns`: Disables checking an asynchronous function returned in a function whose return type is a function that returns `void`
105-
- `variables`: Disables checking an asynchronous function used as a variable whose return type is a function that returns `void`
106-
107-
For example, if you don't mind that passing a `() => Promise<void>` to a `() => void` parameter or JSX attribute can lead to a floating unhandled Promise:
108-
109-
```json
110-
{
111-
"@typescript-eslint/no-misused-promises": [
112-
"error",
113-
{
114-
"checksVoidReturn": {
115-
"arguments": false,
116-
"attributes": false
117-
}
118-
}
119-
]
120-
}
121-
```
122-
123163
Examples of code for this rule with `checksVoidReturn: true`:
124164

125165
<Tabs>
@@ -140,6 +180,15 @@ document.addEventListener('click', async () => {
140180
await fetch('/');
141181
console.log('synchronous call');
142182
});
183+
184+
interface MySyncInterface {
185+
setThing(): void;
186+
}
187+
class MyClass implements MySyncInterface {
188+
async setThing(): Promise<void> {
189+
this.thing = await fetchThing();
190+
}
191+
}
143192
```
144193

145194
</TabItem>
@@ -182,26 +231,22 @@ document.addEventListener('click', () => {
182231

183232
handler().catch(handleError);
184233
});
234+
235+
interface MyAsyncInterface {
236+
setThing(): Promise<void>;
237+
}
238+
class MyClass implements MyAsyncInterface {
239+
async setThing(): Promise<void> {
240+
this.thing = await fetchThing();
241+
}
242+
}
185243
```
186244

187245
</TabItem>
188246
</Tabs>
189247

190248
### `checksSpreads`
191249

192-
If you don't want to check object spreads, you can add this configuration:
193-
194-
```json
195-
{
196-
"@typescript-eslint/no-misused-promises": [
197-
"error",
198-
{
199-
"checksSpreads": false
200-
}
201-
]
202-
}
203-
```
204-
205250
Examples of code for this rule with `checksSpreads: true`:
206251

207252
<Tabs>

packages/eslint-plugin/src/rules/no-misused-promises.ts

Lines changed: 104 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ type Options = [
2323
interface ChecksVoidReturnOptions {
2424
arguments?: boolean;
2525
attributes?: boolean;
26+
inheritedMethods?: boolean;
2627
properties?: boolean;
2728
returns?: boolean;
2829
variables?: boolean;
@@ -33,6 +34,7 @@ type MessageId =
3334
| 'spread'
3435
| 'voidReturnArgument'
3536
| 'voidReturnAttribute'
37+
| 'voidReturnInheritedMethod'
3638
| 'voidReturnProperty'
3739
| 'voidReturnReturnValue'
3840
| 'voidReturnVariable';
@@ -49,6 +51,7 @@ function parseChecksVoidReturn(
4951
return {
5052
arguments: true,
5153
attributes: true,
54+
inheritedMethods: true,
5255
properties: true,
5356
returns: true,
5457
variables: true,
@@ -58,6 +61,7 @@ function parseChecksVoidReturn(
5861
return {
5962
arguments: checksVoidReturn.arguments ?? true,
6063
attributes: checksVoidReturn.attributes ?? true,
64+
inheritedMethods: checksVoidReturn.inheritedMethods ?? true,
6165
properties: checksVoidReturn.properties ?? true,
6266
returns: checksVoidReturn.returns ?? true,
6367
variables: checksVoidReturn.variables ?? true,
@@ -76,14 +80,16 @@ export default createRule<Options, MessageId>({
7680
messages: {
7781
voidReturnArgument:
7882
'Promise returned in function argument where a void return was expected.',
79-
voidReturnVariable:
80-
'Promise-returning function provided to variable where a void return was expected.',
83+
voidReturnAttribute:
84+
'Promise-returning function provided to attribute where a void return was expected.',
85+
voidReturnInheritedMethod:
86+
"Promise-returning method provided where a void return was expected by extended/implemented type '{{ heritageTypeName }}'.",
8187
voidReturnProperty:
8288
'Promise-returning function provided to property where a void return was expected.',
8389
voidReturnReturnValue:
8490
'Promise-returning function provided to return value where a void return was expected.',
85-
voidReturnAttribute:
86-
'Promise-returning function provided to attribute where a void return was expected.',
91+
voidReturnVariable:
92+
'Promise-returning function provided to variable where a void return was expected.',
8793
conditional: 'Expected non-Promise value in a boolean conditional.',
8894
spread: 'Expected a non-Promise value to be spreaded in an object.',
8995
},
@@ -103,6 +109,7 @@ export default createRule<Options, MessageId>({
103109
properties: {
104110
arguments: { type: 'boolean' },
105111
attributes: { type: 'boolean' },
112+
inheritedMethods: { type: 'boolean' },
106113
properties: { type: 'boolean' },
107114
returns: { type: 'boolean' },
108115
variables: { type: 'boolean' },
@@ -156,6 +163,11 @@ export default createRule<Options, MessageId>({
156163
...(checksVoidReturn.attributes && {
157164
JSXAttribute: checkJSXAttribute,
158165
}),
166+
...(checksVoidReturn.inheritedMethods && {
167+
ClassDeclaration: checkClassLikeOrInterfaceNode,
168+
ClassExpression: checkClassLikeOrInterfaceNode,
169+
TSInterfaceDeclaration: checkClassLikeOrInterfaceNode,
170+
}),
159171
...(checksVoidReturn.properties && {
160172
Property: checkProperty,
161173
}),
@@ -466,6 +478,71 @@ export default createRule<Options, MessageId>({
466478
}
467479
}
468480

481+
function checkClassLikeOrInterfaceNode(
482+
node:
483+
| TSESTree.ClassDeclaration
484+
| TSESTree.ClassExpression
485+
| TSESTree.TSInterfaceDeclaration,
486+
): void {
487+
const tsNode = services.esTreeNodeToTSNodeMap.get(node);
488+
489+
const heritageTypes = getHeritageTypes(checker, tsNode);
490+
if (!heritageTypes?.length) {
491+
return;
492+
}
493+
494+
for (const nodeMember of tsNode.members) {
495+
const memberName = nodeMember.name?.getText();
496+
if (memberName === undefined) {
497+
// Call/construct/index signatures don't have names. TS allows call signatures to mismatch,
498+
// and construct signatures can't be async.
499+
// TODO - Once we're able to use `checker.isTypeAssignableTo` (v8), we can check an index
500+
// signature here against its compatible index signatures in `heritageTypes`
501+
continue;
502+
}
503+
if (!returnsThenable(checker, nodeMember)) {
504+
continue;
505+
}
506+
for (const heritageType of heritageTypes) {
507+
checkHeritageTypeForMemberReturningVoid(
508+
nodeMember,
509+
heritageType,
510+
memberName,
511+
);
512+
}
513+
}
514+
}
515+
516+
/**
517+
* Checks `heritageType` for a member named `memberName` that returns void; reports the
518+
* 'voidReturnInheritedMethod' message if found.
519+
* @param nodeMember Node member that returns a Promise
520+
* @param heritageType Heritage type to check against
521+
* @param memberName Name of the member to check for
522+
*/
523+
function checkHeritageTypeForMemberReturningVoid(
524+
nodeMember: ts.Node,
525+
heritageType: ts.Type,
526+
memberName: string,
527+
): void {
528+
const heritageMember = getMemberIfExists(heritageType, memberName);
529+
if (heritageMember === undefined) {
530+
return;
531+
}
532+
const memberType = checker.getTypeOfSymbolAtLocation(
533+
heritageMember,
534+
nodeMember,
535+
);
536+
if (!isVoidReturningFunctionType(checker, nodeMember, memberType)) {
537+
return;
538+
}
539+
context.report({
540+
node: services.tsNodeToESTreeNodeMap.get(nodeMember),
541+
messageId: 'voidReturnInheritedMethod',
542+
data: { heritageTypeName: checker.typeToString(heritageType) },
543+
});
544+
}
545+
469546
function checkJSXAttribute(node: TSESTree.JSXAttribute): void {
470547
if (
471548
node.value == null ||
@@ -777,3 +854,26 @@ function returnsThenable(checker: ts.TypeChecker, node: ts.Node): boolean {
777854
.unionTypeParts(type)
778855
.some(t => anySignatureIsThenableType(checker, node, t));
779856
}
857+
858+
function getHeritageTypes(
859+
checker: ts.TypeChecker,
860+
tsNode: ts.ClassDeclaration | ts.ClassExpression | ts.InterfaceDeclaration,
861+
): ts.Type[] | undefined {
862+
return tsNode.heritageClauses
863+
?.flatMap(clause => clause.types)
864+
.map(typeExpression => checker.getTypeAtLocation(typeExpression));
865+
}
866+
867+
/**
868+
* @returns The member with the given name in `type`, if it exists.
869+
*/
870+
function getMemberIfExists(
871+
type: ts.Type,
872+
memberName: string,
873+
): ts.Symbol | undefined {
874+
const escapedMemberName = ts.escapeLeadingUnderscores(memberName);
875+
const symbolMemberMatch = type.getSymbol()?.members?.get(escapedMemberName);
876+
return (
877+
symbolMemberMatch ?? tsutils.getPropertyOfType(type, escapedMemberName)
878+
);
879+
}

packages/eslint-plugin/tests/docs-eslint-output-snapshots/no-misused-promises.shot

Lines changed: 21 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)
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