From c3e212e94d052ac448b2d11d03ac4c9729fba91b Mon Sep 17 00:00:00 2001 From: Kirk Waiblinger Date: Tue, 17 Sep 2024 20:35:43 -0600 Subject: [PATCH 01/14] [no-unnecessary-condition]: add checkTruthinessAssertions --- eslint.config.mjs | 2 +- .../docs/rules/no-unnecessary-condition.mdx | 22 +++ .../src/rules/no-unnecessary-condition.ts | 16 +++ .../src/rules/strict-boolean-expressions.ts | 125 +---------------- .../src/util/assertionFunctionUtils.ts | 132 ++++++++++++++++++ .../rules/no-unnecessary-condition.test.ts | 40 ++++++ .../tests/lib/semanticInfo.test.ts | 7 + 7 files changed, 220 insertions(+), 124 deletions(-) create mode 100644 packages/eslint-plugin/src/util/assertionFunctionUtils.ts diff --git a/eslint.config.mjs b/eslint.config.mjs index c87098636b74..a0f6ac7f4454 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -129,7 +129,7 @@ export default tseslint.config( 'no-constant-condition': 'off', '@typescript-eslint/no-unnecessary-condition': [ 'error', - { allowConstantLoopConditions: true }, + { allowConstantLoopConditions: true, checkTruthinessAssertions: true }, ], '@typescript-eslint/no-unnecessary-type-parameters': 'error', '@typescript-eslint/no-unused-expressions': 'error', diff --git a/packages/eslint-plugin/docs/rules/no-unnecessary-condition.mdx b/packages/eslint-plugin/docs/rules/no-unnecessary-condition.mdx index ec5f234ac981..a2cd18050232 100644 --- a/packages/eslint-plugin/docs/rules/no-unnecessary-condition.mdx +++ b/packages/eslint-plugin/docs/rules/no-unnecessary-condition.mdx @@ -90,6 +90,28 @@ for (; true; ) {} do {} while (true); ``` +### `checkTruthinessAssertions` + +Example of additional incorrect code with `{ checkTruthinessAssertions: true }`: + +```ts option='{ "checkTruthinessAssertions": true }' showPlaygroundButton +function assert(condition: unknown): asserts condition { + if (!condition) { + throw new Error('Condition is falsy'); + } +} + +assert(false); // Unnecessary; condition is always falsy. + +const neverNull = {}; +assert(neverNull); // Unnecessary; condition is always truthy. +``` + +Whether this option makes sense for your project may vary. +Some projects may intentionally use assertion functions to ensure that runtime values do indeed match the types according to TypeScript, especially in test code. +Often, it makes sense to use eslint-disable comments in these cases, with a comment indicating why the condition should be checked at runtime, despite appearing unnecessary. +However, in some contexts, it may be more appropriate to keep this option disabled entirely. + ### `allowRuleToRunWithoutStrictNullChecksIKnowWhatIAmDoing` :::danger Deprecated diff --git a/packages/eslint-plugin/src/rules/no-unnecessary-condition.ts b/packages/eslint-plugin/src/rules/no-unnecessary-condition.ts index 035ba000f33d..58371535d8ce 100644 --- a/packages/eslint-plugin/src/rules/no-unnecessary-condition.ts +++ b/packages/eslint-plugin/src/rules/no-unnecessary-condition.ts @@ -18,6 +18,7 @@ import { nullThrows, NullThrowsReasons, } from '../util'; +import { findAssertedArgument } from '../util/assertionFunctionUtils'; // Truthiness utilities // #region @@ -71,6 +72,7 @@ export type Options = [ { allowConstantLoopConditions?: boolean; allowRuleToRunWithoutStrictNullChecksIKnowWhatIAmDoing?: boolean; + checkTruthinessAssertions?: boolean; }, ]; @@ -111,6 +113,11 @@ export default createRule({ 'Whether to not error when running with a tsconfig that has strictNullChecks turned.', type: 'boolean', }, + checkTruthinessAssertions: { + description: + 'Whether to check the assertedof a truthiness assertion function as a conditional context', + type: 'boolean', + }, }, additionalProperties: false, }, @@ -141,6 +148,7 @@ export default createRule({ { allowConstantLoopConditions: false, allowRuleToRunWithoutStrictNullChecksIKnowWhatIAmDoing: false, + checkTruthinessAssertions: false, }, ], create( @@ -149,6 +157,7 @@ export default createRule({ { allowConstantLoopConditions, allowRuleToRunWithoutStrictNullChecksIKnowWhatIAmDoing, + checkTruthinessAssertions, }, ], ) { @@ -463,6 +472,13 @@ export default createRule({ } function checkCallExpression(node: TSESTree.CallExpression): void { + if (checkTruthinessAssertions) { + const assertedArgument = findAssertedArgument(services, node); + if (assertedArgument != null) { + checkNode(assertedArgument); + } + } + // If this is something like arr.filter(x => /*condition*/), check `condition` if ( isArrayMethodCallWithPredicate(context, services, node) && diff --git a/packages/eslint-plugin/src/rules/strict-boolean-expressions.ts b/packages/eslint-plugin/src/rules/strict-boolean-expressions.ts index 7bd708ac6f9c..48b121a0512f 100644 --- a/packages/eslint-plugin/src/rules/strict-boolean-expressions.ts +++ b/packages/eslint-plugin/src/rules/strict-boolean-expressions.ts @@ -13,6 +13,7 @@ import { getWrappingFixer, isTypeArrayTypeOrUnionOfArrayTypes, } from '../util'; +import { findAssertedArgument } from '../util/assertionFunctionUtils'; export type Options = [ { @@ -267,134 +268,12 @@ export default createRule({ } function traverseCallExpression(node: TSESTree.CallExpression): void { - const assertedArgument = findAssertedArgument(node); + const assertedArgument = findAssertedArgument(services, node); if (assertedArgument != null) { traverseNode(assertedArgument, true); } } - /** - * Inspect a call expression to see if it's a call to an assertion function. - * If it is, return the node of the argument that is asserted. - */ - function findAssertedArgument( - node: TSESTree.CallExpression, - ): TSESTree.Expression | undefined { - // If the call looks like `assert(expr1, expr2, ...c, d, e, f)`, then we can - // only care if `expr1` or `expr2` is asserted, since anything that happens - // within or after a spread argument is out of scope to reason about. - const checkableArguments: TSESTree.Expression[] = []; - for (const argument of node.arguments) { - if (argument.type === AST_NODE_TYPES.SpreadElement) { - break; - } - - checkableArguments.push(argument); - } - - // nothing to do - if (checkableArguments.length === 0) { - return undefined; - } - - // Game plan: we're going to check the type of the callee. If it has call - // signatures and they _ALL_ agree that they assert on a parameter at the - // _SAME_ position, we'll consider the argument in that position to be an - // asserted argument. - const calleeType = getConstrainedTypeAtLocation(services, node.callee); - const callSignatures = tsutils.getCallSignaturesOfType(calleeType); - - let assertedParameterIndex: number | undefined = undefined; - for (const signature of callSignatures) { - const declaration = signature.getDeclaration(); - const returnTypeAnnotation = declaration.type; - - // Be sure we're dealing with a truthiness assertion function. - if ( - !( - returnTypeAnnotation != null && - ts.isTypePredicateNode(returnTypeAnnotation) && - // This eliminates things like `x is string` and `asserts x is T` - // leaving us with just the `asserts x` cases. - returnTypeAnnotation.type == null && - // I think this is redundant but, still, it needs to be true - returnTypeAnnotation.assertsModifier != null - ) - ) { - return undefined; - } - - const assertionTarget = returnTypeAnnotation.parameterName; - if (assertionTarget.kind !== ts.SyntaxKind.Identifier) { - // This can happen when asserting on `this`. Ignore! - return undefined; - } - - // If the first parameter is `this`, skip it, so that our index matches - // the index of the argument at the call site. - const firstParameter = declaration.parameters.at(0); - const nonThisParameters = - firstParameter?.name.kind === ts.SyntaxKind.Identifier && - firstParameter.name.text === 'this' - ? declaration.parameters.slice(1) - : declaration.parameters; - - // Don't bother inspecting parameters past the number of - // arguments we have at the call site. - const checkableNonThisParameters = nonThisParameters.slice( - 0, - checkableArguments.length, - ); - - let assertedParameterIndexForThisSignature: number | undefined; - for (const [index, parameter] of checkableNonThisParameters.entries()) { - if (parameter.dotDotDotToken != null) { - // Cannot assert a rest parameter, and can't have a rest parameter - // before the asserted parameter. It's not only a TS error, it's - // not something we can logically make sense of, so give up here. - return undefined; - } - - if (parameter.name.kind !== ts.SyntaxKind.Identifier) { - // Only identifiers are valid for assertion targets, so skip over - // anything like `{ destructuring: parameter }: T` - continue; - } - - // we've found a match between the "target"s in - // `function asserts(target: T): asserts target;` - if (parameter.name.text === assertionTarget.text) { - assertedParameterIndexForThisSignature = index; - break; - } - } - - if (assertedParameterIndexForThisSignature == null) { - // Didn't find an assertion target in this signature that could match - // the call site. - return undefined; - } - - if ( - assertedParameterIndex != null && - assertedParameterIndex !== assertedParameterIndexForThisSignature - ) { - // The asserted parameter we found for this signature didn't match - // previous signatures. - return undefined; - } - - assertedParameterIndex = assertedParameterIndexForThisSignature; - } - - // Didn't find a unique assertion index. - if (assertedParameterIndex == null) { - return undefined; - } - - return checkableArguments[assertedParameterIndex]; - } - /** * Inspects any node. * diff --git a/packages/eslint-plugin/src/util/assertionFunctionUtils.ts b/packages/eslint-plugin/src/util/assertionFunctionUtils.ts new file mode 100644 index 000000000000..a4c19f626e68 --- /dev/null +++ b/packages/eslint-plugin/src/util/assertionFunctionUtils.ts @@ -0,0 +1,132 @@ +import type { + ParserServicesWithTypeInformation, + TSESTree, +} from '@typescript-eslint/utils'; +import { AST_NODE_TYPES } from '@typescript-eslint/utils'; +import * as tsutils from 'ts-api-utils'; +import * as ts from 'typescript'; + +import { getConstrainedTypeAtLocation } from './index'; + +/** + * Inspect a call expression to see if it's a call to an assertion function. + * If it is, return the node of the argument that is asserted. + */ +export function findAssertedArgument( + services: ParserServicesWithTypeInformation, + node: TSESTree.CallExpression, +): TSESTree.Expression | undefined { + // If the call looks like `assert(expr1, expr2, ...c, d, e, f)`, then we can + // only care if `expr1` or `expr2` is asserted, since anything that happens + // within or after a spread argument is out of scope to reason about. + const checkableArguments: TSESTree.Expression[] = []; + for (const argument of node.arguments) { + if (argument.type === AST_NODE_TYPES.SpreadElement) { + break; + } + + checkableArguments.push(argument); + } + + // nothing to do + if (checkableArguments.length === 0) { + return undefined; + } + + // Game plan: we're going to check the type of the callee. If it has call + // signatures and they _ALL_ agree that they assert on a parameter at the + // _SAME_ position, we'll consider the argument in that position to be an + // asserted argument. + const calleeType = getConstrainedTypeAtLocation(services, node.callee); + const callSignatures = tsutils.getCallSignaturesOfType(calleeType); + + let assertedParameterIndex: number | undefined = undefined; + for (const signature of callSignatures) { + const declaration = signature.getDeclaration(); + const returnTypeAnnotation = declaration.type; + + // Be sure we're dealing with a truthiness assertion function. + if ( + !( + returnTypeAnnotation != null && + ts.isTypePredicateNode(returnTypeAnnotation) && + // This eliminates things like `x is string` and `asserts x is T` + // leaving us with just the `asserts x` cases. + returnTypeAnnotation.type == null && + // I think this is redundant but, still, it needs to be true + returnTypeAnnotation.assertsModifier != null + ) + ) { + return undefined; + } + + const assertionTarget = returnTypeAnnotation.parameterName; + if (assertionTarget.kind !== ts.SyntaxKind.Identifier) { + // This can happen when asserting on `this`. Ignore! + return undefined; + } + + // If the first parameter is `this`, skip it, so that our index matches + // the index of the argument at the call site. + const firstParameter = declaration.parameters.at(0); + const nonThisParameters = + firstParameter?.name.kind === ts.SyntaxKind.Identifier && + firstParameter.name.text === 'this' + ? declaration.parameters.slice(1) + : declaration.parameters; + + // Don't bother inspecting parameters past the number of + // arguments we have at the call site. + const checkableNonThisParameters = nonThisParameters.slice( + 0, + checkableArguments.length, + ); + + let assertedParameterIndexForThisSignature: number | undefined; + for (const [index, parameter] of checkableNonThisParameters.entries()) { + if (parameter.dotDotDotToken != null) { + // Cannot assert a rest parameter, and can't have a rest parameter + // before the asserted parameter. It's not only a TS error, it's + // not something we can logically make sense of, so give up here. + return undefined; + } + + if (parameter.name.kind !== ts.SyntaxKind.Identifier) { + // Only identifiers are valid for assertion targets, so skip over + // anything like `{ destructuring: parameter }: T` + continue; + } + + // we've found a match between the "target"s in + // `function asserts(target: T): asserts target;` + if (parameter.name.text === assertionTarget.text) { + assertedParameterIndexForThisSignature = index; + break; + } + } + + if (assertedParameterIndexForThisSignature == null) { + // Didn't find an assertion target in this signature that could match + // the call site. + return undefined; + } + + if ( + assertedParameterIndex != null && + assertedParameterIndex !== assertedParameterIndexForThisSignature + ) { + // The asserted parameter we found for this signature didn't match + // previous signatures. + return undefined; + } + + assertedParameterIndex = assertedParameterIndexForThisSignature; + } + + // Didn't find a unique assertion index. + if (assertedParameterIndex == null) { + return undefined; + } + + return checkableArguments[assertedParameterIndex]; +} diff --git a/packages/eslint-plugin/tests/rules/no-unnecessary-condition.test.ts b/packages/eslint-plugin/tests/rules/no-unnecessary-condition.test.ts index 9fbd0eb7ac6f..e74732b0cd4c 100644 --- a/packages/eslint-plugin/tests/rules/no-unnecessary-condition.test.ts +++ b/packages/eslint-plugin/tests/rules/no-unnecessary-condition.test.ts @@ -894,6 +894,22 @@ class ConsistentRand { } `, }, + { + code: ` +declare function assert(x: unknown): asserts x; + +assert(Math.random() > 0.5); + `, + options: [{ checkTruthinessAssertions: true }], + }, + { + // should not report because option is disabled. + code: ` +declare function assert(x: unknown): asserts x; +assert(true); + `, + options: [{ checkTruthinessAssertions: false }], + }, ], invalid: [ // Ensure that it's checking in all the right places @@ -2305,6 +2321,30 @@ foo?.['bar']?.().toExponential(); `, errors: [ruleError(3, 13, 'alwaysTruthy')], }, + { + code: ` +declare function assert(x: unknown): asserts x; +assert(true); + `, + errors: [ + { + messageId: 'alwaysTruthy', + }, + ], + options: [{ checkTruthinessAssertions: true }], + }, + { + code: ` +declare function assert(x: unknown): asserts x; +assert(false); + `, + errors: [ + { + messageId: 'alwaysFalsy', + }, + ], + options: [{ checkTruthinessAssertions: true }], + }, // "branded" types unnecessaryConditionTest('"" & {}', 'alwaysFalsy'), diff --git a/packages/typescript-estree/tests/lib/semanticInfo.test.ts b/packages/typescript-estree/tests/lib/semanticInfo.test.ts index c191fc65c356..430e46d0ea06 100644 --- a/packages/typescript-estree/tests/lib/semanticInfo.test.ts +++ b/packages/typescript-estree/tests/lib/semanticInfo.test.ts @@ -172,6 +172,7 @@ describe('semanticInfo', () => { ).declarations[0].init!; const tsBinaryExpression = parseResult.services.esTreeNodeToTSNodeMap.get(binaryExpression); + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- intentionally asserting that the runtime value matches the types. expectToBeDefined(tsBinaryExpression); expect(tsBinaryExpression.kind).toEqual(ts.SyntaxKind.BinaryExpression); @@ -181,6 +182,7 @@ describe('semanticInfo', () => { ).key; const tsComputedPropertyString = parseResult.services.esTreeNodeToTSNodeMap.get(computedPropertyString); + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- intentionally asserting that the runtime value matches the types. expectToBeDefined(tsComputedPropertyString); expect(tsComputedPropertyString.kind).toEqual(ts.SyntaxKind.StringLiteral); }); @@ -210,6 +212,7 @@ describe('semanticInfo', () => { expectToHaveParserServices(parseResult.services); const tsArrayBoundName = parseResult.services.esTreeNodeToTSNodeMap.get(arrayBoundName); + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- intentionally asserting that the runtime value matches the types. expectToBeDefined(tsArrayBoundName); checkNumberArrayType(checker, tsArrayBoundName); @@ -235,6 +238,7 @@ describe('semanticInfo', () => { const tsBoundName = parseResult.services.esTreeNodeToTSNodeMap.get(boundName); + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- intentionally asserting that the runtime value matches the types. expectToBeDefined(tsBoundName); expect(tsBoundName).toBeDefined(); @@ -420,6 +424,7 @@ function testIsolatedFile( // get type checker expectToHaveParserServices(parseResult.services); const checker = parseResult.services.program.getTypeChecker(); + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- intentionally asserting that the runtime value matches the types. expectToBeDefined(checker); // get number node (ast shape validated by snapshot) @@ -431,6 +436,7 @@ function testIsolatedFile( // get corresponding TS node const tsArrayMember = parseResult.services.esTreeNodeToTSNodeMap.get(arrayMember); + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- intentionally asserting that the runtime value matches the types. expectToBeDefined(tsArrayMember); expect(tsArrayMember.kind).toBe(ts.SyntaxKind.NumericLiteral); expect((tsArrayMember as ts.NumericLiteral).text).toBe('3'); @@ -451,6 +457,7 @@ function testIsolatedFile( const boundName = declaration.id as TSESTree.Identifier; expect(boundName.name).toBe('x'); const tsBoundName = parseResult.services.esTreeNodeToTSNodeMap.get(boundName); + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- intentionally asserting that the runtime value matches the types. expectToBeDefined(tsBoundName); checkNumberArrayType(checker, tsBoundName); expect(parseResult.services.tsNodeToESTreeNodeMap.get(tsBoundName)).toBe( From 337e9b53cf0ce2da670bb0b081ec52278b7bc252 Mon Sep 17 00:00:00 2001 From: Kirk Waiblinger Date: Tue, 17 Sep 2024 21:03:24 -0600 Subject: [PATCH 02/14] test -u --- .../src/rules/no-unnecessary-condition.ts | 2 +- .../no-unnecessary-condition.shot | 18 ++++++++++++++++++ .../no-unnecessary-condition.shot | 6 ++++++ 3 files changed, 25 insertions(+), 1 deletion(-) diff --git a/packages/eslint-plugin/src/rules/no-unnecessary-condition.ts b/packages/eslint-plugin/src/rules/no-unnecessary-condition.ts index 58371535d8ce..4eec8d040e6d 100644 --- a/packages/eslint-plugin/src/rules/no-unnecessary-condition.ts +++ b/packages/eslint-plugin/src/rules/no-unnecessary-condition.ts @@ -115,7 +115,7 @@ export default createRule({ }, checkTruthinessAssertions: { description: - 'Whether to check the assertedof a truthiness assertion function as a conditional context', + 'Whether to check the asserted argument of a truthiness assertion function as a conditional context', type: 'boolean', }, }, diff --git a/packages/eslint-plugin/tests/docs-eslint-output-snapshots/no-unnecessary-condition.shot b/packages/eslint-plugin/tests/docs-eslint-output-snapshots/no-unnecessary-condition.shot index 508ffbe028f6..a85290ca3d65 100644 --- a/packages/eslint-plugin/tests/docs-eslint-output-snapshots/no-unnecessary-condition.shot +++ b/packages/eslint-plugin/tests/docs-eslint-output-snapshots/no-unnecessary-condition.shot @@ -66,3 +66,21 @@ for (; true; ) {} do {} while (true); " `; + +exports[`Validating rule docs no-unnecessary-condition.mdx code examples ESLint output 4`] = ` +"Options: { "checkTruthinessAssertions": true } + +function assert(condition: unknown): asserts condition { + if (!condition) { + throw new Error('Condition is falsy'); + } +} + +assert(false); // Unnecessary; condition is always falsy. + ~~~~~ Unnecessary conditional, value is always falsy. + +const neverNull = {}; +assert(neverNull); // Unnecessary; condition is always truthy. + ~~~~~~~~~ Unnecessary conditional, value is always truthy. +" +`; diff --git a/packages/eslint-plugin/tests/schema-snapshots/no-unnecessary-condition.shot b/packages/eslint-plugin/tests/schema-snapshots/no-unnecessary-condition.shot index 4f7fef6518ec..645b0605a803 100644 --- a/packages/eslint-plugin/tests/schema-snapshots/no-unnecessary-condition.shot +++ b/packages/eslint-plugin/tests/schema-snapshots/no-unnecessary-condition.shot @@ -15,6 +15,10 @@ exports[`Rule schemas should be convertible to TS types for documentation purpos "allowRuleToRunWithoutStrictNullChecksIKnowWhatIAmDoing": { "description": "Whether to not error when running with a tsconfig that has strictNullChecks turned.", "type": "boolean" + }, + "checkTruthinessAssertions": { + "description": "Whether to check the asserted argument of a truthiness assertion function as a conditional context", + "type": "boolean" } }, "type": "object" @@ -30,6 +34,8 @@ type Options = [ allowConstantLoopConditions?: boolean; /** Whether to not error when running with a tsconfig that has strictNullChecks turned. */ allowRuleToRunWithoutStrictNullChecksIKnowWhatIAmDoing?: boolean; + /** Whether to check the asserted argument of a truthiness assertion function as a conditional context */ + checkTruthinessAssertions?: boolean; }, ]; " From d72bbd3d354221a0fc2c109b436b647c088b27ce Mon Sep 17 00:00:00 2001 From: Kirk Waiblinger Date: Wed, 18 Sep 2024 09:04:46 -0600 Subject: [PATCH 03/14] change internal reports --- .../tests/lib/semanticInfo.test.ts | 20 ++++++------------- 1 file changed, 6 insertions(+), 14 deletions(-) diff --git a/packages/typescript-estree/tests/lib/semanticInfo.test.ts b/packages/typescript-estree/tests/lib/semanticInfo.test.ts index 430e46d0ea06..a5378e52bc9a 100644 --- a/packages/typescript-estree/tests/lib/semanticInfo.test.ts +++ b/packages/typescript-estree/tests/lib/semanticInfo.test.ts @@ -172,8 +172,7 @@ describe('semanticInfo', () => { ).declarations[0].init!; const tsBinaryExpression = parseResult.services.esTreeNodeToTSNodeMap.get(binaryExpression); - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- intentionally asserting that the runtime value matches the types. - expectToBeDefined(tsBinaryExpression); + expect(tsBinaryExpression).toBeDefined(); expect(tsBinaryExpression.kind).toEqual(ts.SyntaxKind.BinaryExpression); const computedPropertyString = ( @@ -182,8 +181,7 @@ describe('semanticInfo', () => { ).key; const tsComputedPropertyString = parseResult.services.esTreeNodeToTSNodeMap.get(computedPropertyString); - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- intentionally asserting that the runtime value matches the types. - expectToBeDefined(tsComputedPropertyString); + expect(tsComputedPropertyString).toBeDefined(); expect(tsComputedPropertyString.kind).toEqual(ts.SyntaxKind.StringLiteral); }); @@ -212,8 +210,7 @@ describe('semanticInfo', () => { expectToHaveParserServices(parseResult.services); const tsArrayBoundName = parseResult.services.esTreeNodeToTSNodeMap.get(arrayBoundName); - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- intentionally asserting that the runtime value matches the types. - expectToBeDefined(tsArrayBoundName); + expect(tsArrayBoundName).toBeDefined(); checkNumberArrayType(checker, tsArrayBoundName); expect( @@ -238,8 +235,6 @@ describe('semanticInfo', () => { const tsBoundName = parseResult.services.esTreeNodeToTSNodeMap.get(boundName); - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- intentionally asserting that the runtime value matches the types. - expectToBeDefined(tsBoundName); expect(tsBoundName).toBeDefined(); expect(parseResult.services.tsNodeToESTreeNodeMap.get(tsBoundName)).toBe( @@ -424,8 +419,7 @@ function testIsolatedFile( // get type checker expectToHaveParserServices(parseResult.services); const checker = parseResult.services.program.getTypeChecker(); - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- intentionally asserting that the runtime value matches the types. - expectToBeDefined(checker); + expect(checker).toBeDefined(); // get number node (ast shape validated by snapshot) const declaration = (parseResult.ast.body[0] as TSESTree.VariableDeclaration) @@ -436,8 +430,7 @@ function testIsolatedFile( // get corresponding TS node const tsArrayMember = parseResult.services.esTreeNodeToTSNodeMap.get(arrayMember); - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- intentionally asserting that the runtime value matches the types. - expectToBeDefined(tsArrayMember); + expect(tsArrayMember).toBeDefined(); expect(tsArrayMember.kind).toBe(ts.SyntaxKind.NumericLiteral); expect((tsArrayMember as ts.NumericLiteral).text).toBe('3'); @@ -457,8 +450,7 @@ function testIsolatedFile( const boundName = declaration.id as TSESTree.Identifier; expect(boundName.name).toBe('x'); const tsBoundName = parseResult.services.esTreeNodeToTSNodeMap.get(boundName); - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- intentionally asserting that the runtime value matches the types. - expectToBeDefined(tsBoundName); + expect(tsBoundName).toBeDefined(); checkNumberArrayType(checker, tsBoundName); expect(parseResult.services.tsNodeToESTreeNodeMap.get(tsBoundName)).toBe( boundName, From f86e85e6dc5015442882f6367387e5009c080b8d Mon Sep 17 00:00:00 2001 From: Kirk Waiblinger Date: Wed, 18 Sep 2024 15:14:16 -0600 Subject: [PATCH 04/14] added some coverage --- .../rules/no-unnecessary-condition.test.ts | 80 +++++++++++++++++++ 1 file changed, 80 insertions(+) diff --git a/packages/eslint-plugin/tests/rules/no-unnecessary-condition.test.ts b/packages/eslint-plugin/tests/rules/no-unnecessary-condition.test.ts index e74732b0cd4c..2cd33aab3531 100644 --- a/packages/eslint-plugin/tests/rules/no-unnecessary-condition.test.ts +++ b/packages/eslint-plugin/tests/rules/no-unnecessary-condition.test.ts @@ -902,6 +902,14 @@ assert(Math.random() > 0.5); `, options: [{ checkTruthinessAssertions: true }], }, + { + code: ` +declare function assert(x: unknown, y: unknown): asserts x; + +assert(Math.random() > 0.5, true); + `, + options: [{ checkTruthinessAssertions: true }], + }, { // should not report because option is disabled. code: ` @@ -910,7 +918,47 @@ assert(true); `, options: [{ checkTruthinessAssertions: false }], }, + { + // could be argued that this should report since `thisAsserter` is truthy. + code: ` +class ThisAsserter { + assertThis(this: unknown, arg2: unknown): asserts this {} +} + +const thisAsserter: ThisAsserter = new ThisAsserter(); +thisAsserter.assertThis(true); + `, + options: [{ checkTruthinessAssertions: true }], + }, + { + // could be argued that this should report since `thisAsserter` is truthy. + code: ` +class ThisAsserter { + assertThis(this: unknown, arg2: unknown): asserts this {} +} + +const thisAsserter: ThisAsserter = new ThisAsserter(); +thisAsserter.assertThis(Math.random()); + `, + options: [{ checkTruthinessAssertions: true }], + }, + { + code: ` +declare function assert(x: unknown): asserts x; +assert(...[]); + `, + options: [{ checkTruthinessAssertions: true }], + }, + { + // ok to report if we start unpacking spread params one day. + code: ` +declare function assert(x: unknown): asserts x; +assert(...[], {}); + `, + options: [{ checkTruthinessAssertions: true }], + }, ], + invalid: [ // Ensure that it's checking in all the right places { @@ -2328,6 +2376,7 @@ assert(true); `, errors: [ { + line: 3, messageId: 'alwaysTruthy', }, ], @@ -2340,11 +2389,42 @@ assert(false); `, errors: [ { + line: 3, + column: 8, messageId: 'alwaysFalsy', }, ], options: [{ checkTruthinessAssertions: true }], }, + { + code: ` +declare function assert(x: unknown, y: unknown): asserts x; + +assert(true, Math.random() > 0.5); + `, + options: [{ checkTruthinessAssertions: true }], + errors: [ + { + messageId: 'alwaysTruthy', + line: 4, + column: 8, + }, + ], + }, + { + code: ` +declare function assert(x: unknown): asserts x; +assert({}); + `, + options: [{ checkTruthinessAssertions: true }], + errors: [ + { + messageId: 'alwaysTruthy', + line: 3, + column: 8, + }, + ], + }, // "branded" types unnecessaryConditionTest('"" & {}', 'alwaysFalsy'), From 4ac67657e1d48eea9f0fad75ffa2e6023e1b8e53 Mon Sep 17 00:00:00 2001 From: Kirk Waiblinger Date: Fri, 20 Sep 2024 18:22:18 -0600 Subject: [PATCH 05/14] hugely simplify --- .../src/util/assertionFunctionUtils.ts | 76 ++++--------------- .../rules/strict-boolean-expressions.test.ts | 23 ------ 2 files changed, 15 insertions(+), 84 deletions(-) diff --git a/packages/eslint-plugin/src/util/assertionFunctionUtils.ts b/packages/eslint-plugin/src/util/assertionFunctionUtils.ts index a4c19f626e68..20b5e2fc2b91 100644 --- a/packages/eslint-plugin/src/util/assertionFunctionUtils.ts +++ b/packages/eslint-plugin/src/util/assertionFunctionUtils.ts @@ -24,7 +24,6 @@ export function findAssertedArgument( if (argument.type === AST_NODE_TYPES.SpreadElement) { break; } - checkableArguments.push(argument); } @@ -40,76 +39,27 @@ export function findAssertedArgument( const calleeType = getConstrainedTypeAtLocation(services, node.callee); const callSignatures = tsutils.getCallSignaturesOfType(calleeType); + const checker = services.program.getTypeChecker(); + let assertedParameterIndex: number | undefined = undefined; for (const signature of callSignatures) { - const declaration = signature.getDeclaration(); - const returnTypeAnnotation = declaration.type; + const predicateInfo = checker.getTypePredicateOfSignature(signature); + if (predicateInfo == null) { + return undefined; + } - // Be sure we're dealing with a truthiness assertion function. + // Be sure we're dealing with a truthiness assertion function, in other words, + // `asserts x` (but not `asserts x is T`, and also not `asserts this is T`). if ( !( - returnTypeAnnotation != null && - ts.isTypePredicateNode(returnTypeAnnotation) && - // This eliminates things like `x is string` and `asserts x is T` - // leaving us with just the `asserts x` cases. - returnTypeAnnotation.type == null && - // I think this is redundant but, still, it needs to be true - returnTypeAnnotation.assertsModifier != null + predicateInfo.kind === ts.TypePredicateKind.AssertsIdentifier && + predicateInfo.type == null ) ) { return undefined; } - const assertionTarget = returnTypeAnnotation.parameterName; - if (assertionTarget.kind !== ts.SyntaxKind.Identifier) { - // This can happen when asserting on `this`. Ignore! - return undefined; - } - - // If the first parameter is `this`, skip it, so that our index matches - // the index of the argument at the call site. - const firstParameter = declaration.parameters.at(0); - const nonThisParameters = - firstParameter?.name.kind === ts.SyntaxKind.Identifier && - firstParameter.name.text === 'this' - ? declaration.parameters.slice(1) - : declaration.parameters; - - // Don't bother inspecting parameters past the number of - // arguments we have at the call site. - const checkableNonThisParameters = nonThisParameters.slice( - 0, - checkableArguments.length, - ); - - let assertedParameterIndexForThisSignature: number | undefined; - for (const [index, parameter] of checkableNonThisParameters.entries()) { - if (parameter.dotDotDotToken != null) { - // Cannot assert a rest parameter, and can't have a rest parameter - // before the asserted parameter. It's not only a TS error, it's - // not something we can logically make sense of, so give up here. - return undefined; - } - - if (parameter.name.kind !== ts.SyntaxKind.Identifier) { - // Only identifiers are valid for assertion targets, so skip over - // anything like `{ destructuring: parameter }: T` - continue; - } - - // we've found a match between the "target"s in - // `function asserts(target: T): asserts target;` - if (parameter.name.text === assertionTarget.text) { - assertedParameterIndexForThisSignature = index; - break; - } - } - - if (assertedParameterIndexForThisSignature == null) { - // Didn't find an assertion target in this signature that could match - // the call site. - return undefined; - } + const assertedParameterIndexForThisSignature = predicateInfo.parameterIndex; if ( assertedParameterIndex != null && @@ -121,6 +71,10 @@ export function findAssertedArgument( } assertedParameterIndex = assertedParameterIndexForThisSignature; + + if (assertedParameterIndex >= checkableArguments.length) { + return undefined; + } } // Didn't find a unique assertion index. diff --git a/packages/eslint-plugin/tests/rules/strict-boolean-expressions.test.ts b/packages/eslint-plugin/tests/rules/strict-boolean-expressions.test.ts index 6706f6e10c6b..f6585eecc52f 100644 --- a/packages/eslint-plugin/tests/rules/strict-boolean-expressions.test.ts +++ b/packages/eslint-plugin/tests/rules/strict-boolean-expressions.test.ts @@ -516,29 +516,6 @@ declare const nullableString: string | null; assert(3 as any, nullableString); `, }, - // Intentional TS error - A rest parameter must be last in a parameter list. - // This is just to test that we don't crash or falsely report. - ` -declare function assert(...a: boolean[], b: unknown): asserts b; -declare const nullableString: string | null; -declare const boo: boolean; -assert(boo, nullableString); - `, - // Intentional TS error - A type predicate cannot reference a rest parameter. - // This is just to test that we don't crash or falsely report. - ` -declare function assert(a: boolean, ...b: unknown[]): asserts b; -declare const nullableString: string | null; -declare const boo: boolean; -assert(boo, nullableString); - `, - // Intentional TS error - An assertion function must have a parameter to assert. - // This is just to test that we don't crash or falsely report. - ` -declare function assert(): asserts x; -declare const nullableString: string | null; -assert(nullableString); - `, ` function assert(one: unknown): asserts one; function assert(one: unknown, two: unknown): asserts two; From 150f221785f895420915b3590d8209d7f31c77dc Mon Sep 17 00:00:00 2001 From: Kirk Waiblinger Date: Fri, 20 Sep 2024 19:22:48 -0600 Subject: [PATCH 06/14] uge --- .../src/rules/no-unnecessary-condition.ts | 45 +++++++++- .../src/rules/prefer-optional-chain.ts | 6 +- .../rules/prefer-string-starts-ends-with.ts | 6 +- .../src/rules/strict-boolean-expressions.ts | 4 +- .../src/util/assertionFunctionUtils.ts | 82 ++++++++++++++++++- 5 files changed, 126 insertions(+), 17 deletions(-) diff --git a/packages/eslint-plugin/src/rules/no-unnecessary-condition.ts b/packages/eslint-plugin/src/rules/no-unnecessary-condition.ts index 4eec8d040e6d..99264b0cdb8f 100644 --- a/packages/eslint-plugin/src/rules/no-unnecessary-condition.ts +++ b/packages/eslint-plugin/src/rules/no-unnecessary-condition.ts @@ -1,5 +1,6 @@ import type { TSESTree } from '@typescript-eslint/utils'; import { AST_NODE_TYPES, AST_TOKEN_TYPES } from '@typescript-eslint/utils'; +import type { ReportFixFunction } from '@typescript-eslint/utils/ts-eslint'; import * as tsutils from 'ts-api-utils'; import * as ts from 'typescript'; @@ -18,7 +19,10 @@ import { nullThrows, NullThrowsReasons, } from '../util'; -import { findAssertedArgument } from '../util/assertionFunctionUtils'; +import { + findTruthinessAssertedArgument, + findTypeGuardAssertedArgument, +} from '../util/assertionFunctionUtils'; // Truthiness utilities // #region @@ -83,6 +87,8 @@ export type MessageId = | 'alwaysTruthy' | 'alwaysTruthyFunc' | 'literalBooleanExpression' + | 'typeGuardAlreadyIsType' + | 'replaceWithTrue' | 'never' | 'neverNullish' | 'neverOptionalChain' @@ -92,6 +98,7 @@ export type MessageId = export default createRule({ name: 'no-unnecessary-condition', meta: { + hasSuggestions: true, type: 'suggestion', docs: { description: @@ -136,12 +143,15 @@ export default createRule({ 'Unnecessary conditional, left-hand side of `??` operator is always `null` or `undefined`.', literalBooleanExpression: 'Unnecessary conditional, both sides of the expression are literal values.', + replaceWithTrue: 'Replace always true expression with `true`.', noOverlapBooleanExpression: 'Unnecessary conditional, the types have no overlap.', never: 'Unnecessary conditional, value is `never`.', neverOptionalChain: 'Unnecessary optional chain on a non-nullish value.', noStrictNullCheck: 'This rule requires the `strictNullChecks` compiler option to be turned on to function correctly.', + typeGuardAlreadyIsType: + 'Unnecessary conditional, expression already has the type being checked.', }, }, defaultOptions: [ @@ -473,9 +483,36 @@ export default createRule({ function checkCallExpression(node: TSESTree.CallExpression): void { if (checkTruthinessAssertions) { - const assertedArgument = findAssertedArgument(services, node); - if (assertedArgument != null) { - checkNode(assertedArgument); + const truthinessAssertedArgument = findTruthinessAssertedArgument( + services, + node, + ); + if (truthinessAssertedArgument != null) { + checkNode(truthinessAssertedArgument); + } + } + + const typeGuardAssertedArgument = findTypeGuardAssertedArgument( + services, + node, + ); + if (typeGuardAssertedArgument != null) { + const typeOfArgument = getConstrainedTypeAtLocation( + services, + typeGuardAssertedArgument.argument, + ); + if (typeOfArgument === typeGuardAssertedArgument.type) { + context.report({ + messageId: 'typeGuardAlreadyIsType', + node: typeGuardAssertedArgument.argument, + suggest: [ + { + messageId: 'replaceWithTrue', + fix: (fixer): ReturnType => + fixer.replaceText(node, '(true)'), + }, + ], + }); } } diff --git a/packages/eslint-plugin/src/rules/prefer-optional-chain.ts b/packages/eslint-plugin/src/rules/prefer-optional-chain.ts index 1e979e47a4c8..2c42a9286d31 100644 --- a/packages/eslint-plugin/src/rules/prefer-optional-chain.ts +++ b/packages/eslint-plugin/src/rules/prefer-optional-chain.ts @@ -130,14 +130,10 @@ export default createRule< function isLeftSideLowerPrecedence(): boolean { const logicalTsNode = parserServices.esTreeNodeToTSNodeMap.get(node); - const leftTsNode = parserServices.esTreeNodeToTSNodeMap.get(leftNode); - const operator = ts.isBinaryExpression(logicalTsNode) - ? logicalTsNode.operatorToken.kind - : ts.SyntaxKind.Unknown; const leftPrecedence = getOperatorPrecedence( leftTsNode.kind, - operator, + logicalTsNode.operatorToken.kind, ); return leftPrecedence < OperatorPrecedence.LeftHandSide; diff --git a/packages/eslint-plugin/src/rules/prefer-string-starts-ends-with.ts b/packages/eslint-plugin/src/rules/prefer-string-starts-ends-with.ts index d65fd9736ad5..ca0bd74c03bd 100644 --- a/packages/eslint-plugin/src/rules/prefer-string-starts-ends-with.ts +++ b/packages/eslint-plugin/src/rules/prefer-string-starts-ends-with.ts @@ -535,11 +535,7 @@ export default createRule({ const callNode = getParent(node) as TSESTree.CallExpression; const parentNode = getParent(callNode) as TSESTree.BinaryExpression; - if ( - !isEqualityComparison(parentNode) || - !isNull(parentNode.right) || - !isStringType(node.object) - ) { + if (!isNull(parentNode.right) || !isStringType(node.object)) { return; } diff --git a/packages/eslint-plugin/src/rules/strict-boolean-expressions.ts b/packages/eslint-plugin/src/rules/strict-boolean-expressions.ts index 48b121a0512f..7adbad1e525d 100644 --- a/packages/eslint-plugin/src/rules/strict-boolean-expressions.ts +++ b/packages/eslint-plugin/src/rules/strict-boolean-expressions.ts @@ -13,7 +13,7 @@ import { getWrappingFixer, isTypeArrayTypeOrUnionOfArrayTypes, } from '../util'; -import { findAssertedArgument } from '../util/assertionFunctionUtils'; +import { findTruthinessAssertedArgument } from '../util/assertionFunctionUtils'; export type Options = [ { @@ -268,7 +268,7 @@ export default createRule({ } function traverseCallExpression(node: TSESTree.CallExpression): void { - const assertedArgument = findAssertedArgument(services, node); + const assertedArgument = findTruthinessAssertedArgument(services, node); if (assertedArgument != null) { traverseNode(assertedArgument, true); } diff --git a/packages/eslint-plugin/src/util/assertionFunctionUtils.ts b/packages/eslint-plugin/src/util/assertionFunctionUtils.ts index 20b5e2fc2b91..cb956d7b5e2a 100644 --- a/packages/eslint-plugin/src/util/assertionFunctionUtils.ts +++ b/packages/eslint-plugin/src/util/assertionFunctionUtils.ts @@ -12,7 +12,7 @@ import { getConstrainedTypeAtLocation } from './index'; * Inspect a call expression to see if it's a call to an assertion function. * If it is, return the node of the argument that is asserted. */ -export function findAssertedArgument( +export function findTruthinessAssertedArgument( services: ParserServicesWithTypeInformation, node: TSESTree.CallExpression, ): TSESTree.Expression | undefined { @@ -84,3 +84,83 @@ export function findAssertedArgument( return checkableArguments[assertedParameterIndex]; } + +/** + * Inspect a call expression to see if it's a call to an assertion function. + * If it is, return the node of the argument that is asserted. + */ +export function findTypeGuardAssertedArgument( + services: ParserServicesWithTypeInformation, + node: TSESTree.CallExpression, +): { argument: TSESTree.Expression; type: ts.Type } | undefined { + // If the call looks like `assert(expr1, expr2, ...c, d, e, f)`, then we can + // only care if `expr1` or `expr2` is asserted, since anything that happens + // within or after a spread argument is out of scope to reason about. + const checkableArguments: TSESTree.Expression[] = []; + for (const argument of node.arguments) { + if (argument.type === AST_NODE_TYPES.SpreadElement) { + break; + } + checkableArguments.push(argument); + } + + // nothing to do + if (checkableArguments.length === 0) { + return undefined; + } + + // Game plan: we're going to check the type of the callee. If it has call + // signatures and they _ALL_ agree that they assert on a parameter at the + // _SAME_ position, we'll consider the argument in that position to be an + // asserted argument. + const calleeType = getConstrainedTypeAtLocation(services, node.callee); + const callSignatures = tsutils.getCallSignaturesOfType(calleeType); + + const checker = services.program.getTypeChecker(); + + let predicateInfo: { parameterIndex: number; type: ts.Type } | undefined = + undefined; + + for (const signature of callSignatures) { + const thisPredicateInfo = checker.getTypePredicateOfSignature(signature); + if (thisPredicateInfo == null) { + return undefined; + } + + // Be sure we're dealing with a truthiness assertion function, in other words, + // `asserts x` (but not `asserts x is T`, and also not `asserts this is T`). + if (!(thisPredicateInfo.kind === ts.TypePredicateKind.Identifier)) { + return undefined; + } + + const { parameterIndex, type } = thisPredicateInfo; + + if (predicateInfo != null) { + if ( + predicateInfo.parameterIndex !== parameterIndex || + predicateInfo.type !== type + ) { + return undefined; + } + } else { + if (parameterIndex >= checkableArguments.length) { + return undefined; + } + + predicateInfo = { + parameterIndex, + type, + }; + } + } + + // Didn't find a unique assertion index. + if (predicateInfo == null) { + return undefined; + } + + return { + argument: checkableArguments[predicateInfo.parameterIndex], + type: predicateInfo.type, + }; +} From d6c67fe0854ffdbe7ee2623d7ea05be7a04cd1c0 Mon Sep 17 00:00:00 2001 From: Kirk Waiblinger Date: Fri, 20 Sep 2024 19:58:43 -0600 Subject: [PATCH 07/14] yooj --- .../src/rules/prefer-optional-chain.ts | 1 - .../src/util/assertionFunctionUtils.ts | 35 +++++++++++++++---- packages/type-utils/src/getTypeName.ts | 3 +- .../tests/lib/source-files.test.ts | 1 + .../tests/test-utils/test-utils.ts | 2 ++ .../typeDetails/SimplifiedTreeView.tsx | 4 +-- 6 files changed, 36 insertions(+), 10 deletions(-) diff --git a/packages/eslint-plugin/src/rules/prefer-optional-chain.ts b/packages/eslint-plugin/src/rules/prefer-optional-chain.ts index 2c42a9286d31..cea09251e53f 100644 --- a/packages/eslint-plugin/src/rules/prefer-optional-chain.ts +++ b/packages/eslint-plugin/src/rules/prefer-optional-chain.ts @@ -1,7 +1,6 @@ import type { TSESTree } from '@typescript-eslint/utils'; import { AST_NODE_TYPES } from '@typescript-eslint/utils'; import type { RuleFix } from '@typescript-eslint/utils/ts-eslint'; -import * as ts from 'typescript'; import { createRule, diff --git a/packages/eslint-plugin/src/util/assertionFunctionUtils.ts b/packages/eslint-plugin/src/util/assertionFunctionUtils.ts index cb956d7b5e2a..af6e5a6345fb 100644 --- a/packages/eslint-plugin/src/util/assertionFunctionUtils.ts +++ b/packages/eslint-plugin/src/util/assertionFunctionUtils.ts @@ -92,7 +92,15 @@ export function findTruthinessAssertedArgument( export function findTypeGuardAssertedArgument( services: ParserServicesWithTypeInformation, node: TSESTree.CallExpression, -): { argument: TSESTree.Expression; type: ts.Type } | undefined { +): + | { + predicateKind: + | ts.TypePredicateKind.AssertsIdentifier + | ts.TypePredicateKind.Identifier; + argument: TSESTree.Expression; + type: ts.Type | undefined; + } + | undefined { // If the call looks like `assert(expr1, expr2, ...c, d, e, f)`, then we can // only care if `expr1` or `expr2` is asserted, since anything that happens // within or after a spread argument is out of scope to reason about. @@ -118,8 +126,15 @@ export function findTypeGuardAssertedArgument( const checker = services.program.getTypeChecker(); - let predicateInfo: { parameterIndex: number; type: ts.Type } | undefined = - undefined; + let predicateInfo: + | { + parameterIndex: number; + type: ts.Type | undefined; + predicateKind: + | ts.TypePredicateKind.AssertsIdentifier + | ts.TypePredicateKind.Identifier; + } + | undefined = undefined; for (const signature of callSignatures) { const thisPredicateInfo = checker.getTypePredicateOfSignature(signature); @@ -129,16 +144,22 @@ export function findTypeGuardAssertedArgument( // Be sure we're dealing with a truthiness assertion function, in other words, // `asserts x` (but not `asserts x is T`, and also not `asserts this is T`). - if (!(thisPredicateInfo.kind === ts.TypePredicateKind.Identifier)) { + if ( + !( + thisPredicateInfo.kind === ts.TypePredicateKind.Identifier || + thisPredicateInfo.kind === ts.TypePredicateKind.AssertsIdentifier + ) + ) { return undefined; } - const { parameterIndex, type } = thisPredicateInfo; + const { parameterIndex, type, kind } = thisPredicateInfo; if (predicateInfo != null) { if ( predicateInfo.parameterIndex !== parameterIndex || - predicateInfo.type !== type + predicateInfo.type !== type || + predicateInfo.predicateKind !== kind ) { return undefined; } @@ -148,6 +169,7 @@ export function findTypeGuardAssertedArgument( } predicateInfo = { + predicateKind: kind, parameterIndex, type, }; @@ -160,6 +182,7 @@ export function findTypeGuardAssertedArgument( } return { + predicateKind: predicateInfo.predicateKind, argument: checkableArguments[predicateInfo.parameterIndex], type: predicateInfo.type, }; diff --git a/packages/type-utils/src/getTypeName.ts b/packages/type-utils/src/getTypeName.ts index ea3f69e4b5d6..72395f996ca0 100644 --- a/packages/type-utils/src/getTypeName.ts +++ b/packages/type-utils/src/getTypeName.ts @@ -23,8 +23,9 @@ export function getTypeName( // via AST. const symbol = type.getSymbol(); const decls = symbol?.getDeclarations(); - const typeParamDecl = decls?.[0] as ts.TypeParameterDeclaration; + const typeParamDecl = decls?.[0]; if ( + typeParamDecl != null && ts.isTypeParameterDeclaration(typeParamDecl) && typeParamDecl.constraint != null ) { diff --git a/packages/typescript-estree/tests/lib/source-files.test.ts b/packages/typescript-estree/tests/lib/source-files.test.ts index e6edb1c9c657..1986b25094e3 100644 --- a/packages/typescript-estree/tests/lib/source-files.test.ts +++ b/packages/typescript-estree/tests/lib/source-files.test.ts @@ -13,6 +13,7 @@ describe('isSourceFile', () => { it('returns true when given a real source file', () => { const input = ts.createSourceFile('test.ts', '', ts.ScriptTarget.ESNext); + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- intentionally testing that the types match reality expect(isSourceFile(input)).toBe(true); }); }); diff --git a/packages/typescript-estree/tests/test-utils/test-utils.ts b/packages/typescript-estree/tests/test-utils/test-utils.ts index ed18eb6fb3fe..e4dc84ff780a 100644 --- a/packages/typescript-estree/tests/test-utils/test-utils.ts +++ b/packages/typescript-estree/tests/test-utils/test-utils.ts @@ -117,6 +117,7 @@ export function omitDeep( oNode: UnknownObject, parent: UnknownObject | null, ): UnknownObject { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- I don't know if it's safe to change this. if (!Array.isArray(oNode) && !isObjectLike(oNode)) { return oNode; } @@ -139,6 +140,7 @@ export function omitDeep( value.push(visit(el, node)); } node[prop] = value; + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- I don't know if it's safe to change this. } else if (isObjectLike(child)) { node[prop] = visit(child, node); } diff --git a/packages/website/src/components/typeDetails/SimplifiedTreeView.tsx b/packages/website/src/components/typeDetails/SimplifiedTreeView.tsx index c5dc1b37926d..745fd08992fa 100644 --- a/packages/website/src/components/typeDetails/SimplifiedTreeView.tsx +++ b/packages/website/src/components/typeDetails/SimplifiedTreeView.tsx @@ -6,7 +6,7 @@ import styles from '../ast/ASTViewer.module.css'; import PropertyName from '../ast/PropertyName'; import { tsEnumToString } from '../ast/tsUtils'; import type { OnHoverNodeFn } from '../ast/types'; -import { getRange, isTSNode } from '../ast/utils'; +import { getRange } from '../ast/utils'; export interface SimplifiedTreeViewProps { readonly value: ts.Node; @@ -31,7 +31,7 @@ function SimplifiedItem({ const onHover = useCallback( (v: boolean) => { - if (isTSNode(value) && onHoverNode) { + if (onHoverNode) { return onHoverNode(v ? getRange(value, 'tsNode') : undefined); } }, From e9aa6525d7c15e7d262ed4eb28ef2079ec54906b Mon Sep 17 00:00:00 2001 From: Kirk Waiblinger Date: Sun, 22 Sep 2024 21:25:41 -0600 Subject: [PATCH 08/14] changes --- eslint.config.mjs | 2 +- .../docs/rules/no-unnecessary-condition.mdx | 6 +- .../src/rules/no-unnecessary-condition.ts | 43 ++--- .../src/util/assertionFunctionUtils.ts | 155 ++++++++---------- .../no-unnecessary-condition.shot | 2 +- .../rules/no-unnecessary-condition.test.ts | 96 +++++++++-- .../no-unnecessary-condition.shot | 4 +- 7 files changed, 175 insertions(+), 133 deletions(-) diff --git a/eslint.config.mjs b/eslint.config.mjs index a0f6ac7f4454..6e510fb45010 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -129,7 +129,7 @@ export default tseslint.config( 'no-constant-condition': 'off', '@typescript-eslint/no-unnecessary-condition': [ 'error', - { allowConstantLoopConditions: true, checkTruthinessAssertions: true }, + { allowConstantLoopConditions: true, checkTypePredicates: true }, ], '@typescript-eslint/no-unnecessary-type-parameters': 'error', '@typescript-eslint/no-unused-expressions': 'error', diff --git a/packages/eslint-plugin/docs/rules/no-unnecessary-condition.mdx b/packages/eslint-plugin/docs/rules/no-unnecessary-condition.mdx index a2cd18050232..78be72868387 100644 --- a/packages/eslint-plugin/docs/rules/no-unnecessary-condition.mdx +++ b/packages/eslint-plugin/docs/rules/no-unnecessary-condition.mdx @@ -90,11 +90,11 @@ for (; true; ) {} do {} while (true); ``` -### `checkTruthinessAssertions` +### `checkTypePredicates` -Example of additional incorrect code with `{ checkTruthinessAssertions: true }`: +Example of additional incorrect code with `{ checkTypePredicates: true }`: -```ts option='{ "checkTruthinessAssertions": true }' showPlaygroundButton +```ts option='{ "checkTypePredicates": true }' showPlaygroundButton function assert(condition: unknown): asserts condition { if (!condition) { throw new Error('Condition is falsy'); diff --git a/packages/eslint-plugin/src/rules/no-unnecessary-condition.ts b/packages/eslint-plugin/src/rules/no-unnecessary-condition.ts index 99264b0cdb8f..0cda9a8e79a0 100644 --- a/packages/eslint-plugin/src/rules/no-unnecessary-condition.ts +++ b/packages/eslint-plugin/src/rules/no-unnecessary-condition.ts @@ -76,7 +76,7 @@ export type Options = [ { allowConstantLoopConditions?: boolean; allowRuleToRunWithoutStrictNullChecksIKnowWhatIAmDoing?: boolean; - checkTruthinessAssertions?: boolean; + checkTypePredicates?: boolean; }, ]; @@ -120,7 +120,7 @@ export default createRule({ 'Whether to not error when running with a tsconfig that has strictNullChecks turned.', type: 'boolean', }, - checkTruthinessAssertions: { + checkTypePredicates: { description: 'Whether to check the asserted argument of a truthiness assertion function as a conditional context', type: 'boolean', @@ -158,7 +158,7 @@ export default createRule({ { allowConstantLoopConditions: false, allowRuleToRunWithoutStrictNullChecksIKnowWhatIAmDoing: false, - checkTruthinessAssertions: false, + checkTypePredicates: false, }, ], create( @@ -167,7 +167,7 @@ export default createRule({ { allowConstantLoopConditions, allowRuleToRunWithoutStrictNullChecksIKnowWhatIAmDoing, - checkTruthinessAssertions, + checkTypePredicates, }, ], ) { @@ -482,7 +482,7 @@ export default createRule({ } function checkCallExpression(node: TSESTree.CallExpression): void { - if (checkTruthinessAssertions) { + if (checkTypePredicates) { const truthinessAssertedArgument = findTruthinessAssertedArgument( services, node, @@ -490,29 +490,22 @@ export default createRule({ if (truthinessAssertedArgument != null) { checkNode(truthinessAssertedArgument); } - } - const typeGuardAssertedArgument = findTypeGuardAssertedArgument( - services, - node, - ); - if (typeGuardAssertedArgument != null) { - const typeOfArgument = getConstrainedTypeAtLocation( + const typeGuardAssertedArgument = findTypeGuardAssertedArgument( services, - typeGuardAssertedArgument.argument, + node, ); - if (typeOfArgument === typeGuardAssertedArgument.type) { - context.report({ - messageId: 'typeGuardAlreadyIsType', - node: typeGuardAssertedArgument.argument, - suggest: [ - { - messageId: 'replaceWithTrue', - fix: (fixer): ReturnType => - fixer.replaceText(node, '(true)'), - }, - ], - }); + if (typeGuardAssertedArgument != null) { + const typeOfArgument = getConstrainedTypeAtLocation( + services, + typeGuardAssertedArgument.argument, + ); + if (typeOfArgument === typeGuardAssertedArgument.type) { + context.report({ + messageId: 'typeGuardAlreadyIsType', + node: typeGuardAssertedArgument.argument, + }); + } } } diff --git a/packages/eslint-plugin/src/util/assertionFunctionUtils.ts b/packages/eslint-plugin/src/util/assertionFunctionUtils.ts index af6e5a6345fb..5745671498df 100644 --- a/packages/eslint-plugin/src/util/assertionFunctionUtils.ts +++ b/packages/eslint-plugin/src/util/assertionFunctionUtils.ts @@ -39,50 +39,41 @@ export function findTruthinessAssertedArgument( const calleeType = getConstrainedTypeAtLocation(services, node.callee); const callSignatures = tsutils.getCallSignaturesOfType(calleeType); - const checker = services.program.getTypeChecker(); - - let assertedParameterIndex: number | undefined = undefined; - for (const signature of callSignatures) { - const predicateInfo = checker.getTypePredicateOfSignature(signature); - if (predicateInfo == null) { - return undefined; - } - - // Be sure we're dealing with a truthiness assertion function, in other words, - // `asserts x` (but not `asserts x is T`, and also not `asserts this is T`). - if ( - !( - predicateInfo.kind === ts.TypePredicateKind.AssertsIdentifier && - predicateInfo.type == null - ) - ) { - return undefined; - } + if (callSignatures.length === 0) { + return undefined; + } - const assertedParameterIndexForThisSignature = predicateInfo.parameterIndex; + const checker = services.program.getTypeChecker(); - if ( - assertedParameterIndex != null && - assertedParameterIndex !== assertedParameterIndexForThisSignature - ) { - // The asserted parameter we found for this signature didn't match - // previous signatures. - return undefined; - } + const typePredicates = callSignatures.map(signature => + checker.getTypePredicateOfSignature(signature), + ); - assertedParameterIndex = assertedParameterIndexForThisSignature; + const [firstTypePredicateResult, ...otherTypePredicateResults] = + typePredicates; + if (firstTypePredicateResult == null) { + return undefined; + } - if (assertedParameterIndex >= checkableArguments.length) { - return undefined; - } + // Ensure all call signatures are asserting the same thing. + const { parameterIndex, kind, type } = firstTypePredicateResult; + if (!(kind === ts.TypePredicateKind.AssertsIdentifier && type == null)) { + return undefined; } - // Didn't find a unique assertion index. - if (assertedParameterIndex == null) { + if ( + otherTypePredicateResults.some( + otherResult => + otherResult == null || + otherResult.parameterIndex !== parameterIndex || + otherResult.kind !== kind || + otherResult.type != null, + ) + ) { return undefined; } - return checkableArguments[assertedParameterIndex]; + return checkableArguments.at(parameterIndex); } /** @@ -94,11 +85,9 @@ export function findTypeGuardAssertedArgument( node: TSESTree.CallExpression, ): | { - predicateKind: - | ts.TypePredicateKind.AssertsIdentifier - | ts.TypePredicateKind.Identifier; + asserts: boolean; argument: TSESTree.Expression; - type: ts.Type | undefined; + type: ts.Type; } | undefined { // If the call looks like `assert(expr1, expr2, ...c, d, e, f)`, then we can @@ -124,66 +113,52 @@ export function findTypeGuardAssertedArgument( const calleeType = getConstrainedTypeAtLocation(services, node.callee); const callSignatures = tsutils.getCallSignaturesOfType(calleeType); + if (callSignatures.length === 0) { + return undefined; + } + const checker = services.program.getTypeChecker(); - let predicateInfo: - | { - parameterIndex: number; - type: ts.Type | undefined; - predicateKind: - | ts.TypePredicateKind.AssertsIdentifier - | ts.TypePredicateKind.Identifier; - } - | undefined = undefined; - - for (const signature of callSignatures) { - const thisPredicateInfo = checker.getTypePredicateOfSignature(signature); - if (thisPredicateInfo == null) { - return undefined; - } + const typePredicates = callSignatures.map(signature => + checker.getTypePredicateOfSignature(signature), + ); - // Be sure we're dealing with a truthiness assertion function, in other words, - // `asserts x` (but not `asserts x is T`, and also not `asserts this is T`). - if ( - !( - thisPredicateInfo.kind === ts.TypePredicateKind.Identifier || - thisPredicateInfo.kind === ts.TypePredicateKind.AssertsIdentifier - ) - ) { - return undefined; - } + const [firstTypePredicateResult, ...otherTypePredicateResults] = + typePredicates; + if (firstTypePredicateResult == null) { + return undefined; + } - const { parameterIndex, type, kind } = thisPredicateInfo; - - if (predicateInfo != null) { - if ( - predicateInfo.parameterIndex !== parameterIndex || - predicateInfo.type !== type || - predicateInfo.predicateKind !== kind - ) { - return undefined; - } - } else { - if (parameterIndex >= checkableArguments.length) { - return undefined; - } - - predicateInfo = { - predicateKind: kind, - parameterIndex, - type, - }; - } + // Ensure all call signatures are asserting the same thing. + const { parameterIndex, kind, type } = firstTypePredicateResult; + if ( + !( + (kind === ts.TypePredicateKind.AssertsIdentifier && type != null) || + (kind === ts.TypePredicateKind.Identifier && type != null) + ) + ) { + return undefined; + } + + if ( + otherTypePredicateResults.some( + otherResult => + otherResult == null || + otherResult.parameterIndex !== parameterIndex || + otherResult.kind !== kind || + otherResult.type !== type, + ) + ) { + return undefined; } - // Didn't find a unique assertion index. - if (predicateInfo == null) { + if (parameterIndex >= checkableArguments.length) { return undefined; } return { - predicateKind: predicateInfo.predicateKind, - argument: checkableArguments[predicateInfo.parameterIndex], - type: predicateInfo.type, + type, + asserts: kind === ts.TypePredicateKind.AssertsIdentifier, + argument: checkableArguments[parameterIndex], }; } diff --git a/packages/eslint-plugin/tests/docs-eslint-output-snapshots/no-unnecessary-condition.shot b/packages/eslint-plugin/tests/docs-eslint-output-snapshots/no-unnecessary-condition.shot index a85290ca3d65..5e9690f8f312 100644 --- a/packages/eslint-plugin/tests/docs-eslint-output-snapshots/no-unnecessary-condition.shot +++ b/packages/eslint-plugin/tests/docs-eslint-output-snapshots/no-unnecessary-condition.shot @@ -68,7 +68,7 @@ do {} while (true); `; exports[`Validating rule docs no-unnecessary-condition.mdx code examples ESLint output 4`] = ` -"Options: { "checkTruthinessAssertions": true } +"Options: { "checkTypePredicates": true } function assert(condition: unknown): asserts condition { if (!condition) { diff --git a/packages/eslint-plugin/tests/rules/no-unnecessary-condition.test.ts b/packages/eslint-plugin/tests/rules/no-unnecessary-condition.test.ts index 2cd33aab3531..72786be464de 100644 --- a/packages/eslint-plugin/tests/rules/no-unnecessary-condition.test.ts +++ b/packages/eslint-plugin/tests/rules/no-unnecessary-condition.test.ts @@ -900,7 +900,7 @@ declare function assert(x: unknown): asserts x; assert(Math.random() > 0.5); `, - options: [{ checkTruthinessAssertions: true }], + options: [{ checkTypePredicates: true }], }, { code: ` @@ -908,7 +908,7 @@ declare function assert(x: unknown, y: unknown): asserts x; assert(Math.random() > 0.5, true); `, - options: [{ checkTruthinessAssertions: true }], + options: [{ checkTypePredicates: true }], }, { // should not report because option is disabled. @@ -916,7 +916,7 @@ assert(Math.random() > 0.5, true); declare function assert(x: unknown): asserts x; assert(true); `, - options: [{ checkTruthinessAssertions: false }], + options: [{ checkTypePredicates: false }], }, { // could be argued that this should report since `thisAsserter` is truthy. @@ -928,7 +928,7 @@ class ThisAsserter { const thisAsserter: ThisAsserter = new ThisAsserter(); thisAsserter.assertThis(true); `, - options: [{ checkTruthinessAssertions: true }], + options: [{ checkTypePredicates: true }], }, { // could be argued that this should report since `thisAsserter` is truthy. @@ -940,14 +940,14 @@ class ThisAsserter { const thisAsserter: ThisAsserter = new ThisAsserter(); thisAsserter.assertThis(Math.random()); `, - options: [{ checkTruthinessAssertions: true }], + options: [{ checkTypePredicates: true }], }, { code: ` declare function assert(x: unknown): asserts x; assert(...[]); `, - options: [{ checkTruthinessAssertions: true }], + options: [{ checkTypePredicates: true }], }, { // ok to report if we start unpacking spread params one day. @@ -955,7 +955,39 @@ assert(...[]); declare function assert(x: unknown): asserts x; assert(...[], {}); `, - options: [{ checkTruthinessAssertions: true }], + options: [{ checkTypePredicates: true }], + }, + { + code: ` +declare function assertString(x: unknown): asserts x is string; +declare const a: string; +assertString(a); + `, + options: [{ checkTypePredicates: false }], + }, + { + code: ` +declare function isString(x: unknown): x is string; +declare const a: string; +isString(a); + `, + options: [{ checkTypePredicates: false }], + }, + { + // Technically, this has type 'falafel' and not string. + code: ` + declare function assertString(x: unknown): asserts x is string; + assertString('falafel'); + `, + options: [{ checkTypePredicates: true }], + }, + { + // Technically, this has type 'falafel' and not string. + code: ` +declare function isString(x: unknown): x is string; +isString('falafel'); + `, + options: [{ checkTypePredicates: true }], }, ], @@ -2380,7 +2412,7 @@ assert(true); messageId: 'alwaysTruthy', }, ], - options: [{ checkTruthinessAssertions: true }], + options: [{ checkTypePredicates: true }], }, { code: ` @@ -2394,7 +2426,7 @@ assert(false); messageId: 'alwaysFalsy', }, ], - options: [{ checkTruthinessAssertions: true }], + options: [{ checkTypePredicates: true }], }, { code: ` @@ -2402,7 +2434,7 @@ declare function assert(x: unknown, y: unknown): asserts x; assert(true, Math.random() > 0.5); `, - options: [{ checkTruthinessAssertions: true }], + options: [{ checkTypePredicates: true }], errors: [ { messageId: 'alwaysTruthy', @@ -2416,7 +2448,7 @@ assert(true, Math.random() > 0.5); declare function assert(x: unknown): asserts x; assert({}); `, - options: [{ checkTruthinessAssertions: true }], + options: [{ checkTypePredicates: true }], errors: [ { messageId: 'alwaysTruthy', @@ -2425,6 +2457,48 @@ assert({}); }, ], }, + { + code: ` +declare function assertsString(x: unknown): asserts x is string; +declare const a: string; +assertsString(a); + `, + options: [{ checkTypePredicates: true }], + errors: [ + { + messageId: 'typeGuardAlreadyIsType', + line: 4, + }, + ], + }, + { + code: ` +declare function isString(x: unknown): x is string; +declare const a: string; +isString(a); + `, + options: [{ checkTypePredicates: true }], + errors: [ + { + messageId: 'typeGuardAlreadyIsType', + line: 4, + }, + ], + }, + { + code: ` +declare function isString(x: unknown): x is string; +declare const a: string; +isString('fa' + 'lafel'); + `, + options: [{ checkTypePredicates: true }], + errors: [ + { + messageId: 'typeGuardAlreadyIsType', + line: 4, + }, + ], + }, // "branded" types unnecessaryConditionTest('"" & {}', 'alwaysFalsy'), diff --git a/packages/eslint-plugin/tests/schema-snapshots/no-unnecessary-condition.shot b/packages/eslint-plugin/tests/schema-snapshots/no-unnecessary-condition.shot index 645b0605a803..7d51c2053fc4 100644 --- a/packages/eslint-plugin/tests/schema-snapshots/no-unnecessary-condition.shot +++ b/packages/eslint-plugin/tests/schema-snapshots/no-unnecessary-condition.shot @@ -16,7 +16,7 @@ exports[`Rule schemas should be convertible to TS types for documentation purpos "description": "Whether to not error when running with a tsconfig that has strictNullChecks turned.", "type": "boolean" }, - "checkTruthinessAssertions": { + "checkTypePredicates": { "description": "Whether to check the asserted argument of a truthiness assertion function as a conditional context", "type": "boolean" } @@ -35,7 +35,7 @@ type Options = [ /** Whether to not error when running with a tsconfig that has strictNullChecks turned. */ allowRuleToRunWithoutStrictNullChecksIKnowWhatIAmDoing?: boolean; /** Whether to check the asserted argument of a truthiness assertion function as a conditional context */ - checkTruthinessAssertions?: boolean; + checkTypePredicates?: boolean; }, ]; " From e2426833f92a72f09b774e475d90085822a1b30c Mon Sep 17 00:00:00 2001 From: Kirk Waiblinger Date: Sun, 22 Sep 2024 21:49:17 -0600 Subject: [PATCH 09/14] some changes --- .../src/rules/no-unnecessary-condition.ts | 11 +++++++---- .../eslint-plugin/src/util/assertionFunctionUtils.ts | 2 +- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/packages/eslint-plugin/src/rules/no-unnecessary-condition.ts b/packages/eslint-plugin/src/rules/no-unnecessary-condition.ts index 0cda9a8e79a0..f92acd61d670 100644 --- a/packages/eslint-plugin/src/rules/no-unnecessary-condition.ts +++ b/packages/eslint-plugin/src/rules/no-unnecessary-condition.ts @@ -1,6 +1,5 @@ import type { TSESTree } from '@typescript-eslint/utils'; import { AST_NODE_TYPES, AST_TOKEN_TYPES } from '@typescript-eslint/utils'; -import type { ReportFixFunction } from '@typescript-eslint/utils/ts-eslint'; import * as tsutils from 'ts-api-utils'; import * as ts from 'typescript'; @@ -98,7 +97,6 @@ export type MessageId = export default createRule({ name: 'no-unnecessary-condition', meta: { - hasSuggestions: true, type: 'suggestion', docs: { description: @@ -151,7 +149,7 @@ export default createRule({ noStrictNullCheck: 'This rule requires the `strictNullChecks` compiler option to be turned on to function correctly.', typeGuardAlreadyIsType: - 'Unnecessary conditional, expression already has the type being checked.', + 'Unnecessary conditional, expression already has the type being checked by the {{typeGuardOrAssertionFunction}}.', }, }, defaultOptions: [ @@ -502,8 +500,13 @@ export default createRule({ ); if (typeOfArgument === typeGuardAssertedArgument.type) { context.report({ - messageId: 'typeGuardAlreadyIsType', node: typeGuardAssertedArgument.argument, + messageId: 'typeGuardAlreadyIsType', + data: { + typeGuardOrAssertionFunction: typeGuardAssertedArgument.asserts + ? 'assertion function' + : 'type guard', + }, }); } } diff --git a/packages/eslint-plugin/src/util/assertionFunctionUtils.ts b/packages/eslint-plugin/src/util/assertionFunctionUtils.ts index 5745671498df..d2b3a362bfbe 100644 --- a/packages/eslint-plugin/src/util/assertionFunctionUtils.ts +++ b/packages/eslint-plugin/src/util/assertionFunctionUtils.ts @@ -78,7 +78,7 @@ export function findTruthinessAssertedArgument( /** * Inspect a call expression to see if it's a call to an assertion function. - * If it is, return the node of the argument that is asserted. + * If it is, return the node of the argument that is asserted and other useful info. */ export function findTypeGuardAssertedArgument( services: ParserServicesWithTypeInformation, From b8ce05ca8127f84d53eddab85ae324c73c6d4d50 Mon Sep 17 00:00:00 2001 From: Kirk Waiblinger Date: Sun, 22 Sep 2024 22:30:13 -0600 Subject: [PATCH 10/14] test changes --- .../src/util/assertionFunctionUtils.ts | 77 ++++-------------- .../rules/strict-boolean-expressions.test.ts | 80 ++++++++++++++----- 2 files changed, 78 insertions(+), 79 deletions(-) diff --git a/packages/eslint-plugin/src/util/assertionFunctionUtils.ts b/packages/eslint-plugin/src/util/assertionFunctionUtils.ts index d2b3a362bfbe..c561d1c59df8 100644 --- a/packages/eslint-plugin/src/util/assertionFunctionUtils.ts +++ b/packages/eslint-plugin/src/util/assertionFunctionUtils.ts @@ -3,11 +3,8 @@ import type { TSESTree, } from '@typescript-eslint/utils'; import { AST_NODE_TYPES } from '@typescript-eslint/utils'; -import * as tsutils from 'ts-api-utils'; import * as ts from 'typescript'; -import { getConstrainedTypeAtLocation } from './index'; - /** * Inspect a call expression to see if it's a call to an assertion function. * If it is, return the node of the argument that is asserted. @@ -32,47 +29,26 @@ export function findTruthinessAssertedArgument( return undefined; } - // Game plan: we're going to check the type of the callee. If it has call - // signatures and they _ALL_ agree that they assert on a parameter at the - // _SAME_ position, we'll consider the argument in that position to be an - // asserted argument. - const calleeType = getConstrainedTypeAtLocation(services, node.callee); - const callSignatures = tsutils.getCallSignaturesOfType(calleeType); + const checker = services.program.getTypeChecker(); + const tsNode = services.esTreeNodeToTSNodeMap.get(node); + const signature = checker.getResolvedSignature(tsNode); - if (callSignatures.length === 0) { + if (signature == null) { return undefined; } - const checker = services.program.getTypeChecker(); - - const typePredicates = callSignatures.map(signature => - checker.getTypePredicateOfSignature(signature), - ); + const firstTypePredicateResult = + checker.getTypePredicateOfSignature(signature); - const [firstTypePredicateResult, ...otherTypePredicateResults] = - typePredicates; if (firstTypePredicateResult == null) { return undefined; } - // Ensure all call signatures are asserting the same thing. const { parameterIndex, kind, type } = firstTypePredicateResult; if (!(kind === ts.TypePredicateKind.AssertsIdentifier && type == null)) { return undefined; } - if ( - otherTypePredicateResults.some( - otherResult => - otherResult == null || - otherResult.parameterIndex !== parameterIndex || - otherResult.kind !== kind || - otherResult.type != null, - ) - ) { - return undefined; - } - return checkableArguments.at(parameterIndex); } @@ -106,47 +82,26 @@ export function findTypeGuardAssertedArgument( return undefined; } - // Game plan: we're going to check the type of the callee. If it has call - // signatures and they _ALL_ agree that they assert on a parameter at the - // _SAME_ position, we'll consider the argument in that position to be an - // asserted argument. - const calleeType = getConstrainedTypeAtLocation(services, node.callee); - const callSignatures = tsutils.getCallSignaturesOfType(calleeType); + const checker = services.program.getTypeChecker(); + const tsNode = services.esTreeNodeToTSNodeMap.get(node); + const callSignature = checker.getResolvedSignature(tsNode); - if (callSignatures.length === 0) { + if (callSignature == null) { return undefined; } - const checker = services.program.getTypeChecker(); - - const typePredicates = callSignatures.map(signature => - checker.getTypePredicateOfSignature(signature), - ); + const typePredicateInfo = checker.getTypePredicateOfSignature(callSignature); - const [firstTypePredicateResult, ...otherTypePredicateResults] = - typePredicates; - if (firstTypePredicateResult == null) { + if (typePredicateInfo == null) { return undefined; } - // Ensure all call signatures are asserting the same thing. - const { parameterIndex, kind, type } = firstTypePredicateResult; + const { parameterIndex, kind, type } = typePredicateInfo; if ( !( - (kind === ts.TypePredicateKind.AssertsIdentifier && type != null) || - (kind === ts.TypePredicateKind.Identifier && type != null) - ) - ) { - return undefined; - } - - if ( - otherTypePredicateResults.some( - otherResult => - otherResult == null || - otherResult.parameterIndex !== parameterIndex || - otherResult.kind !== kind || - otherResult.type !== type, + (kind === ts.TypePredicateKind.AssertsIdentifier || + kind === ts.TypePredicateKind.Identifier) && + type != null ) ) { return undefined; diff --git a/packages/eslint-plugin/tests/rules/strict-boolean-expressions.test.ts b/packages/eslint-plugin/tests/rules/strict-boolean-expressions.test.ts index f6585eecc52f..50395575a81f 100644 --- a/packages/eslint-plugin/tests/rules/strict-boolean-expressions.test.ts +++ b/packages/eslint-plugin/tests/rules/strict-boolean-expressions.test.ts @@ -439,14 +439,6 @@ assert(boo, nullableString); declare function assert(a: boolean, b: unknown): asserts b is string; declare const nullableString: string | null; declare const boo: boolean; -assert(boo, nullableString); - `, - // Intentional TS error - cannot assert a parameter in a binding pattern. - ` -declare function assert(a: boolean, b: unknown): asserts b; -declare function assert(a: boolean, { b }: { b: unknown }): asserts b; -declare const nullableString: string | null; -declare const boo: boolean; assert(boo, nullableString); `, ` @@ -516,16 +508,6 @@ declare const nullableString: string | null; assert(3 as any, nullableString); `, }, - ` -function assert(one: unknown): asserts one; -function assert(one: unknown, two: unknown): asserts two; -function assert(...args: unknown[]) { - throw new Error('not implemented'); -} -declare const nullableString: string | null; -assert(nullableString); -assert('one', nullableString); - `, // Intentional use of `any` to test a function call with no call signatures. ` declare const assert: any; @@ -2555,6 +2537,12 @@ assert(foo, Boolean(nullableString)); ], }, { + // This should be checkable, but the TS API doesn't currently report + // `someAssert(maybeString)` as a type predicate call, which appears to be + // a bug. + // + // See https://github.com/microsoft/TypeScript/issues/59707 + skip: true, code: ` function asserts1(x: string | number | undefined): asserts x {} function asserts2(x: string | number | undefined): asserts x {} @@ -2866,5 +2854,61 @@ assert(boo, Boolean(nullableString)); }, ], }, + { + // This report matches TS's analysis, which selects the assertion overload. + code: ` +function assert(one: unknown): asserts one; +function assert(one: unknown, two: unknown): asserts two; +function assert(...args: unknown[]) { + throw new Error('not implemented'); +} +declare const nullableString: string | null; +assert(nullableString); + `, + errors: [ + { + messageId: 'conditionErrorNullableString', + line: 8, + suggestions: [ + { + messageId: 'conditionFixCompareNullish', + output: ` +function assert(one: unknown): asserts one; +function assert(one: unknown, two: unknown): asserts two; +function assert(...args: unknown[]) { + throw new Error('not implemented'); +} +declare const nullableString: string | null; +assert(nullableString != null); + `, + }, + { + messageId: 'conditionFixDefaultEmptyString', + output: ` +function assert(one: unknown): asserts one; +function assert(one: unknown, two: unknown): asserts two; +function assert(...args: unknown[]) { + throw new Error('not implemented'); +} +declare const nullableString: string | null; +assert(nullableString ?? ""); + `, + }, + { + messageId: 'conditionFixCastBoolean', + output: ` +function assert(one: unknown): asserts one; +function assert(one: unknown, two: unknown): asserts two; +function assert(...args: unknown[]) { + throw new Error('not implemented'); +} +declare const nullableString: string | null; +assert(Boolean(nullableString)); + `, + }, + ], + }, + ], + }, ], }); From cd2b4612741278e5b8b44c64dad4a717665d0758 Mon Sep 17 00:00:00 2001 From: Kirk Waiblinger Date: Sun, 22 Sep 2024 22:43:45 -0600 Subject: [PATCH 11/14] finishing touches --- .../docs/rules/no-unnecessary-condition.mdx | 20 ++++++++++++++++++- .../rules/no-unnecessary-condition.test.ts | 4 ++-- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/packages/eslint-plugin/docs/rules/no-unnecessary-condition.mdx b/packages/eslint-plugin/docs/rules/no-unnecessary-condition.mdx index 78be72868387..befeea8b2401 100644 --- a/packages/eslint-plugin/docs/rules/no-unnecessary-condition.mdx +++ b/packages/eslint-plugin/docs/rules/no-unnecessary-condition.mdx @@ -105,10 +105,28 @@ assert(false); // Unnecessary; condition is always falsy. const neverNull = {}; assert(neverNull); // Unnecessary; condition is always truthy. + +function isString(value: unknown): value is string { + return typeof value === 'string'; +} + +declare const s: string; + +// Unnecessary; s is always a string. +if (isString(s)) { +} + +function assertIsString(value: unknown): asserts value is string { + if (!isString(value)) { + throw new Error('Value is not a string'); + } +} + +assertIsString(s); // Unnecessary; s is always a string. ``` Whether this option makes sense for your project may vary. -Some projects may intentionally use assertion functions to ensure that runtime values do indeed match the types according to TypeScript, especially in test code. +Some projects may intentionally use type predicates to ensure that runtime values do indeed match the types according to TypeScript, especially in test code. Often, it makes sense to use eslint-disable comments in these cases, with a comment indicating why the condition should be checked at runtime, despite appearing unnecessary. However, in some contexts, it may be more appropriate to keep this option disabled entirely. diff --git a/packages/eslint-plugin/tests/rules/no-unnecessary-condition.test.ts b/packages/eslint-plugin/tests/rules/no-unnecessary-condition.test.ts index 72786be464de..91e770cefef9 100644 --- a/packages/eslint-plugin/tests/rules/no-unnecessary-condition.test.ts +++ b/packages/eslint-plugin/tests/rules/no-unnecessary-condition.test.ts @@ -976,8 +976,8 @@ isString(a); { // Technically, this has type 'falafel' and not string. code: ` - declare function assertString(x: unknown): asserts x is string; - assertString('falafel'); +declare function assertString(x: unknown): asserts x is string; +assertString('falafel'); `, options: [{ checkTypePredicates: true }], }, From 12df5fc101871b30f1cfb39a70a48c8bf2388010 Mon Sep 17 00:00:00 2001 From: Kirk Waiblinger Date: Sun, 22 Sep 2024 22:52:00 -0600 Subject: [PATCH 12/14] snapshots --- .../no-unnecessary-condition.shot | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/packages/eslint-plugin/tests/docs-eslint-output-snapshots/no-unnecessary-condition.shot b/packages/eslint-plugin/tests/docs-eslint-output-snapshots/no-unnecessary-condition.shot index 5e9690f8f312..3ec0af465b4f 100644 --- a/packages/eslint-plugin/tests/docs-eslint-output-snapshots/no-unnecessary-condition.shot +++ b/packages/eslint-plugin/tests/docs-eslint-output-snapshots/no-unnecessary-condition.shot @@ -82,5 +82,25 @@ assert(false); // Unnecessary; condition is always falsy. const neverNull = {}; assert(neverNull); // Unnecessary; condition is always truthy. ~~~~~~~~~ Unnecessary conditional, value is always truthy. + +function isString(value: unknown): value is string { + return typeof value === 'string'; +} + +declare const s: string; + +// Unnecessary; s is always a string. +if (isString(s)) { + ~ Unnecessary conditional, expression already has the type being checked by the type guard. +} + +function assertIsString(value: unknown): asserts value is string { + if (!isString(value)) { + throw new Error('Value is not a string'); + } +} + +assertIsString(s); // Unnecessary; s is always a string. + ~ Unnecessary conditional, expression already has the type being checked by the assertion function. " `; From 8dbb540ff30903e6089dd833a88e6e35dec268b4 Mon Sep 17 00:00:00 2001 From: Kirk Waiblinger Date: Sun, 22 Sep 2024 23:18:46 -0600 Subject: [PATCH 13/14] fixup --- packages/eslint-plugin/src/rules/no-unnecessary-condition.ts | 2 +- .../tests/schema-snapshots/no-unnecessary-condition.shot | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/eslint-plugin/src/rules/no-unnecessary-condition.ts b/packages/eslint-plugin/src/rules/no-unnecessary-condition.ts index f92acd61d670..4befec262769 100644 --- a/packages/eslint-plugin/src/rules/no-unnecessary-condition.ts +++ b/packages/eslint-plugin/src/rules/no-unnecessary-condition.ts @@ -120,7 +120,7 @@ export default createRule({ }, checkTypePredicates: { description: - 'Whether to check the asserted argument of a truthiness assertion function as a conditional context', + 'Whether to check the asserted argument of a type predicate function for unnecessary conditions', type: 'boolean', }, }, diff --git a/packages/eslint-plugin/tests/schema-snapshots/no-unnecessary-condition.shot b/packages/eslint-plugin/tests/schema-snapshots/no-unnecessary-condition.shot index 7d51c2053fc4..824987f01dbd 100644 --- a/packages/eslint-plugin/tests/schema-snapshots/no-unnecessary-condition.shot +++ b/packages/eslint-plugin/tests/schema-snapshots/no-unnecessary-condition.shot @@ -17,7 +17,7 @@ exports[`Rule schemas should be convertible to TS types for documentation purpos "type": "boolean" }, "checkTypePredicates": { - "description": "Whether to check the asserted argument of a truthiness assertion function as a conditional context", + "description": "Whether to check the asserted argument of a type predicate function for unnecessary conditions", "type": "boolean" } }, @@ -34,7 +34,7 @@ type Options = [ allowConstantLoopConditions?: boolean; /** Whether to not error when running with a tsconfig that has strictNullChecks turned. */ allowRuleToRunWithoutStrictNullChecksIKnowWhatIAmDoing?: boolean; - /** Whether to check the asserted argument of a truthiness assertion function as a conditional context */ + /** Whether to check the asserted argument of a type predicate function for unnecessary conditions */ checkTypePredicates?: boolean; }, ]; From 42caf70de6b955d5ce7a3aebda895a939e558151 Mon Sep 17 00:00:00 2001 From: Kirk Waiblinger Date: Mon, 23 Sep 2024 18:13:14 -0600 Subject: [PATCH 14/14] remove type predicate --- packages/typescript-estree/tests/test-utils/test-utils.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/typescript-estree/tests/test-utils/test-utils.ts b/packages/typescript-estree/tests/test-utils/test-utils.ts index e4dc84ff780a..b307e755e880 100644 --- a/packages/typescript-estree/tests/test-utils/test-utils.ts +++ b/packages/typescript-estree/tests/test-utils/test-utils.ts @@ -83,7 +83,7 @@ export function deeplyCopy>(ast: T): T { type UnknownObject = Record; -function isObjectLike(value: unknown): value is UnknownObject { +function isObjectLike(value: unknown): boolean { return ( typeof value === 'object' && !(value instanceof RegExp) && value != null ); @@ -117,7 +117,6 @@ export function omitDeep( oNode: UnknownObject, parent: UnknownObject | null, ): UnknownObject { - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- I don't know if it's safe to change this. if (!Array.isArray(oNode) && !isObjectLike(oNode)) { return oNode; } @@ -140,7 +139,6 @@ export function omitDeep( value.push(visit(el, node)); } node[prop] = value; - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- I don't know if it's safe to change this. } else if (isObjectLike(child)) { node[prop] = visit(child, node); } pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy