Skip to content

Commit

Permalink
feat(eslint-plugin): [no-unnecessary-condition] add checkTypePredicat…
Browse files Browse the repository at this point in the history
…es (#10009)

* [no-unnecessary-condition]: add checkTruthinessAssertions

* test -u

* change internal reports

* added some coverage

* hugely simplify

* uge

* yooj

* changes

* some changes

* test changes

* finishing touches

* snapshots

* fixup

* remove type predicate
  • Loading branch information
kirkwaiblinger authored Sep 29, 2024
1 parent 032918a commit a916ff2
Show file tree
Hide file tree
Showing 16 changed files with 525 additions and 187 deletions.
2 changes: 1 addition & 1 deletion eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ export default tseslint.config(
'no-constant-condition': 'off',
'@typescript-eslint/no-unnecessary-condition': [
'error',
{ allowConstantLoopConditions: true },
{ allowConstantLoopConditions: true, checkTypePredicates: true },
],
'@typescript-eslint/no-unnecessary-type-parameters': 'error',
'@typescript-eslint/no-unused-expressions': 'error',
Expand Down
40 changes: 40 additions & 0 deletions packages/eslint-plugin/docs/rules/no-unnecessary-condition.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,46 @@ for (; true; ) {}
do {} while (true);
```

### `checkTypePredicates`

Example of additional incorrect code with `{ checkTypePredicates: true }`:

```ts option='{ "checkTypePredicates": 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.

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 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.

### `allowRuleToRunWithoutStrictNullChecksIKnowWhatIAmDoing`

:::danger Deprecated
Expand Down
49 changes: 49 additions & 0 deletions packages/eslint-plugin/src/rules/no-unnecessary-condition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ import {
nullThrows,
NullThrowsReasons,
} from '../util';
import {
findTruthinessAssertedArgument,
findTypeGuardAssertedArgument,
} from '../util/assertionFunctionUtils';

// Truthiness utilities
// #region
Expand Down Expand Up @@ -71,6 +75,7 @@ export type Options = [
{
allowConstantLoopConditions?: boolean;
allowRuleToRunWithoutStrictNullChecksIKnowWhatIAmDoing?: boolean;
checkTypePredicates?: boolean;
},
];

Expand All @@ -81,6 +86,8 @@ export type MessageId =
| 'alwaysTruthy'
| 'alwaysTruthyFunc'
| 'literalBooleanExpression'
| 'typeGuardAlreadyIsType'
| 'replaceWithTrue'
| 'never'
| 'neverNullish'
| 'neverOptionalChain'
Expand Down Expand Up @@ -111,6 +118,11 @@ export default createRule<Options, MessageId>({
'Whether to not error when running with a tsconfig that has strictNullChecks turned.',
type: 'boolean',
},
checkTypePredicates: {
description:
'Whether to check the asserted argument of a type predicate function for unnecessary conditions',
type: 'boolean',
},
},
additionalProperties: false,
},
Expand All @@ -129,18 +141,22 @@ export default createRule<Options, MessageId>({
'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 by the {{typeGuardOrAssertionFunction}}.',
},
},
defaultOptions: [
{
allowConstantLoopConditions: false,
allowRuleToRunWithoutStrictNullChecksIKnowWhatIAmDoing: false,
checkTypePredicates: false,
},
],
create(
Expand All @@ -149,6 +165,7 @@ export default createRule<Options, MessageId>({
{
allowConstantLoopConditions,
allowRuleToRunWithoutStrictNullChecksIKnowWhatIAmDoing,
checkTypePredicates,
},
],
) {
Expand Down Expand Up @@ -463,6 +480,38 @@ export default createRule<Options, MessageId>({
}

function checkCallExpression(node: TSESTree.CallExpression): void {
if (checkTypePredicates) {
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({
node: typeGuardAssertedArgument.argument,
messageId: 'typeGuardAlreadyIsType',
data: {
typeGuardOrAssertionFunction: typeGuardAssertedArgument.asserts
? 'assertion function'
: 'type guard',
},
});
}
}
}

// If this is something like arr.filter(x => /*condition*/), check `condition`
if (
isArrayMethodCallWithPredicate(context, services, node) &&
Expand Down
7 changes: 1 addition & 6 deletions packages/eslint-plugin/src/rules/prefer-optional-chain.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -130,14 +129,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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -535,11 +535,7 @@ export default createRule<Options, MessageIds>({
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;
}

Expand Down
125 changes: 2 additions & 123 deletions packages/eslint-plugin/src/rules/strict-boolean-expressions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
getWrappingFixer,
isTypeArrayTypeOrUnionOfArrayTypes,
} from '../util';
import { findTruthinessAssertedArgument } from '../util/assertionFunctionUtils';

export type Options = [
{
Expand Down Expand Up @@ -267,134 +268,12 @@ export default createRule<Options, MessageId>({
}

function traverseCallExpression(node: TSESTree.CallExpression): void {
const assertedArgument = findAssertedArgument(node);
const assertedArgument = findTruthinessAssertedArgument(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.
*
Expand Down
Loading

0 comments on commit a916ff2

Please sign in to comment.