diff --git a/packages/eslint-plugin/docs/rules/prefer-nullish-coalescing.mdx b/packages/eslint-plugin/docs/rules/prefer-nullish-coalescing.mdx index 73822ae7198d..8965b74c3245 100644 --- a/packages/eslint-plugin/docs/rules/prefer-nullish-coalescing.mdx +++ b/packages/eslint-plugin/docs/rules/prefer-nullish-coalescing.mdx @@ -172,6 +172,28 @@ foo ?? 'a string'; Also, if you would like to ignore all primitives types, you can set `ignorePrimitives: true`. It is equivalent to `ignorePrimitives: { string: true, number: true, bigint: true, boolean: true }`. +### `ignoreBooleanCoercion` + +Whether to ignore expressions that coerce a value into a boolean: `Boolean(...)`. + +Incorrect code for `ignoreBooleanCoercion: false`, and correct code for `ignoreBooleanCoercion: true`: + +```ts option='{ "ignoreBooleanCoercion": true }' showPlaygroundButton +let a: string | true | undefined; +let b: string | boolean | undefined; + +const x = Boolean(a || b); +``` + +Correct code for `ignoreBooleanCoercion: false`: + +```ts option='{ "ignoreBooleanCoercion": false }' showPlaygroundButton +let a: string | true | undefined; +let b: string | boolean | undefined; + +const x = Boolean(a ?? b); +``` + ### `allowRuleToRunWithoutStrictNullChecksIKnowWhatIAmDoing` {/* insert option description */} diff --git a/packages/eslint-plugin/src/rules/prefer-nullish-coalescing.ts b/packages/eslint-plugin/src/rules/prefer-nullish-coalescing.ts index 8e3f3db8e87f..9f5a6661cca6 100644 --- a/packages/eslint-plugin/src/rules/prefer-nullish-coalescing.ts +++ b/packages/eslint-plugin/src/rules/prefer-nullish-coalescing.ts @@ -21,6 +21,7 @@ import { export type Options = [ { allowRuleToRunWithoutStrictNullChecksIKnowWhatIAmDoing?: boolean; + ignoreBooleanCoercion?: boolean; ignoreConditionalTests?: boolean; ignoreMixedLogicalExpressions?: boolean; ignorePrimitives?: @@ -71,6 +72,11 @@ export default createRule({ description: 'Unless this is set to `true`, the rule will error on every file whose `tsconfig.json` does _not_ have the `strictNullChecks` compiler option (or `strict`) set to `true`.', }, + ignoreBooleanCoercion: { + type: 'boolean', + description: + 'Whether to ignore arguments to the `Boolean` constructor', + }, ignoreConditionalTests: { type: 'boolean', description: @@ -126,6 +132,7 @@ export default createRule({ defaultOptions: [ { allowRuleToRunWithoutStrictNullChecksIKnowWhatIAmDoing: false, + ignoreBooleanCoercion: false, ignoreConditionalTests: true, ignoreMixedLogicalExpressions: false, ignorePrimitives: { @@ -142,6 +149,7 @@ export default createRule({ [ { allowRuleToRunWithoutStrictNullChecksIKnowWhatIAmDoing, + ignoreBooleanCoercion, ignoreConditionalTests, ignoreMixedLogicalExpressions, ignorePrimitives, @@ -431,6 +439,13 @@ export default createRule({ 'LogicalExpression[operator = "||"]'( node: TSESTree.LogicalExpression, ): void { + if ( + ignoreBooleanCoercion === true && + isBooleanConstructorContext(node, context) + ) { + return; + } + checkAssignmentOrLogicalExpression(node, 'or', ''); }, }; @@ -438,39 +453,88 @@ export default createRule({ }); function isConditionalTest(node: TSESTree.Node): boolean { - const parents = new Set([node]); - let current = node.parent; - while (current) { - parents.add(current); + const parent = node.parent; + if (parent == null) { + return false; + } - if ( - (current.type === AST_NODE_TYPES.ConditionalExpression || - current.type === AST_NODE_TYPES.DoWhileStatement || - current.type === AST_NODE_TYPES.IfStatement || - current.type === AST_NODE_TYPES.ForStatement || - current.type === AST_NODE_TYPES.WhileStatement) && - parents.has(current.test) - ) { - return true; - } + if (parent.type === AST_NODE_TYPES.LogicalExpression) { + return isConditionalTest(parent); + } - if ( - [ - AST_NODE_TYPES.ArrowFunctionExpression, - AST_NODE_TYPES.FunctionExpression, - ].includes(current.type) - ) { - /** - * This is a weird situation like: - * `if (() => a || b) {}` - * `if (function () { return a || b }) {}` - */ - return false; - } + if ( + parent.type === AST_NODE_TYPES.ConditionalExpression && + (parent.consequent === node || parent.alternate === node) + ) { + return isConditionalTest(parent); + } + + if ( + parent.type === AST_NODE_TYPES.SequenceExpression && + parent.expressions.at(-1) === node + ) { + return isConditionalTest(parent); + } - current = current.parent; + if ( + (parent.type === AST_NODE_TYPES.ConditionalExpression || + parent.type === AST_NODE_TYPES.DoWhileStatement || + parent.type === AST_NODE_TYPES.IfStatement || + parent.type === AST_NODE_TYPES.ForStatement || + parent.type === AST_NODE_TYPES.WhileStatement) && + parent.test === node + ) { + return true; + } + + return false; +} + +function isBooleanConstructorContext( + node: TSESTree.Node, + context: Readonly>, +): boolean { + const parent = node.parent; + if (parent == null) { + return false; + } + + if (parent.type === AST_NODE_TYPES.LogicalExpression) { + return isBooleanConstructorContext(parent, context); + } + + if ( + parent.type === AST_NODE_TYPES.ConditionalExpression && + (parent.consequent === node || parent.alternate === node) + ) { + return isBooleanConstructorContext(parent, context); + } + + if ( + parent.type === AST_NODE_TYPES.SequenceExpression && + parent.expressions.at(-1) === node + ) { + return isBooleanConstructorContext(parent, context); } + return isBuiltInBooleanCall(parent, context); +} + +function isBuiltInBooleanCall( + node: TSESTree.Node, + context: Readonly>, +): boolean { + if ( + node.type === AST_NODE_TYPES.CallExpression && + node.callee.type === AST_NODE_TYPES.Identifier && + // eslint-disable-next-line @typescript-eslint/internal/prefer-ast-types-enum + node.callee.name === 'Boolean' && + node.arguments[0] + ) { + const scope = context.sourceCode.getScope(node); + const variable = scope.set.get(AST_TOKEN_TYPES.Boolean); + return variable == null || variable.defs.length === 0; + } return false; } diff --git a/packages/eslint-plugin/tests/docs-eslint-output-snapshots/prefer-nullish-coalescing.shot b/packages/eslint-plugin/tests/docs-eslint-output-snapshots/prefer-nullish-coalescing.shot index d2c8afaf5096..dd5126a91def 100644 --- a/packages/eslint-plugin/tests/docs-eslint-output-snapshots/prefer-nullish-coalescing.shot +++ b/packages/eslint-plugin/tests/docs-eslint-output-snapshots/prefer-nullish-coalescing.shot @@ -136,3 +136,23 @@ const foo: string | undefined = 'bar'; foo ?? 'a string'; " `; + +exports[`Validating rule docs prefer-nullish-coalescing.mdx code examples ESLint output 9`] = ` +"Options: { "ignoreBooleanCoercion": true } + +let a: string | true | undefined; +let b: string | boolean | undefined; + +const x = Boolean(a || b); +" +`; + +exports[`Validating rule docs prefer-nullish-coalescing.mdx code examples ESLint output 10`] = ` +"Options: { "ignoreBooleanCoercion": false } + +let a: string | true | undefined; +let b: string | boolean | undefined; + +const x = Boolean(a ?? b); +" +`; diff --git a/packages/eslint-plugin/tests/rules/prefer-nullish-coalescing.test.ts b/packages/eslint-plugin/tests/rules/prefer-nullish-coalescing.test.ts index d30d7e21b178..38e65b6c56ae 100644 --- a/packages/eslint-plugin/tests/rules/prefer-nullish-coalescing.test.ts +++ b/packages/eslint-plugin/tests/rules/prefer-nullish-coalescing.test.ts @@ -367,6 +367,222 @@ x || y; }, ], }, + { + code: ` +let a: string | true | undefined; +let b: string | boolean | undefined; + +const x = Boolean(a || b); + `, + options: [ + { + ignoreBooleanCoercion: true, + }, + ], + }, + { + code: ` +let a: string | boolean | undefined; +let b: string | boolean | undefined; +let c: string | boolean | undefined; + +const test = Boolean(a || b || c); + `, + options: [ + { + ignoreBooleanCoercion: true, + }, + ], + }, + { + code: ` +let a: string | boolean | undefined; +let b: string | boolean | undefined; +let c: string | boolean | undefined; + +const test = Boolean(a || (b && c)); + `, + options: [ + { + ignoreBooleanCoercion: true, + }, + ], + }, + { + code: ` +let a: string | boolean | undefined; +let b: string | boolean | undefined; +let c: string | boolean | undefined; + +const test = Boolean((a || b) ?? c); + `, + options: [ + { + ignoreBooleanCoercion: true, + }, + ], + }, + { + code: ` +let a: string | boolean | undefined; +let b: string | boolean | undefined; +let c: string | boolean | undefined; + +const test = Boolean(a ?? (b || c)); + `, + options: [ + { + ignoreBooleanCoercion: true, + }, + ], + }, + { + code: ` +let a: string | boolean | undefined; +let b: string | boolean | undefined; +let c: string | boolean | undefined; + +const test = Boolean(a ? b || c : 'fail'); + `, + options: [ + { + ignoreBooleanCoercion: true, + }, + ], + }, + { + code: ` +let a: string | boolean | undefined; +let b: string | boolean | undefined; +let c: string | boolean | undefined; + +const test = Boolean(a ? 'success' : b || c); + `, + options: [ + { + ignoreBooleanCoercion: true, + }, + ], + }, + { + code: ` +let a: string | boolean | undefined; +let b: string | boolean | undefined; +let c: string | boolean | undefined; + +const test = Boolean(((a = b), b || c)); + `, + options: [ + { + ignoreBooleanCoercion: true, + }, + ], + }, + { + code: ` +let a: string | boolean | undefined; +let b: string | boolean | undefined; +let c: string | boolean | undefined; + +if (a || b || c) { +} + `, + options: [ + { + ignoreConditionalTests: true, + }, + ], + }, + { + code: ` +let a: string | boolean | undefined; +let b: string | boolean | undefined; +let c: string | boolean | undefined; + +if (a || (b && c)) { +} + `, + options: [ + { + ignoreConditionalTests: true, + }, + ], + }, + { + code: ` +let a: string | boolean | undefined; +let b: string | boolean | undefined; +let c: string | boolean | undefined; + +if ((a || b) ?? c) { +} + `, + options: [ + { + ignoreConditionalTests: true, + }, + ], + }, + { + code: ` +let a: string | boolean | undefined; +let b: string | boolean | undefined; +let c: string | boolean | undefined; + +if (a ?? (b || c)) { +} + `, + options: [ + { + ignoreConditionalTests: true, + }, + ], + }, + { + code: ` +let a: string | boolean | undefined; +let b: string | boolean | undefined; +let c: string | boolean | undefined; + +if (a ? b || c : 'fail') { +} + `, + options: [ + { + ignoreConditionalTests: true, + }, + ], + }, + { + code: ` +let a: string | boolean | undefined; +let b: string | boolean | undefined; +let c: string | boolean | undefined; + +if (a ? 'success' : b || c) { +} + `, + options: [ + { + ignoreConditionalTests: true, + }, + ], + }, + { + code: ` +let a: string | boolean | undefined; +let b: string | boolean | undefined; +let c: string | boolean | undefined; + +if (((a = b), b || c)) { +} + `, + options: [ + { + ignoreConditionalTests: true, + }, + ], + }, ], invalid: [ ...nullishTypeTest((nullish, type, equals) => ({ @@ -1835,5 +2051,224 @@ x ?? y; ], output: null, }, + { + code: ` +let a: string | true | undefined; +let b: string | boolean | undefined; +let c: boolean | undefined; + +const x = Boolean(a || b); + `, + errors: [ + { + messageId: 'preferNullishOverOr', + suggestions: [ + { + messageId: 'suggestNullish', + output: ` +let a: string | true | undefined; +let b: string | boolean | undefined; +let c: boolean | undefined; + +const x = Boolean(a ?? b); + `, + }, + ], + }, + ], + options: [ + { + ignoreBooleanCoercion: false, + }, + ], + }, + { + code: ` +let a: string | true | undefined; +let b: string | boolean | undefined; + +const x = String(a || b); + `, + errors: [ + { + messageId: 'preferNullishOverOr', + suggestions: [ + { + messageId: 'suggestNullish', + output: ` +let a: string | true | undefined; +let b: string | boolean | undefined; + +const x = String(a ?? b); + `, + }, + ], + }, + ], + options: [ + { + ignoreBooleanCoercion: true, + }, + ], + }, + { + code: ` +let a: string | true | undefined; +let b: string | boolean | undefined; + +const x = Boolean(() => a || b); + `, + errors: [ + { + messageId: 'preferNullishOverOr', + suggestions: [ + { + messageId: 'suggestNullish', + output: ` +let a: string | true | undefined; +let b: string | boolean | undefined; + +const x = Boolean(() => a ?? b); + `, + }, + ], + }, + ], + options: [ + { + ignoreBooleanCoercion: true, + }, + ], + }, + { + code: ` +let a: string | true | undefined; +let b: string | boolean | undefined; + +const x = Boolean(function weird() { + return a || b; +}); + `, + errors: [ + { + messageId: 'preferNullishOverOr', + suggestions: [ + { + messageId: 'suggestNullish', + output: ` +let a: string | true | undefined; +let b: string | boolean | undefined; + +const x = Boolean(function weird() { + return a ?? b; +}); + `, + }, + ], + }, + ], + options: [ + { + ignoreBooleanCoercion: true, + }, + ], + }, + { + code: ` +let a: string | true | undefined; +let b: string | boolean | undefined; + +declare function f(x: unknown): unknown; + +const x = Boolean(f(a || b)); + `, + errors: [ + { + messageId: 'preferNullishOverOr', + suggestions: [ + { + messageId: 'suggestNullish', + output: ` +let a: string | true | undefined; +let b: string | boolean | undefined; + +declare function f(x: unknown): unknown; + +const x = Boolean(f(a ?? b)); + `, + }, + ], + }, + ], + options: [ + { + ignoreBooleanCoercion: true, + }, + ], + }, + { + code: ` +let a: string | true | undefined; +let b: string | boolean | undefined; + +const x = Boolean(1 + (a || b)); + `, + errors: [ + { + messageId: 'preferNullishOverOr', + suggestions: [ + { + messageId: 'suggestNullish', + output: ` +let a: string | true | undefined; +let b: string | boolean | undefined; + +const x = Boolean(1 + (a ?? b)); + `, + }, + ], + }, + ], + options: [ + { + ignoreBooleanCoercion: true, + }, + ], + }, + { + code: ` +let a: string | true | undefined; +let b: string | boolean | undefined; + +declare function f(x: unknown): unknown; + +if (f(a || b)) { +} + `, + errors: [ + { + messageId: 'preferNullishOverOr', + suggestions: [ + { + messageId: 'suggestNullish', + output: ` +let a: string | true | undefined; +let b: string | boolean | undefined; + +declare function f(x: unknown): unknown; + +if (f(a ?? b)) { +} + `, + }, + ], + }, + ], + options: [ + { + ignoreBooleanCoercion: true, + }, + ], + }, ], }); diff --git a/packages/eslint-plugin/tests/schema-snapshots/prefer-nullish-coalescing.shot b/packages/eslint-plugin/tests/schema-snapshots/prefer-nullish-coalescing.shot index 58a4820086c3..ea441036f3a0 100644 --- a/packages/eslint-plugin/tests/schema-snapshots/prefer-nullish-coalescing.shot +++ b/packages/eslint-plugin/tests/schema-snapshots/prefer-nullish-coalescing.shot @@ -12,6 +12,10 @@ exports[`Rule schemas should be convertible to TS types for documentation purpos "description": "Unless this is set to \`true\`, the rule will error on every file whose \`tsconfig.json\` does _not_ have the \`strictNullChecks\` compiler option (or \`strict\`) set to \`true\`.", "type": "boolean" }, + "ignoreBooleanCoercion": { + "description": "Whether to ignore arguments to the \`Boolean\` constructor", + "type": "boolean" + }, "ignoreConditionalTests": { "description": "Whether to ignore cases that are located within a conditional test.", "type": "boolean" @@ -68,6 +72,8 @@ type Options = [ { /** Unless this is set to \`true\`, the rule will error on every file whose \`tsconfig.json\` does _not_ have the \`strictNullChecks\` compiler option (or \`strict\`) set to \`true\`. */ allowRuleToRunWithoutStrictNullChecksIKnowWhatIAmDoing?: boolean; + /** Whether to ignore arguments to the \`Boolean\` constructor */ + ignoreBooleanCoercion?: boolean; /** Whether to ignore cases that are located within a conditional test. */ ignoreConditionalTests?: boolean; /** Whether to ignore any logical or expressions that are part of a mixed logical expression (with \`&&\`). */