Skip to content

feat(eslint-plugin): [no-misused-promises] check subtype methods against heritage type methods #8765

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
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
5bc1c5f
Implement checking of subtypes for no-misused-promises (currently wor…
alythobani Mar 22, 2024
8a75efa
Finished working logic for no-misused-promises checksVoidReturn.subtypes
alythobani Mar 23, 2024
24283e0
Cleanup
alythobani Mar 23, 2024
0936f3b
Refactor and improve (better more concise approach), and add more tes…
alythobani Mar 23, 2024
3cc1c0d
Handle type aliases and add type alias test cases
alythobani Mar 24, 2024
7146bce
Added expected {{ baseTypeName }} values to test cases
alythobani Mar 24, 2024
87c6947
Added test cases for handling class expressions, and fixed code to ha…
alythobani Mar 25, 2024
b3c477e
Fix no-misused-promises schema snapshot to account for new subtypes o…
alythobani Mar 25, 2024
714fffe
Updated copy (base type => heritage type) and added documentation for…
alythobani Mar 25, 2024
1f09c9b
Update copy in test cases (baseTypeName => heritageTypeName)
alythobani Mar 25, 2024
32dd7f7
Refactoring
alythobani Mar 25, 2024
36b460c
Copy change again: subtypes => heritageTypes
alythobani Mar 25, 2024
a7b4e1f
Fix location of heritageTypes examples in no-misused-promises mdx doc
alythobani Mar 25, 2024
3c9c945
Refactor out getHeritageTypes function
alythobani Mar 25, 2024
c71dfca
Update no-misused-promises doc to specify that it checks named methods
alythobani Mar 29, 2024
62cc008
Add test cases for ignoring unnamed methods (signatures) and add expl…
alythobani Mar 29, 2024
330b8d4
Add combination test cases which add coverage for when the member is …
alythobani Mar 29, 2024
ce240c7
Rename subtypes => heritageTypes in region comments
alythobani Mar 29, 2024
ab6174c
Adjust no-misused-promises doc to be more explicit about heritageTypes
alythobani Apr 23, 2024
93ce983
Remove `#region`s from no-misused-promises test file
alythobani Apr 23, 2024
a175db7
Merge branch 'main' into 1/no-misused-promises-abstract-method-issue-…
alythobani Apr 24, 2024
74d421d
Update (use jest to generate) no-misused-promises rules snapshot
alythobani Apr 24, 2024
d6cfd24
refactor: map(...).flat() => flatMap(...)
alythobani Jun 6, 2024
eca836f
docs: restructure no-misused-promises doc
alythobani Jun 7, 2024
fbcc229
chore: remove my unit-test-labeling comments
alythobani Jun 7, 2024
f10b62d
rename `heritageTypes` suboption to `inheritedMethods`
alythobani Jul 17, 2024
6944e66
Style nitty nit - not worrying about strict-boolean-expressions,
alythobani Jul 22, 2024
8304cef
Merge branch 'main'
JoshuaKGoldberg Aug 12, 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
151 changes: 98 additions & 53 deletions packages/eslint-plugin/docs/rules/no-misused-promises.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -37,10 +37,89 @@ If you don't want to check conditionals, you can configure the rule with `"check

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

Examples of code for this rule with `checksConditionals: true`:
### `checksVoidReturn`

Likewise, if you don't want to check functions that return promises where a void return is
expected, your configuration will look like this:

```json
{
"@typescript-eslint/no-misused-promises": [
"error",
{
"checksVoidReturn": false
}
]
}
```

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:

```json
{
"@typescript-eslint/no-misused-promises": [
"error",
{
"checksVoidReturn": {
"arguments": false,
"attributes": false
}
}
]
}
```

The following sub-options are supported:

#### `arguments`

Disables checking an asynchronous function passed as argument where the parameter type expects a function that returns `void`.

#### `attributes`

Disables checking an asynchronous function passed as a JSX attribute expected to be a function that returns `void`.

#### `inheritedMethods`

Disables checking an asynchronous method in a type that extends or implements another type expecting that method to return `void`.

:::note
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.
:::

#### `properties`

Disables checking an asynchronous function passed as an object property expected to be a function that returns `void`.

#### `returns`

Disables checking an asynchronous function returned in a function whose return type is a function that returns `void`.

#### `variables`

Disables checking an asynchronous function used as a variable whose return type is a function that returns `void`.

### `checksSpreads`

If you don't want to check object spreads, you can add this configuration:

```json
{
"@typescript-eslint/no-misused-promises": [
"error",
{
"checksSpreads": false
}
]
}
```

## Examples

### `checksConditionals`

Examples of code for this rule with `checksConditionals: true`:

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

Expand Down Expand Up @@ -81,45 +160,6 @@ while (await promise) {

### `checksVoidReturn`

Likewise, if you don't want to check functions that return promises where a void return is
expected, your configuration will look like this:

```json
{
"@typescript-eslint/no-misused-promises": [
"error",
{
"checksVoidReturn": false
}
]
}
```

You can disable selective parts of the `checksVoidReturn` option by providing an object that disables specific checks.
The following options are supported:

- `arguments`: Disables checking an asynchronous function passed as argument where the parameter type expects a function that returns `void`
- `attributes`: Disables checking an asynchronous function passed as a JSX attribute expected to be a function that returns `void`
- `properties`: Disables checking an asynchronous function passed as an object property expected to be a function that returns `void`
- `returns`: Disables checking an asynchronous function returned in a function whose return type is a function that returns `void`
- `variables`: Disables checking an asynchronous function used as a variable whose return type is a function that returns `void`

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:

```json
{
"@typescript-eslint/no-misused-promises": [
"error",
{
"checksVoidReturn": {
"arguments": false,
"attributes": false
}
}
]
}
```

Examples of code for this rule with `checksVoidReturn: true`:

<Tabs>
Expand All @@ -140,6 +180,15 @@ document.addEventListener('click', async () => {
await fetch('/');
console.log('synchronous call');
});

interface MySyncInterface {
setThing(): void;
}
class MyClass implements MySyncInterface {
async setThing(): Promise<void> {
this.thing = await fetchThing();
}
}
```

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

handler().catch(handleError);
});

interface MyAsyncInterface {
setThing(): Promise<void>;
}
class MyClass implements MyAsyncInterface {
async setThing(): Promise<void> {
this.thing = await fetchThing();
}
}
```

</TabItem>
</Tabs>

### `checksSpreads`

If you don't want to check object spreads, you can add this configuration:

```json
{
"@typescript-eslint/no-misused-promises": [
"error",
{
"checksSpreads": false
}
]
}
```

Examples of code for this rule with `checksSpreads: true`:

<Tabs>
Expand Down
108 changes: 104 additions & 4 deletions packages/eslint-plugin/src/rules/no-misused-promises.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ type Options = [
interface ChecksVoidReturnOptions {
arguments?: boolean;
attributes?: boolean;
inheritedMethods?: boolean;
properties?: boolean;
returns?: boolean;
variables?: boolean;
Expand All @@ -33,6 +34,7 @@ type MessageId =
| 'spread'
| 'voidReturnArgument'
| 'voidReturnAttribute'
| 'voidReturnInheritedMethod'
| 'voidReturnProperty'
| 'voidReturnReturnValue'
| 'voidReturnVariable';
Expand All @@ -49,6 +51,7 @@ function parseChecksVoidReturn(
return {
arguments: true,
attributes: true,
inheritedMethods: true,
properties: true,
returns: true,
variables: true,
Expand All @@ -58,6 +61,7 @@ function parseChecksVoidReturn(
return {
arguments: checksVoidReturn.arguments ?? true,
attributes: checksVoidReturn.attributes ?? true,
inheritedMethods: checksVoidReturn.inheritedMethods ?? true,
properties: checksVoidReturn.properties ?? true,
returns: checksVoidReturn.returns ?? true,
variables: checksVoidReturn.variables ?? true,
Expand All @@ -76,14 +80,16 @@ export default createRule<Options, MessageId>({
messages: {
voidReturnArgument:
'Promise returned in function argument where a void return was expected.',
voidReturnVariable:
'Promise-returning function provided to variable where a void return was expected.',
voidReturnAttribute:
'Promise-returning function provided to attribute where a void return was expected.',
voidReturnInheritedMethod:
"Promise-returning method provided where a void return was expected by extended/implemented type '{{ heritageTypeName }}'.",
voidReturnProperty:
'Promise-returning function provided to property where a void return was expected.',
voidReturnReturnValue:
'Promise-returning function provided to return value where a void return was expected.',
voidReturnAttribute:
'Promise-returning function provided to attribute where a void return was expected.',
voidReturnVariable:
'Promise-returning function provided to variable where a void return was expected.',
conditional: 'Expected non-Promise value in a boolean conditional.',
spread: 'Expected a non-Promise value to be spreaded in an object.',
},
Expand All @@ -103,6 +109,7 @@ export default createRule<Options, MessageId>({
properties: {
arguments: { type: 'boolean' },
attributes: { type: 'boolean' },
inheritedMethods: { type: 'boolean' },
properties: { type: 'boolean' },
returns: { type: 'boolean' },
variables: { type: 'boolean' },
Expand Down Expand Up @@ -156,6 +163,11 @@ export default createRule<Options, MessageId>({
...(checksVoidReturn.attributes && {
JSXAttribute: checkJSXAttribute,
}),
...(checksVoidReturn.inheritedMethods && {
ClassDeclaration: checkClassLikeOrInterfaceNode,
ClassExpression: checkClassLikeOrInterfaceNode,
TSInterfaceDeclaration: checkClassLikeOrInterfaceNode,
}),
...(checksVoidReturn.properties && {
Property: checkProperty,
}),
Expand Down Expand Up @@ -466,6 +478,71 @@ export default createRule<Options, MessageId>({
}
}

function checkClassLikeOrInterfaceNode(
node:
| TSESTree.ClassDeclaration
| TSESTree.ClassExpression
| TSESTree.TSInterfaceDeclaration,
): void {
const tsNode = services.esTreeNodeToTSNodeMap.get(node);

const heritageTypes = getHeritageTypes(checker, tsNode);
if (!heritageTypes?.length) {
return;
}

for (const nodeMember of tsNode.members) {
const memberName = nodeMember.name?.getText();
if (memberName === undefined) {
// Call/construct/index signatures don't have names. TS allows call signatures to mismatch,
// and construct signatures can't be async.
// TODO - Once we're able to use `checker.isTypeAssignableTo` (v8), we can check an index
Copy link
Member

Choose a reason for hiding this comment

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

v8 is upon us! Though of course this PR has been in the works since long before that.

@alythobani, let's merge as-is, but would you mind filing a followup (that we now know will not be blocked 🙂)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Sweet, yeah definitely! Do you mean filing an issue for this? And I can start looking into a proper implementation too if you'd like

// signature here against its compatible index signatures in `heritageTypes`
continue;
}
if (!returnsThenable(checker, nodeMember)) {
continue;
}
for (const heritageType of heritageTypes) {
checkHeritageTypeForMemberReturningVoid(
nodeMember,
heritageType,
memberName,
);
}
}
}

/**
* Checks `heritageType` for a member named `memberName` that returns void; reports the
* 'voidReturnInheritedMethod' message if found.
* @param nodeMember Node member that returns a Promise
* @param heritageType Heritage type to check against
* @param memberName Name of the member to check for
*/
function checkHeritageTypeForMemberReturningVoid(
nodeMember: ts.Node,
heritageType: ts.Type,
memberName: string,
): void {
const heritageMember = getMemberIfExists(heritageType, memberName);
if (heritageMember === undefined) {
return;
}
const memberType = checker.getTypeOfSymbolAtLocation(
heritageMember,
nodeMember,
);
if (!isVoidReturningFunctionType(checker, nodeMember, memberType)) {
return;
}
context.report({
node: services.tsNodeToESTreeNodeMap.get(nodeMember),
messageId: 'voidReturnInheritedMethod',
data: { heritageTypeName: checker.typeToString(heritageType) },
});
}

function checkJSXAttribute(node: TSESTree.JSXAttribute): void {
if (
node.value == null ||
Expand Down Expand Up @@ -777,3 +854,26 @@ function returnsThenable(checker: ts.TypeChecker, node: ts.Node): boolean {
.unionTypeParts(type)
.some(t => anySignatureIsThenableType(checker, node, t));
}

function getHeritageTypes(
checker: ts.TypeChecker,
tsNode: ts.ClassDeclaration | ts.ClassExpression | ts.InterfaceDeclaration,
): ts.Type[] | undefined {
return tsNode.heritageClauses
?.flatMap(clause => clause.types)
.map(typeExpression => checker.getTypeAtLocation(typeExpression));
}

/**
* @returns The member with the given name in `type`, if it exists.
*/
function getMemberIfExists(
type: ts.Type,
memberName: string,
): ts.Symbol | undefined {
const escapedMemberName = ts.escapeLeadingUnderscores(memberName);
const symbolMemberMatch = type.getSymbol()?.members?.get(escapedMemberName);
return (
symbolMemberMatch ?? tsutils.getPropertyOfType(type, escapedMemberName)
);
}

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

Loading
Loading