diff --git a/packages/eslint-plugin/docs/rules/prefer-optional-chain.mdx b/packages/eslint-plugin/docs/rules/prefer-optional-chain.mdx index 47fcec6d598f..ee7aa6abf42f 100644 --- a/packages/eslint-plugin/docs/rules/prefer-optional-chain.mdx +++ b/packages/eslint-plugin/docs/rules/prefer-optional-chain.mdx @@ -265,7 +265,7 @@ When this option is `true` the rule will skip operands that are not typed with ` -```ts option='{ "requireNullish": true }' skipValidation +```ts option='{ "requireNullish": true }' declare const thing1: string | null; thing1 && thing1.toString(); ``` diff --git a/packages/eslint-plugin/src/rules/prefer-optional-chain-utils/analyzeChain.ts b/packages/eslint-plugin/src/rules/prefer-optional-chain-utils/analyzeChain.ts index 5ebb8e02ae51..24ba787dec77 100644 --- a/packages/eslint-plugin/src/rules/prefer-optional-chain-utils/analyzeChain.ts +++ b/packages/eslint-plugin/src/rules/prefer-optional-chain-utils/analyzeChain.ts @@ -20,6 +20,7 @@ import { NullThrowsReasons, OperatorPrecedence, } from '../../util'; +import { checkNullishAndReport } from './checkNullishAndReport'; import { compareNodes, NodeComparisonResult } from './compareNodes'; import type { ValidOperand } from './gatherLogicalOperands'; import { NullishComparisonType } from './gatherLogicalOperands'; @@ -493,20 +494,26 @@ export function analyzeChain( ): void => { if (subChain.length > 1) { const subChainFlat = subChain.flat(); - context.report({ - messageId: 'preferOptionalChain', - loc: { - start: subChainFlat[0].node.loc.start, - end: subChainFlat[subChainFlat.length - 1].node.loc.end, + checkNullishAndReport( + context, + parserServices, + options, + subChainFlat.slice(0, -1).map(({ node }) => node), + { + messageId: 'preferOptionalChain', + loc: { + start: subChainFlat[0].node.loc.start, + end: subChainFlat[subChainFlat.length - 1].node.loc.end, + }, + ...getFixer( + context.sourceCode, + parserServices, + operator, + options, + subChainFlat, + ), }, - ...getFixer( - context.sourceCode, - parserServices, - operator, - options, - subChainFlat, - ), - }); + ); } // we've reached the end of a chain of logical expressions diff --git a/packages/eslint-plugin/src/rules/prefer-optional-chain-utils/checkNullishAndReport.ts b/packages/eslint-plugin/src/rules/prefer-optional-chain-utils/checkNullishAndReport.ts new file mode 100644 index 000000000000..404b3736040a --- /dev/null +++ b/packages/eslint-plugin/src/rules/prefer-optional-chain-utils/checkNullishAndReport.ts @@ -0,0 +1,38 @@ +import { isTypeFlagSet } from '@typescript-eslint/type-utils'; +import type { + ParserServicesWithTypeInformation, + TSESTree, +} from '@typescript-eslint/utils'; +import type { + ReportDescriptor, + RuleContext, +} from '@typescript-eslint/utils/ts-eslint'; +import { unionTypeParts } from 'ts-api-utils'; +import * as ts from 'typescript'; + +import type { + PreferOptionalChainMessageIds, + PreferOptionalChainOptions, +} from './PreferOptionalChainOptions'; + +export function checkNullishAndReport( + context: RuleContext< + PreferOptionalChainMessageIds, + [PreferOptionalChainOptions] + >, + parserServices: ParserServicesWithTypeInformation, + { requireNullish }: PreferOptionalChainOptions, + maybeNullishNodes: TSESTree.Expression[], + descriptor: ReportDescriptor, +): void { + if ( + !requireNullish || + maybeNullishNodes.some(node => + unionTypeParts(parserServices.getTypeAtLocation(node)).some(t => + isTypeFlagSet(t, ts.TypeFlags.Null | ts.TypeFlags.Undefined), + ), + ) + ) { + context.report(descriptor); + } +} diff --git a/packages/eslint-plugin/src/rules/prefer-optional-chain-utils/gatherLogicalOperands.ts b/packages/eslint-plugin/src/rules/prefer-optional-chain-utils/gatherLogicalOperands.ts index 4b90947da0ed..54c44f4edda9 100644 --- a/packages/eslint-plugin/src/rules/prefer-optional-chain-utils/gatherLogicalOperands.ts +++ b/packages/eslint-plugin/src/rules/prefer-optional-chain-utils/gatherLogicalOperands.ts @@ -90,10 +90,6 @@ function isValidFalseBooleanCheckType( } } - if (options.requireNullish === true) { - return types.some(t => isTypeFlagSet(t, NULLISH_FLAGS)); - } - let allowedFlags = NULLISH_FLAGS | ts.TypeFlags.Object; if (options.checkAny === true) { allowedFlags |= ts.TypeFlags.Any; diff --git a/packages/eslint-plugin/src/rules/prefer-optional-chain.ts b/packages/eslint-plugin/src/rules/prefer-optional-chain.ts index 605045b99fd2..18acdd72caf2 100644 --- a/packages/eslint-plugin/src/rules/prefer-optional-chain.ts +++ b/packages/eslint-plugin/src/rules/prefer-optional-chain.ts @@ -10,6 +10,7 @@ import { OperatorPrecedence, } from '../util'; import { analyzeChain } from './prefer-optional-chain-utils/analyzeChain'; +import { checkNullishAndReport } from './prefer-optional-chain-utils/checkNullishAndReport'; import type { ValidOperand } from './prefer-optional-chain-utils/gatherLogicalOperands'; import { gatherLogicalOperands, @@ -141,9 +142,9 @@ export default createRule< return leftPrecedence < OperatorPrecedence.LeftHandSide; } - context.report({ - node: parentNode, + checkNullishAndReport(context, parserServices, options, [leftNode], { messageId: 'preferOptionalChain', + node: parentNode, suggest: [ { messageId: 'optionalChainSuggest', diff --git a/packages/eslint-plugin/tests/docs-eslint-output-snapshots/prefer-optional-chain.shot b/packages/eslint-plugin/tests/docs-eslint-output-snapshots/prefer-optional-chain.shot index 36130171307f..980b3f8058df 100644 --- a/packages/eslint-plugin/tests/docs-eslint-output-snapshots/prefer-optional-chain.shot +++ b/packages/eslint-plugin/tests/docs-eslint-output-snapshots/prefer-optional-chain.shot @@ -202,6 +202,7 @@ Options: { "requireNullish": true } declare const thing1: string | null; thing1 && thing1.toString(); +~~~~~~~~~~~~~~~~~~~~~~~~~~~ Prefer using an optional chain expression instead, as it's more concise and easier to read. " `; diff --git a/packages/eslint-plugin/tests/rules/prefer-optional-chain/prefer-optional-chain.test.ts b/packages/eslint-plugin/tests/rules/prefer-optional-chain/prefer-optional-chain.test.ts index 99b6362132cf..55ab44f1a069 100644 --- a/packages/eslint-plugin/tests/rules/prefer-optional-chain/prefer-optional-chain.test.ts +++ b/packages/eslint-plugin/tests/rules/prefer-optional-chain/prefer-optional-chain.test.ts @@ -815,22 +815,64 @@ describe('hand-crafted cases', () => { declare const x: string; x && x.length; `, - options: [ - { - requireNullish: true, - }, - ], + options: [{ requireNullish: true }], + }, + { + code: ` + declare const foo: string; + foo && foo.toString(); + `, + options: [{ requireNullish: true }], }, { code: ` declare const x: string | number | boolean | object; x && x.toString(); `, - options: [ - { - requireNullish: true, - }, - ], + options: [{ requireNullish: true }], + }, + { + code: ` + declare const foo: { bar: string }; + foo && foo.bar && foo.bar.toString(); + `, + options: [{ requireNullish: true }], + }, + { + code: ` + declare const foo: string; + foo && foo.toString() && foo.toString(); + `, + options: [{ requireNullish: true }], + }, + { + code: ` + declare const foo: { bar: string }; + foo && foo.bar && foo.bar.toString() && foo.bar.toString(); + `, + options: [{ requireNullish: true }], + }, + { + code: ` + declare const foo1: { bar: string | null }; + foo1 && foo1.bar; + `, + options: [{ requireNullish: true }], + }, + { + code: ` + declare const foo: string; + (foo || {}).toString(); + `, + options: [{ requireNullish: true }], + }, + + { + code: ` + declare const foo: string | null; + (foo || 'a' || {}).toString(); + `, + options: [{ requireNullish: true }], }, { code: ` @@ -1895,6 +1937,80 @@ describe('hand-crafted cases', () => { ], }, + // requireNullish + { + code: ` + declare const thing1: string | null; + thing1 && thing1.toString(); + `, + options: [{ requireNullish: true }], + errors: [{ messageId: 'preferOptionalChain' }], + }, + { + code: ` + declare const thing1: string | null; + thing1 && thing1.toString() && true; + `, + options: [{ requireNullish: true }], + errors: [{ messageId: 'preferOptionalChain' }], + }, + { + code: ` + declare const foo: string | null; + foo && foo.toString() && foo.toString(); + `, + options: [{ requireNullish: true }], + errors: [{ messageId: 'preferOptionalChain' }], + }, + { + code: ` + declare const foo: { bar: string | null | undefined } | null | undefined; + foo && foo.bar && foo.bar.toString(); + `, + output: ` + declare const foo: { bar: string | null | undefined } | null | undefined; + foo?.bar?.toString(); + `, + options: [{ requireNullish: true }], + errors: [{ messageId: 'preferOptionalChain' }], + }, + { + code: ` + declare const foo: { bar: string | null | undefined } | null | undefined; + foo && foo.bar && foo.bar.toString() && foo.bar.toString(); + `, + output: ` + declare const foo: { bar: string | null | undefined } | null | undefined; + foo?.bar?.toString() && foo.bar.toString(); + `, + options: [{ requireNullish: true }], + errors: [{ messageId: 'preferOptionalChain' }], + }, + { + code: ` + declare const foo: string | null; + (foo || {}).toString(); + `, + options: [{ requireNullish: true }], + errors: [{ messageId: 'preferOptionalChain' }], + }, + { + code: ` + declare const foo: string; + (foo || undefined || {}).toString(); + `, + options: [{ requireNullish: true }], + errors: [{ messageId: 'preferOptionalChain' }], + }, + { + code: ` + declare const foo: string | null; + (foo || undefined || {}).toString(); + `, + options: [{ requireNullish: true }], + errors: [{ messageId: 'preferOptionalChain' }], + }, + // allowPotentiallyUnsafeFixesThatModifyTheReturnTypeIKnowWhatImDoing { code: `