Skip to content

feat(eslint-plugin): add rule [strict-void-return] #9707

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

Open
wants to merge 60 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
60 commits
Select commit Hold shift + click to select a range
76c295b
feat(eslint-plugin): add rule [strict-void-return]
phaux Aug 2, 2024
5454727
chore: generate configs
phaux Aug 2, 2024
38e2d18
better handling of base class/interfaces
phaux Aug 2, 2024
cb58ccc
update docs
phaux Aug 2, 2024
75da13d
restructure some code
phaux Aug 2, 2024
db1bc42
Merge branch 'main' into strict-void-return
phaux Aug 4, 2024
e0ef02d
Merge branch 'main' into strict-void-return
phaux Aug 5, 2024
2d82282
update to new RuleTester
phaux Aug 5, 2024
8cea97b
more detailed messages
phaux Aug 9, 2024
c16c874
Merge branch 'main' into strict-void-return
phaux Aug 9, 2024
d8f2b03
more coverage
phaux Aug 9, 2024
b353dc9
lint fix
phaux Aug 10, 2024
3de72ca
fix addEventListener test
phaux Aug 10, 2024
ae1fd10
Merge branch 'main' into strict-void-return
phaux Aug 15, 2024
cc7a23e
fix node imports
phaux Aug 15, 2024
fe7a62e
Merge branch 'main' into strict-void-return
phaux Sep 14, 2024
a335f3c
simplify options
phaux Sep 15, 2024
4d8bf19
Apply suggestions from code review
Sep 18, 2024
31434c8
Merge branch 'main' into strict-void-return
Sep 18, 2024
4075db2
docs
phaux Sep 18, 2024
4c9a620
Merge branch 'main' into strict-void-return
phaux Nov 4, 2024
f4f68f9
update snapshots
phaux Nov 4, 2024
f4bc0bc
fix lint errors
phaux Nov 7, 2024
458ccf5
simplify messages
phaux Nov 7, 2024
335a7cf
move option descriptions to schema
phaux Nov 7, 2024
6981f67
update snapshots
phaux Nov 7, 2024
3c0484f
Merge branch 'main' into strict-void-return
phaux Dec 7, 2024
851f472
update messages
phaux Dec 7, 2024
21b4020
don't use node:assert
phaux Dec 13, 2024
8687130
simplify examples
phaux Dec 21, 2024
3f9d977
update snapshots
phaux Dec 21, 2024
172548c
remove autofixes
phaux Dec 21, 2024
a484413
Merge branch 'main' into strict-void-return
phaux Dec 22, 2024
bb4c90b
remove unused function
phaux Jan 7, 2025
5d76db0
Merge branch 'main' into strict-void-return
phaux Jan 7, 2025
5c56d72
Merge branch 'main' into strict-void-return
phaux Mar 9, 2025
55b6378
recreate from main
phaux Apr 24, 2025
600422e
Merge branch 'main' into strict-void-return
phaux Apr 24, 2025
f72849a
take ESTree nodes in utils
phaux Apr 24, 2025
b43a125
add tests
phaux Apr 24, 2025
76c3b30
add test
phaux Apr 24, 2025
75ac458
remove dynamic messages
phaux Apr 25, 2025
2f0326d
remove unnecessary code
phaux Apr 25, 2025
8b20c4b
remove allowReturnPromiseIfTryCatch
phaux Apr 25, 2025
74dfcba
update docs
phaux Apr 25, 2025
ede0145
Merge branch 'main' into strict-void-return
JoshuaKGoldberg Jun 30, 2025
47bfe75
remove unnecessary condition
phaux Jul 9, 2025
2137c1c
test weird jsx
phaux Jul 9, 2025
a061962
fix deprecation
phaux Jul 9, 2025
337694a
Update docs
phaux Jul 10, 2025
1143df9
Update docs
phaux Jul 10, 2025
1b1ed16
Update docs
phaux Jul 10, 2025
f63c609
remove comment
phaux Jul 10, 2025
f491103
Update docs
phaux Jul 10, 2025
9ba717f
Update docs
phaux Jul 10, 2025
83cb8a7
Update docs
phaux Jul 10, 2025
5d71ef5
simplify void check
phaux Jul 10, 2025
9b2f2f4
assert object prop type
phaux Jul 11, 2025
3cad6b9
test empty and accessor methods
phaux Jul 12, 2025
aad8977
update docs
phaux Jul 18, 2025
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
3 changes: 2 additions & 1 deletion packages/eslint-plugin/docs/rules/no-misused-promises.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -311,4 +311,5 @@ You might consider using [ESLint disable comments](https://eslint.org/docs/lates

## Related To

- [`no-floating-promises`](./no-floating-promises.mdx)
- [`strict-void-return`](./strict-void-return.mdx) - A superset of this rule's `checksVoidReturn` option which also checks for non-Promise values.
- [`no-floating-promises`](./no-floating-promises.mdx) - Warns about unhandled promises in _statement_ positions.
321 changes: 321 additions & 0 deletions packages/eslint-plugin/docs/rules/strict-void-return.mdx
Copy link
Member

Choose a reason for hiding this comment

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

At this point this rule already did everything no-misused-promises's checkVoidReturn did, but better. It doesn't have problems #8054 or #8739. Maybe it's worth splitting no-misused-promises into 3 separate rules in the future? (this one being one of them)

This is a great question. no-misused-promises was already largely overlapped by no-unnecessary-condition. This new strict-void-return pretty much takes on the rest of no-misused-promises, making no-misused-promises redundant if you have both no-unnecessary-condition and strict-void-return...

I'd be in favor of deprecated no-misused-promises in favor of using no-unnecessary-condition + strict-void-return. The only benefit I can think of for no-misused-promises would be projects that want to only apply the checks for Promises... Maybe these two rules could each be given some kind of "only check Promises" option?

Also of note is that no-misused-promises's checkVoidReturns is pretty configurable. Maybe, if this rule is to replace no-misused-promises, it'd be useful to have each of those configurable options? Or, on the other hand, maybe those options are holdovers that real-world don't generally use? Investigation needed. I think those options can be a followup & shouldn't block this PR.

What do you think?

Also cc: @typescript-eslint/triage-team in general, and @kirkwaiblinger + @alythobani from #8765.

Copy link
Contributor

@alythobani alythobani Sep 6, 2024

Choose a reason for hiding this comment

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

no-misused-promises was already largely overlapped by no-unnecessary-condition. This new strict-void-return pretty much takes on the rest of no-misused-promises, making no-misused-promises redundant if you have both no-unnecessary-condition and strict-void-return...

Yeah the only thing left I think would be the checksSpreads option:

const myPromise = Promise.resolve({num: 2, str: "2"});
const myObject = {...myPromise}; // Expected a non-Promise value to be spreaded in an object. eslint(@typescript-eslint/no-misused-promises)

I do agree it could make sense to replace checksVoidReturn with strict-void-return. Although there may be tradeoffs in terms of eng effort and/or UX complexity if we wanted to retain all the configurability on top of having an onlyChecksPromises option.

As for checksConditionals, I actually just found microsoft/TypeScript#34717 and microsoft/TypeScript#39175—looks like checkConditionals has been covered by TypeScript for a couple years now :)

The only benefit I can think of for no-misused-promises would be projects that want to only apply the checks for Promises

Yeah e.g. one example I've seen when looking into this topic (void function assignability), is using push with forEach:

declare function forEach<T>(arr: T[], callback: (el: T) => void): void;
let target: number[] = [];
forEach([1, 2, 3], el => target.push(el)); // OK

It's possible some users would prefer to only check Promises so they can still use shorthands like the above without linter errors (and/or just mainly care about forgetting to await Promises), in which case an onlyChecksPromises option would be useful if we did replace checksVoidReturn with strict-void-return.

Maybe, if this rule is to replace no-misused-promises, it'd be useful to have each of those configurable options? Or, on the other hand, maybe those options are holdovers that real-world don't generally use?

It looks like #4619 was originally the impetus for adding the options (#4623); and based on the thread it looks like there are at least a few people who find the configurability you added very helpful!

Copy link
Member

Choose a reason for hiding this comment

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

Maybe a lame response, but is there a compelling reason not to land this first, then consider the no-misused-promises deprecation, and which options we might need to port or create in order to do so, afterwards?

Just thinking, deprecating no-misused-promises might have some strings attached, such as some nontrivial updating of the docs in no-floating-promises that explain how to lint against promise antipatterns outside of ExpressionStatements.

Copy link
Member

Choose a reason for hiding this comment

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

Agreed with landing this first, then considering a deprecation as a followup.

In fact, this rule is pretty big and scary. We don't really have a process for declaring rules as "canary" or "experimental". #8676 is the closest we have to a feature request. Maybe we should set a precedent?

(I don't think this PR should be blocked on that)

Original file line number Diff line number Diff line change
@@ -0,0 +1,321 @@
---
description: 'Disallow passing a value-returning function in a position accepting a void function.'
---

import Tabs from '@theme/Tabs';
import TabItem from '@theme/TabItem';

> 🛑 This file is source code, not the primary documentation location! 🛑
>
> See **https://typescript-eslint.io/rules/strict-void-return** for documentation.

## Rule Details

TypeScript considers functions returning a value to be assignable to a function returning void.
Using this feature of TypeScript can lead to bugs or confusing code.

## Examples

### Return type unsafety

Passing a value-returning function in a place expecting a void function can be unsound.
TypeScript generally treats the `void` type as though it has the same runtime behavior as `undefined`,
so this pattern will cause a mismatch between the runtime behavior and the types.

```ts
// TypeScript errors on overtly wrong ways of populating the `void` type...
function returnsVoid(): void {
return 1234; // TS Error: Type 'number' is not assignable to type 'void'.
}

// ...but allows more subtle ones
const returnsVoid: () => void = () => 1234;

// Likewise, TypeScript errors on overtly wrong usages of `void` as a runtime value...
declare const v: void;
if (v) {
// TS Error: An expression of type 'void' cannot be tested for truthiness.
// ... do something
}

// ...but silently treats `void` as `undefined` in more subtle scenarios
declare const voidOrString: void | string;
if (voidOrString) {
// voidOrString is narrowed to string in this branch, so this is allowed.
console.log(voidOrString.toUpperCase());
}
```

Between these two behaviors, examples like the following will throw at runtime, despite not reporting a type error:

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

```ts
const getNothing: () => void = () => 2137;
const getString: () => string = () => 'Hello';
const maybeString = Math.random() > 0.1 ? getNothing() : getString();
if (maybeString) console.log(maybeString.toUpperCase()); // ❌ Crash if getNothing was called
```

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

```ts
const getNothing: () => void = () => {};
const getString: () => string = () => 'Hello';
const maybeString = Math.random() > 0.1 ? getNothing() : getString();
if (maybeString) console.log(maybeString.toUpperCase()); // ✅ No crash
```

</TabItem>
</Tabs>

### Unhandled returned promises

If a callback is meant to return void, values returned from functions are likely ignored.
Ignoring a returned Promise means any Promise rejection will be silently ignored
or crash the process depending on runtime.

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

```ts
declare function takesCallback(cb: () => void): void;

takesCallback(async () => {
const response = await fetch('https://api.example.com/');
const data = await response.json();
console.log(data);
});
```

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

```ts
declare function takesCallback(cb: () => void): void;

takesCallback(() => {
(async () => {
const response = await fetch('https://api.example.com/');
const data = await response.json();
console.log(data);
})().catch(console.error);
});
```

</TabItem>
</Tabs>

:::info
If you only care about promises,
you can use the [`no-misused-promises`](no-misused-promises.mdx) rule instead.
:::

:::tip
Use [`no-floating-promises`](no-floating-promises.mdx)
to also enforce error handling of non-awaited promises in statement positions.
:::

### Ignored returned generators

If a generator is returned from a void function it won't even be started.

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

```ts
declare function takesCallback(cb: () => void): void;

takesCallback(function* () {
console.log('Hello');
yield;
console.log('World');
});
```

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

```ts
declare function takesCallback(cb: () => void): void;

takesCallback(() => {
function* gen() {
console.log('Hello');
yield;
console.log('World');
}
for (const _ of gen());
});
```

</TabItem>
</Tabs>

### Accidental dead code

Returning a value from a void function often is an indication of incorrect assumptions about APIs.
Those incorrect assumptions can often lead to unnecessary code.

The following `forEach` loop is a common mistake: its author likely either meant to add `console.log` or meant to use `.map` instead.

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

```ts
['Kazik', 'Zenek'].forEach(name => `Hello, ${name}!`);
```

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

```ts
['Kazik', 'Zenek'].forEach(name => console.log(`Hello, ${name}!`));
```

</TabItem>
</Tabs>

### Void context from extended classes

This rule enforces class methods which override a void method to also be void.

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

```ts
class Foo {
cb() {
console.log('foo');
}
}

class Bar extends Foo {
cb() {
super.cb();
return 'bar';
}
}
```

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

```ts
class Foo {
cb() {
console.log('foo');
}
}

class Bar extends Foo {
cb() {
super.cb();
console.log('bar');
}
}
```

</TabItem>
</Tabs>

### Void context from implemented interfaces

This rule enforces class methods which implement a void method to also be void.

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

```ts
interface Foo {
cb(): void;
}

class Bar implements Foo {
cb() {
return 'cb';
}
}
```

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

```ts
interface Foo {
cb(): void;
}

class Bar implements Foo {
cb() {
console.log('cb');
}
}
```

</TabItem>
</Tabs>

## Options

### `allowReturnAny`

{/* insert option description */}

Additional incorrect code when the option is **disabled**:

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

```ts option='{ "allowReturnAny": false }'
declare function fn(cb: () => void): void;

fn(() => JSON.parse('{}'));

fn(() => {
return someUntypedApi();
});
```

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

```ts option='{ "allowReturnAny": false }'
declare function fn(cb: () => void): void;

fn(() => void JSON.parse('{}'));

fn(() => {
someUntypedApi();
});
```

</TabItem>
</Tabs>

## When Not To Use It

Some projects are architected so that values returned from synchronous void functions are generally safe.
If you only want to check for misused voids with asynchronous functions then you can use [`no-misused-promises`](./no-misused-promises.mdx) instead.

In browser context, an unhandled promise will be reported as an error in the console.
It's generally a good idea to also show some kind of indicator on the page that something went wrong,
but if you are just prototyping or don't care about that, the default behavior might be acceptable.
In such case, instead of handling the promises and `console.error`ing them anyways, you can just disable this rule.

Similarly, the default behavior of crashing the process on unhandled promise rejection
might be acceptable when developing, for example, a CLI tool.
If your promise handlers simply call `process.exit(1)` on rejection,
you may prefer to avoid this rule and rely on the default behavior.

## Related To

- [`no-misused-promises`](./no-misused-promises.mdx) - A subset of this rule which only cares about promises.
- [`no-floating-promises`](./no-floating-promises.mdx) - Warns about unhandled promises in _statement_ positions.
- [`no-confusing-void-expression`](./no-confusing-void-expression.mdx) - Disallows returning _void_ values.

## Further Reading

- [TypeScript FAQ - Void function assignability](https://github.com/Microsoft/TypeScript/wiki/FAQ#why-are-functions-returning-non-void-assignable-to-function-returning-void)
1 change: 1 addition & 0 deletions packages/eslint-plugin/src/configs/eslintrc/all.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,7 @@ export = {
'no-return-await': 'off',
'@typescript-eslint/return-await': 'error',
'@typescript-eslint/strict-boolean-expressions': 'error',
'@typescript-eslint/strict-void-return': 'error',
'@typescript-eslint/switch-exhaustiveness-check': 'error',
'@typescript-eslint/triple-slash-reference': 'error',
'@typescript-eslint/unbound-method': 'error',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ export = {
'@typescript-eslint/restrict-template-expressions': 'off',
'@typescript-eslint/return-await': 'off',
'@typescript-eslint/strict-boolean-expressions': 'off',
'@typescript-eslint/strict-void-return': 'off',
'@typescript-eslint/switch-exhaustiveness-check': 'off',
'@typescript-eslint/unbound-method': 'off',
'@typescript-eslint/use-unknown-in-catch-callback-variable': 'off',
Expand Down
1 change: 1 addition & 0 deletions packages/eslint-plugin/src/configs/flat/all.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,7 @@ export default (
'no-return-await': 'off',
'@typescript-eslint/return-await': 'error',
'@typescript-eslint/strict-boolean-expressions': 'error',
'@typescript-eslint/strict-void-return': 'error',
'@typescript-eslint/switch-exhaustiveness-check': 'error',
'@typescript-eslint/triple-slash-reference': 'error',
'@typescript-eslint/unbound-method': 'error',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ export default (
'@typescript-eslint/restrict-template-expressions': 'off',
'@typescript-eslint/return-await': 'off',
'@typescript-eslint/strict-boolean-expressions': 'off',
'@typescript-eslint/strict-void-return': 'off',
'@typescript-eslint/switch-exhaustiveness-check': 'off',
'@typescript-eslint/unbound-method': 'off',
'@typescript-eslint/use-unknown-in-catch-callback-variable': 'off',
Expand Down
2 changes: 2 additions & 0 deletions packages/eslint-plugin/src/rules/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@ import restrictTemplateExpressions from './restrict-template-expressions';
import returnAwait from './return-await';
import sortTypeConstituents from './sort-type-constituents';
import strictBooleanExpressions from './strict-boolean-expressions';
import strictVoidReturn from './strict-void-return';
import switchExhaustivenessCheck from './switch-exhaustiveness-check';
import tripleSlashReference from './triple-slash-reference';
import typedef from './typedef';
Expand Down Expand Up @@ -259,6 +260,7 @@ const rules = {
'return-await': returnAwait,
'sort-type-constituents': sortTypeConstituents,
'strict-boolean-expressions': strictBooleanExpressions,
'strict-void-return': strictVoidReturn,
'switch-exhaustiveness-check': switchExhaustivenessCheck,
'triple-slash-reference': tripleSlashReference,
typedef,
Expand Down
Loading
Loading