Skip to content

fix(eslint-plugin): [prefer-optional-chain] should report case that can be converted to optional void function call ?.() #11272

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 6 commits into
base: main
Choose a base branch
from
Open
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
31 changes: 31 additions & 0 deletions packages/eslint-plugin/docs/rules/prefer-optional-chain.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,37 @@ thing?.toString();
</TabItem>
</Tabs>

### `checkVoid`

{/* insert option description */}

Examples of code for this rule with `{ checkVoid: true }`:

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

```ts option='{ "checkVoid": true }'
declare const thing: {
method: undefined | (() => void);
};

thing.method && thing.method();
```

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

```ts option='{ "checkVoid": true }'
declare const thing: {
method: undefined | (() => void);
};

thing.method?.();
```

</TabItem>
</Tabs>

### `requireNullish`

{/* insert option description */}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,6 @@ export interface PreferOptionalChainOptions {
checkNumber?: boolean;
checkString?: boolean;
checkUnknown?: boolean;
checkVoid?: boolean;
requireNullish?: boolean;
}
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,11 @@ function isValidFalseBooleanCheckType(
if (options.checkBigInt === true) {
allowedFlags |= ts.TypeFlags.BigIntLike;
}

if (options.checkVoid === true) {
allowedFlags |= ts.TypeFlags.Void;
}

return types.every(t => isTypeFlagSet(t, allowedFlags));
}
Comment on lines 111 to 120
Copy link
Member

Choose a reason for hiding this comment

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

I'm wondering if this section should be flipped to only specify the flags that aren't checked instead of the ones that are?

For now, no tests fail if I switch the logic to the following, where we don't bother with explicit options for or handling of void:

  let flagsToExcludeFromCheck = 0;
  if (options.checkAny !== true) {
    flagsToExcludeFromCheck |= ts.TypeFlags.Any;
  }
  if (options.checkUnknown !== true) {
    flagsToExcludeFromCheck |= ts.TypeFlags.Unknown;
  }
  if (options.checkString !== true) {
    flagsToExcludeFromCheck |= ts.TypeFlags.StringLike;
  }
  if (options.checkNumber !== true) {
    flagsToExcludeFromCheck |= ts.TypeFlags.NumberLike;
  }
  if (options.checkBoolean !== true) {
    flagsToExcludeFromCheck |= ts.TypeFlags.BooleanLike;
  }
  if (options.checkBigInt !== true) {
    flagsToExcludeFromCheck |= ts.TypeFlags.BigIntLike;
  }

  return !types.some(t => isTypeFlagSet(t, flagsToExcludeFromCheck));

Can we come up with some test cases that would

  • demonstrate which approach is preferable
  • solidify that approach rather than passing with either choice of logic

I haven't thought deeply about which logic makes more sense, so it would be lovely if you have time to give this some thought and justify with examples which way is preferable. Thanks!

For reference, I ask this because:

  • prefer-nullish-coalescing does have the "only ignore specific types" approach, rather than "only check some hardcoded types plus allowed primitives" as in the existing implementation of this rule.
  • the answer will determine whether creation of a new option is warranted

Copy link
Author

@nayounsang nayounsang Jun 7, 2025

Choose a reason for hiding this comment

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

I will think about it more as I work, but I will leave a quick comment first. The more immediate the communication, the better. Even if it's not accurate!

  1. I would recommend adding void options. My philosophy is that the more open source users can customize with options, the more use cases users can cover. (Adding options would likely require some additional documentation changes beyond the current commit.)
  2. Since the defaults are given as true, it seems more natural to change to the suggested code. Even better, it prevents optional chaining from being skipped on unexpected types.
  3. I guess I need to consider side effects when flagsToExcludeFromCheck = 0.
let allowedFlags = NULLISH_FLAGS | ts.TypeFlags.Object;

All tests will pass if flagsToExcludeFromCheck = 0 for now and I don't think there's a problem at all.(Due to 2), but something feels off because allowedFlags has default value...

Perhaps the test case would be to determine what error/suggestion occurs depending on whether the option is turned off.


Expand Down
6 changes: 6 additions & 0 deletions packages/eslint-plugin/src/rules/prefer-optional-chain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,11 @@ export default createRule<
description:
'Check operands that are typed as `unknown` when inspecting "loose boolean" operands.',
},
checkVoid: {
type: 'boolean',
description:
'Check operands that are typed as `void` when inspecting "loose boolean" operands.',
},
requireNullish: {
type: 'boolean',
description:
Expand All @@ -100,6 +105,7 @@ export default createRule<
checkNumber: true,
checkString: true,
checkUnknown: true,
checkVoid: true,
requireNullish: false,
},
],
Expand Down

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

Original file line number Diff line number Diff line change
Expand Up @@ -1680,6 +1680,22 @@ describe('hand-crafted cases', () => {
],
output: 'a?.prop;',
},
// check void
{
code: `
declare const foo: {
method: undefined | (() => void);
};
foo.method && foo.method();
`,
errors: [{ messageId: 'preferOptionalChain' }],
output: `
declare const foo: {
method: undefined | (() => void);
};
foo.method?.();
`,
},
],
valid: [
'!a || !b;',
Expand Down

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