Skip to content

Commit 07d0e0d

Browse files
feat(eslint-plugin): [strict-boolean-expressions] check array predicate functions' return statements (typescript-eslint#10106)
* initial implementation * tests * refactor * some more tests * update docs * update snapshots * handle implicit or explicit undefined return types * refactor and update tests * remove unnecessary union check * inline check * cover empty return staetments * report a function as returning undefined only if no other isssues were found * update comments * add test * remove unnecessary test * update code to match function's inferred return type rather than each return statement individually * update tests to match the implementation changes * adjust suggestion message * final adjustments * final adjustments #2 * test additions * update snapshots * Update packages/eslint-plugin/docs/rules/strict-boolean-expressions.mdx Co-authored-by: Kirk Waiblinger <[email protected]> * initial implementation of deducing the correct signature for non-function-expressions * comments * take type constraints into consideration * only check type constraints on type parameters * update tests * update index tests * update snapshot * simplify code a bit * remove overly complex heuristic * use an existing helper rather than implementing one * update tests * Update packages/eslint-plugin/src/rules/strict-boolean-expressions.ts Co-authored-by: Kirk Waiblinger <[email protected]> * fix codecov * cleanup old code, support type constraints * refactor fixer * remove unnecessary tests * revert changes to isParenlessArrowFunction * oops --------- Co-authored-by: Kirk Waiblinger <[email protected]>
1 parent 5a39e0c commit 07d0e0d

File tree

4 files changed

+548
-8
lines changed

4 files changed

+548
-8
lines changed

packages/eslint-plugin/docs/rules/strict-boolean-expressions.mdx

+4
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ The following nodes are considered boolean expressions and their type is checked
2222
- Right-hand side operand is ignored when it's not a descendant of another boolean expression.
2323
This is to allow usage of boolean operators for their short-circuiting behavior.
2424
- Asserted argument of an assertion function (`assert(arg)`).
25+
- Return type of array predicate functions such as `filter()`, `some()`, etc.
2526

2627
## Examples
2728

