Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Conversation

@mdm317
Copy link
Contributor

@mdm317 mdm317 commented Aug 27, 2025

PR Checklist

Overview

"boolean" checks (eg foo and !foo)
!==/===/!=/== comparing against anything except typeof
not typeof because foo && typeof foo.bar will return undefined | typeof foo.bar whereas typeof foo?.bar will return typeof foo.bar | 'undefined' - which is VERY different
should not include >/>=/</<= because TS will hard error if one of the operands is nullish.

In this PR, I decided not to include boolean checks within optional chaining.
The reason is that the boolean outcome may differ between the original code and the optional chaining, which may lead to type inference issues (for example, as described in issue 7654

foo && !foo.bar
if( foo && !foo.bar ){
// foo can't nullish
}
if( !foo?.bar ){
// foo can nullisth
}

So I only handle equality operators (==, ===, !=, !==) at the end of a chain, and only when the boolean result is unchanged after converting to optional chaining.

Algorithm
AND (&&) versions

Expression LHS (a != null && …) RHS (a?.b …) Equal if…
a != null && a.b != somevalue ↔ a?.b != somevalue false undefined != somevalue somevalue == null (null or undefined)
a != null && a.b !== somevalue ↔ a?.b !== somevalue false undefined !== somevalue somevalue === undefined
a != null && a.b == somevalue ↔ a?.b == somevalue false undefined == somevalue somevalue != null
a != null && a.b === somevalue ↔ a?.b === somevalue false undefined === somevalue somevalue !== undefined

OR (||) versions

Expression LHS (a == null || …) RHS (a?.b …) Equal if…
a == null |\ a.b != somevalue ↔ a?.b != somevalue true undefined != somevalue somevalue != null
a == null || a.b !== somevalue ↔ a?.b !== somevalue true undefined !== somevalue somevalue !== undefined
a == null || a.b == somevalue ↔ a?.b == somevalue true undefined == somevalue somevalue == null
a == null || a.b === somevalue ↔ a?.b === somevalue true undefined === somevalue somevalue === undefined

Test cases

Valid (no report)

We do not report when converting to optional chaining could change runtime behavior — especially with truthy/falsy values.
Example from issue 7654:

const data: Data | null = getData();

// Both: `data` must be truthy AND `data.value` not null (0 is valid)
if (data && data.value !== null) {
  console.log("Number: " + data.value);
} else {
  console.log("Invalid data.");
}

Error

Conversion to optional chaining either produces the same result as before, or introduces a type allowed by optional chaining.

foo != null && foo.bar
foo?.bar

Suggestion

Optional chaining introduces an additional undefined type, which changes the result type but preserves the same truthy/falsy behavior.

declare const foo: { bar: number } | null | undefined;
foo != undefined && foo.bar;         => false | number
foo?.bar                                           => undefined | number

Currently, even when the type changes, especially truthy and false some cases report as a suggestion.
examples

declare const foo : { bar : number | null } | null

foo == null || foo.bar === null    => true when foo is null
foo?.bar === null                         => false when foo is null

I think we should fix these cases not to be reported as suggestions.
repro

mdm317 added 12 commits August 26, 2025 14:44
…perator consistency based on logical operator

- When parent operator is `&&`, enforce `==` or `===` as the last operand operator.
- When parent operator is `||`, enforce `!=` or `!==` as the last operand operator.
 - Already check isTruthyOperand and isValidLastChainOperand so dowmstream error not occur when ComparisonType
 - And !==, != check not  nullish
some occur in chain when != null is last chain
typescript-eslint#7654
a != null && a.b != null
a?.b != null
always same when a != null is true
a !== undefined && a.b
a?.b

always same when a is not nullish
a || b  is
always b when a is false
@typescript-eslint
Copy link
Contributor

Thanks for the PR, @mdm317!

typescript-eslint is a 100% community driven project, and we are incredibly grateful that you are contributing to that community.

The core maintainers work on this in their personal time, so please understand that it may not be possible for them to review your work immediately.

Thanks again!


🙏 Please, if you or your company is finding typescript-eslint valuable, help us sustain the project by sponsoring it transparently on https://opencollective.com/typescript-eslint.

@netlify
Copy link

netlify bot commented Aug 27, 2025

Deploy Preview for typescript-eslint ready!

Name Link
🔨 Latest commit f8e07cf
🔍 Latest deploy log https://app.netlify.com/projects/typescript-eslint/deploys/68dfc31c2d4ed3000800114c
😎 Deploy Preview https://deploy-preview-11533--typescript-eslint.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
Lighthouse
Lighthouse
1 paths audited
Performance: 99 (🟢 up 21 from production)
Accessibility: 97 (no change from production)
Best Practices: 100 (no change from production)
SEO: 92 (no change from production)
PWA: 80 (no change from production)
View the detailed breakdown and full score reports

To edit notification comments on pull requests, go to your Netlify project configuration.

@nx-cloud
Copy link

nx-cloud bot commented Aug 27, 2025

🤖 Nx Cloud AI Fix Eligible

An automatically generated fix could have helped fix failing tasks for this run, but Self-healing CI is disabled for this workspace. Visit workspace settings to enable it and get automatic fixes in future runs.

To disable these notifications, a workspace admin can disable them in workspace settings.


View your CI Pipeline Execution ↗ for commit f8e07cf

Command Status Duration Result
nx run-many -t typecheck ❌ Failed 2m 2s View ↗
nx test eslint-plugin --coverage=false ✅ Succeeded 5m 13s View ↗
nx test typescript-estree --coverage=false ✅ Succeeded 2s View ↗
nx test eslint-plugin-internal --coverage=false ✅ Succeeded 3s View ↗
nx run types:build ✅ Succeeded 5s View ↗
nx run integration-tests:test ✅ Succeeded 4s View ↗
nx run generate-configs ✅ Succeeded 6s View ↗
nx run-many --target=build --parallel --exclude... ✅ Succeeded 16s View ↗
Additional runs (28) ✅ Succeeded ... View ↗

☁️ Nx Cloud last updated this comment at 2025-10-03 12:48:53 UTC

@mdm317 mdm317 marked this pull request as draft September 4, 2025 09:02
@codecov
Copy link

codecov bot commented Sep 4, 2025

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 90.78%. Comparing base (cdd6384) to head (f8e07cf).
⚠️ Report is 14 commits behind head on main.

Additional details and impacted files
@@            Coverage Diff             @@
##             main   #11533      +/-   ##
==========================================
+ Coverage   90.74%   90.78%   +0.04%     
==========================================
  Files         516      516              
  Lines       51989    52222     +233     
  Branches     8588     8646      +58     
==========================================
+ Hits        47176    47409     +233     
  Misses       4799     4799              
  Partials       14       14              
Flag Coverage Δ
unittest 90.78% <100.00%> (+0.04%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
...kages/eslint-plugin/src/rules/no-base-to-string.ts 99.06% <ø> (ø)
...ackages/eslint-plugin/src/rules/prefer-includes.ts 98.16% <100.00%> (ø)
.../rules/prefer-optional-chain-utils/analyzeChain.ts 100.00% <100.00%> (ø)
...efer-optional-chain-utils/gatherLogicalOperands.ts 100.00% <100.00%> (ø)
...s/eslint-plugin/src/rules/prefer-optional-chain.ts 100.00% <100.00%> (ø)
...plugin/src/rules/prefer-string-starts-ends-with.ts 98.82% <100.00%> (ø)
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Comment on lines +273 to 274
// eslint-disable-next-line @typescript-eslint/prefer-optional-chain
if (declarations == null || declarations.length !== 1) {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

const declarations: ts.Declaration[] | undefined

When the new logic was applied, this code
declarations == null || declarations.length !== 1
was changed to
declarations?.length !== 1

But I think the original code is more readable and intuitive.
For cases like foo == null || <equaliy check>, should we avoid optional chaining?
What do you think?
example new playground

Copy link
Member

Choose a reason for hiding this comment

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

If the choices are these:

  • Original: if (declations == null || declations.length !== 1) {
  • Updated: if (declations?.length !== 1) {

Personal anecodtal thoughts: I find the updated form more readable. I think when ?. was new I would probably have preferred the original for being more familiar. But at this point I'm pretty comfortable with ?. and ??, and it feels natural to me.

return false;
}

function isAlwaysTruthyOperand(
Copy link
Contributor Author

Choose a reason for hiding this comment

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

I'm not convinced this function is necessary.
This function checks if the previous operand is always true:

  • foo != null && foo.bar == somevaluefoo?.bar == somevalue (same result when foo is not nullish)
  • foo && foo.bar == somevaluefoo?.bar == somevalue (same result when foo is truthy)

But if we know foo is not nullish, we can simply write foo.bar == somevalue without optional chaining at all.

Copy link
Member

@JoshuaKGoldberg JoshuaKGoldberg Oct 3, 2025

Choose a reason for hiding this comment

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

Removing it fails some unit tests (that have legitimate cases, I believe). E.g.:

declare const foo: { bar: string } | null;
foo !== null && foo.bar !== null;

chain,
) => {
switch (operand.comparisonType) {
case NullishComparisonType.Boolean: {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This logic moved to line 700.

Copy link
Member

Choose a reason for hiding this comment

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

Thanks for these tips, they made reviewing this (already, by necessity, complex) logic easier. 🙂

Comment on lines +713 to +714
case NullishComparisonType.StrictEqualNull:
case NullishComparisonType.NotStrictEqualNull: {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

When the last parameter is StrictEqualNull like foo && foo.bar !== null, we can't convert to optional chaining because:

  • foo && foo.bar !== null returns false when foo is nullish
  • foo?.bar !== null returns true when foo is nullish

Also StrictEqualNull case foo == null | foo.bar !== null

  • foo == null || foo.bar !== null returns true when foo is nullish
  • foo?.bar !== null returns false when foo is nullish

This issue is documented here.

But as I mentioned above, I'm still curious whether this logic is necessary at all.

lastOperand.comparisonType,
parserServices,
) ||
isValidLastChainOperand(
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This function checks if the last operand is valid as outlined in the table.

left.type === AST_NODE_TYPES.MemberExpression;
const isRightMemberExpression =
right.type === AST_NODE_TYPES.MemberExpression;
if (isLeftMemberExpression && !isRightMemberExpression) {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Only checks MemberExpression because equality checks cannot be the first chain element.

| null
| undefined;
};
foo.bar?.() !== null &&
Copy link
Contributor Author

Choose a reason for hiding this comment

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

If foo.bar is guaranteed to be non-nullish, we can change it to optional chaining.

| undefined;
};
foo.bar === undefined ||
foo.bar() === undefined ||
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Since operand is a nullish check,
operand || foo.bar() === undefined can be converted to optional chaining.

@mdm317 mdm317 marked this pull request as ready for review September 8, 2025 04:43
Copy link
Member

@JoshuaKGoldberg JoshuaKGoldberg left a comment

Choose a reason for hiding this comment

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

I have to be honest, this rule is one of my least favorites to review -- purely because of how intricate and nuanced its logic is. Each time I review I think I get to maybe 80% comprehension of it. Very Pareto Principle. If someone like @bradzacher has time to take a deeper look that'd probably be best... but the rule is quite well-tested, and from what I had time to parse out this is excellent.

I have no suggestions for changes. Just the one request to remove an eslint-disable internally as you'd brought a comment up on.

Awesome work. Really, thank you for diving so much into this beast of a rule! 🔥

Comment on lines +273 to 274
// eslint-disable-next-line @typescript-eslint/prefer-optional-chain
if (declarations == null || declarations.length !== 1) {
Copy link
Member

Choose a reason for hiding this comment

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

If the choices are these:

  • Original: if (declations == null || declations.length !== 1) {
  • Updated: if (declations?.length !== 1) {

Personal anecodtal thoughts: I find the updated form more readable. I think when ?. was new I would probably have preferred the original for being more familiar. But at this point I'm pretty comfortable with ?. and ??, and it feels natural to me.

Copy link
Member

Choose a reason for hiding this comment

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

[Praise] Such thorough testing! Lovely. 👏 🔥

// x == something :(
// x === something :(
// x != something :(
// x !== something :(
Copy link
Member

Choose a reason for hiding this comment

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

[Praise] The :(s made me chuckle. 😄

@JoshuaKGoldberg JoshuaKGoldberg added the 1 approval >=1 team member has approved this PR; we're now leaving it open for more reviews before we merge label Oct 3, 2025
@bradzacher bradzacher merged commit 73003bf into typescript-eslint:main Oct 7, 2025
65 of 67 checks passed
@B4nan
Copy link

B4nan commented Oct 14, 2025

This PR is too aggressive and suggests optional chaining in cases where it would change behavior.

Few cases from mikro-orm/mikro-orm#6924 (when applying the suggested fixes, the better half of the test suite breaks).

-    if (meta.properties[propertyName] && meta.properties[propertyName].kind !== reference) {
+    if (meta.properties[propertyName]?.kind !== reference) {
-    if (!meta.embeddable && (!meta.primaryKeys || meta.primaryKeys.length === 0)) {
+    if (!meta.embeddable && (meta.primaryKeys?.length === 0)) {
-      if (!prop.ownColumns || prop.ownColumns.length === 0) {
+      if (prop.ownColumns?.length === 0) {
-    if (!last || last[0] !== entityName || last[1] !== prop) {
+    if (last?.[0] !== entityName || last[1] !== prop) {

Basically, all the changes that the PR suggests are wrong.

@kirkwaiblinger
Copy link
Member

@B4nan We ask that you please file a new issue rather than commenting on a closed PR, see https://typescript-eslint.io/contributing/issues#commenting.

I'm not able to reproduce what you're seeing in the playground based on my best guess at creating a reproduction... This code does not result in a lint report:

(playground)

declare const a: { b?: number[] };

if (!a.b || a.b.length === 0) {
  
}

Can you please help us help you by creating an isolated reproduction we can try, and filing a new issue with it? Thanks!

@aboks
Copy link

aboks commented Oct 14, 2025

We also observe overly 'aggresive' behavior from this rule, as described in the comment by @B4nan. I've opened #11700 with a reproduction case.

renovate bot added a commit to andrei-picus-tink/auto-renovate that referenced this pull request Oct 15, 2025
| datasource | package                          | from   | to     |
| ---------- | -------------------------------- | ------ | ------ |
| npm        | @typescript-eslint/eslint-plugin | 8.46.0 | 8.46.1 |
| npm        | @typescript-eslint/parser        | 8.46.0 | 8.46.1 |


## [v8.46.1](https://github.com/typescript-eslint/typescript-eslint/blob/HEAD/packages/eslint-plugin/CHANGELOG.md#8461-2025-10-13)

##### 🩹 Fixes

- **eslint-plugin:** \[no-misused-promises] special-case `.finally` not to report when a promise returning function is provided as an argument ([#11667](typescript-eslint/typescript-eslint#11667))
- **eslint-plugin:** \[prefer-optional-chain] include mixed "nullish comparison style" chains in checks ([#11533](typescript-eslint/typescript-eslint#11533))

##### ❤️ Thank You

- mdm317
- Ronen Amiel

You can read about our [versioning strategy](https://typescript-eslint.io/users/versioning) and [releases](https://typescript-eslint.io/users/releases) on our website.
renovate bot added a commit to andrei-picus-tink/auto-renovate that referenced this pull request Oct 17, 2025
| datasource | package                          | from   | to     |
| ---------- | -------------------------------- | ------ | ------ |
| npm        | @typescript-eslint/eslint-plugin | 8.46.0 | 8.46.1 |
| npm        | @typescript-eslint/parser        | 8.46.0 | 8.46.1 |


## [v8.46.1](https://github.com/typescript-eslint/typescript-eslint/blob/HEAD/packages/eslint-plugin/CHANGELOG.md#8461-2025-10-13)

##### 🩹 Fixes

- **eslint-plugin:** \[no-misused-promises] special-case `.finally` not to report when a promise returning function is provided as an argument ([#11667](typescript-eslint/typescript-eslint#11667))
- **eslint-plugin:** \[prefer-optional-chain] include mixed "nullish comparison style" chains in checks ([#11533](typescript-eslint/typescript-eslint#11533))

##### ❤️ Thank You

- mdm317
- Ronen Amiel

You can read about our [versioning strategy](https://typescript-eslint.io/users/versioning) and [releases](https://typescript-eslint.io/users/releases) on our website.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

1 approval >=1 team member has approved this PR; we're now leaving it open for more reviews before we merge

Projects

None yet

6 participants