@@ -61,6 +62,9 @@ while (obj) {
6162
declare function assert(value: unknown): asserts value;
6263
let maybeString = Math.random() > 0.5 ? '' : undefined;
6364
assert(maybeString);
65+
66+
// array predicates' return types are boolean contexts.
67+
['one', null].filter(x => x);
6468
```
6569

6670
</TabItem>

packages/eslint-plugin/src/rules/strict-boolean-expressions.ts

+119-8
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import type {
33
TSESTree,
44
} from '@typescript-eslint/utils';
55

6-
import { AST_NODE_TYPES } from '@typescript-eslint/utils';
6+
import { AST_NODE_TYPES, ASTUtils } from '@typescript-eslint/utils';
77
import * as tsutils from 'ts-api-utils';
88
import * as ts from 'typescript';
99

@@ -12,7 +12,10 @@ import {
1212
getConstrainedTypeAtLocation,
1313
getParserServices,
1414
getWrappingFixer,
15+
isArrayMethodCallWithPredicate,
16+
isParenlessArrowFunction,
1517
isTypeArrayTypeOrUnionOfArrayTypes,
18+
nullThrows,
1619
} from '../util';
1720
import { findTruthinessAssertedArgument } from '../util/assertionFunctionUtils';
1821

@@ -53,7 +56,10 @@ export type MessageId =
5356
| 'conditionFixDefaultEmptyString'
5457
| 'conditionFixDefaultFalse'
5558
| 'conditionFixDefaultZero'
56-
| 'noStrictNullCheck';
59+
| 'explicitBooleanReturnType'
60+
| 'noStrictNullCheck'
61+
| 'predicateCannotBeAsync'
62+
| 'predicateReturnsNonBoolean';
5763

5864
export default createRule<Options, MessageId>({
5965
name: 'strict-boolean-expressions',
@@ -122,8 +128,13 @@ export default createRule<Options, MessageId>({
122128
'Explicitly treat nullish value the same as false (`value ?? false`)',
123129
conditionFixDefaultZero:
124130
'Explicitly treat nullish value the same as 0 (`value ?? 0`)',
131+
explicitBooleanReturnType:
132+
'Add an explicit `boolean` return type annotation.',
125133
noStrictNullCheck:
126134
'This rule requires the `strictNullChecks` compiler option to be turned on to function correctly.',
135+
predicateCannotBeAsync:
136+
"Predicate function should not be 'async'; expected a boolean return type.",
137+
predicateReturnsNonBoolean: 'Predicate function should return a boolean.',
127138
},
128139
schema: [
129140
{
@@ -275,6 +286,104 @@ export default createRule<Options, MessageId>({
275286
if (assertedArgument != null) {
276287
traverseNode(assertedArgument, true);
277288
}
289+
if (isArrayMethodCallWithPredicate(context, services, node)) {
290+
const predicate = node.arguments.at(0);
291+
292+
if (predicate) {
293+
checkArrayMethodCallPredicate(predicate);
294+
}
295+
}
296+
}
297+
298+
/**
299+
* Dedicated function to check array method predicate calls. Reports predicate
300+
* arguments that don't return a boolean value.
301+
*
302+
* Ignores the `allow*` options and requires a boolean value.
303+
*/
304+
function checkArrayMethodCallPredicate(
305+
predicateNode: TSESTree.CallExpressionArgument,
306+
): void {
307+
const isFunctionExpression = ASTUtils.isFunction(predicateNode);
308+
309+
// custom message for accidental `async` function expressions
310+
if (isFunctionExpression && predicateNode.async) {
311+
return context.report({
312+
node: predicateNode,
313+
messageId: 'predicateCannotBeAsync',
314+
});
315+
}
316+
317+
const returnTypes = services
318+
.getTypeAtLocation(predicateNode)
319+
.getCallSignatures()
320+
.map(signature => {
321+
const type = signature.getReturnType();
322+
323+
if (tsutils.isTypeParameter(type)) {
324+
return checker.getBaseConstraintOfType(type) ?? type;
325+
}
326+
327+
return type;
328+
});
329+
330+
if (returnTypes.every(returnType => isBooleanType(returnType))) {
331+
return;
332+
}
333+
334+
const canFix = isFunctionExpression && !predicateNode.returnType;
335+
336+
return context.report({
337+
node: predicateNode,
338+
messageId: 'predicateReturnsNonBoolean',
339+
suggest: canFix
340+
? [
341+
{
342+
messageId: 'explicitBooleanReturnType',
343+
fix: fixer => {
344+
if (
345+
predicateNode.type ===
346+
AST_NODE_TYPES.ArrowFunctionExpression &&
347+
isParenlessArrowFunction(predicateNode, context.sourceCode)
348+
) {
349+
return [
350+
fixer.insertTextBefore(predicateNode.params[0], '('),
351+
fixer.insertTextAfter(
352+
predicateNode.params[0],
353+
'): boolean',
354+
),
355+
];
356+
}
357+
358+
if (predicateNode.params.length === 0) {
359+
const closingBracket = nullThrows(
360+
context.sourceCode.getFirstToken(
361+
predicateNode,
362+
token => token.value === ')',
363+
),
364+
'function expression has to have a closing parenthesis.',
365+
);
366+
367+
return fixer.insertTextAfter(closingBracket, ': boolean');
368+
}
369+
370+
const lastClosingParenthesis = nullThrows(
371+
context.sourceCode.getTokenAfter(
372+
predicateNode.params[predicateNode.params.length - 1],
373+
token => token.value === ')',
374+
),
375+
'function expression has to have a closing parenthesis.',
376+
);
377+
378+
return fixer.insertTextAfter(
379+
lastClosingParenthesis,
380+
': boolean',
381+
);
382+
},
383+
},
384+
]
385+
: null,
386+
});
278387
}
279388

280389
/**
@@ -1007,11 +1116,13 @@ function isArrayLengthExpression(
10071116
function isBrandedBoolean(type: ts.Type): boolean {
10081117
return (
10091118
type.isIntersection() &&
1010-
type.types.some(childType =>
1011-
tsutils.isTypeFlagSet(
1012-
childType,
1013-
ts.TypeFlags.BooleanLiteral | ts.TypeFlags.Boolean,
1014-
),
1015-
)
1119+
type.types.some(childType => isBooleanType(childType))
1120+
);
1121+
}
1122+
1123+
function isBooleanType(expressionType: ts.Type): boolean {
1124+
return tsutils.isTypeFlagSet(
1125+
expressionType,
1126+
ts.TypeFlags.Boolean | ts.TypeFlags.BooleanLiteral,
10161127
);
10171128
}

packages/eslint-plugin/tests/docs-eslint-output-snapshots/strict-boolean-expressions.shot

+4
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)