Skip to content

Commit c0b3057

Browse files
authored
feat(eslint-plugin): [no-unnecessary-boolean-literal-compare] add option to check nullable booleans (typescript-eslint#1983)
1 parent c3753c2 commit c0b3057

File tree

4 files changed

+395
-29
lines changed

4 files changed

+395
-29
lines changed

packages/eslint-plugin/docs/rules/no-unnecessary-boolean-literal-compare.md

+93
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,12 @@ This rule ensures that you do not include unnecessary comparisons with boolean l
88
A comparison is considered unnecessary if it checks a boolean literal against any variable with just the `boolean` type.
99
A comparison is **_not_** considered unnecessary if the type is a union of booleans (`string | boolean`, `someObject | boolean`).
1010

11+
**Note**: Throughout this page, only strict equality (`===` and `!==`) are
12+
used in the examples. However, the implementation of the rule does not
13+
distinguish between strict and loose equality. Any example below that uses
14+
`===` would be treated the same way if `==` was used, and any example below
15+
that uses `!==` would be treated the same way if `!=` was used.
16+
1117
Examples of **incorrect** code for this rule:
1218

1319
```ts
@@ -30,12 +36,99 @@ if (someObjectBoolean === true) {
3036
declare const someStringBoolean: boolean | string;
3137
if (someStringBoolean === true) {
3238
}
39+
```
40+
41+
## Options
42+
43+
The rule accepts an options object with the following properties.
44+
45+
```ts
46+
type Options = {
47+
// if false, comparisons between a nullable boolean variable to `true` will be checked and fixed
48+
allowComparingNullableBooleansToTrue?: boolean;
49+
// if false, comparisons between a nullable boolean variable to `false` will be checked and fixed
50+
allowComparingNullableBooleansToFalse?: boolean;
51+
};
52+
```
53+
54+
### Defaults
55+
56+
This rule always checks comparisons between a boolean variable and a boolean
57+
literal. Comparisons between nullable boolean variables and boolean literals
58+
are **not** checked by default.
59+
60+
```ts
61+
const defaults = {
62+
allowComparingNullableBooleansToTrue: true,
63+
allowComparingNullableBooleansToFalse: true,
64+
};
65+
```
66+
67+
### `allowComparingNullableBooleansToTrue`
68+
69+
Examples of **incorrect** code for this rule with `{ allowComparingNullableBooleansToTrue: false }`:
70+
71+
```ts
72+
declare const someUndefinedCondition: boolean | undefined;
73+
if (someUndefinedCondition === true) {
74+
}
75+
76+
declare const someNullCondition: boolean | null;
77+
if (someNullCondition !== true) {
78+
}
79+
```
80+
81+
Examples of **correct** code for this rule with `{ allowComparingNullableBooleansToTrue: false }`:
82+
83+
```ts
84+
declare const someUndefinedCondition: boolean | undefined;
85+
if (someUndefinedCondition) {
86+
}
3387

88+
declare const someNullCondition: boolean | null;
89+
if (!someNullCondition) {
90+
}
91+
```
92+
93+
### `allowComparingNullableBooleansToFalse`
94+
95+
Examples of **incorrect** code for this rule with `{ allowComparingNullableBooleansToFalse: false }`:
96+
97+
```ts
3498
declare const someUndefinedCondition: boolean | undefined;
3599
if (someUndefinedCondition === false) {
36100
}
101+
102+
declare const someNullCondition: boolean | null;
103+
if (someNullCondition !== false) {
104+
}
37105
```
38106

107+
Examples of **correct** code for this rule with `{ allowComparingNullableBooleansToFalse: false }`:
108+
109+
```ts
110+
declare const someUndefinedCondition: boolean | undefined;
111+
if (someUndefinedCondition ?? true) {
112+
}
113+
114+
declare const someNullCondition: boolean | null;
115+
if (!(someNullCondition ?? true)) {
116+
}
117+
```
118+
119+
## Fixer
120+
121+
| Comparison | Fixer Output | Notes |
122+
| :----------------------------: | ------------------------------- | ----------------------------------------------------------------------------------- |
123+
| `booleanVar === true` | `booleanLiteral` | |
124+
| `booleanVar !== true` | `!booleanLiteral` | |
125+
| `booleanVar === false` | `!booleanLiteral` | |
126+
| `booleanVar !== false` | `booleanLiteral` | |
127+
| `nullableBooleanVar === true` | `nullableBooleanVar` | Only checked/fixed if the `allowComparingNullableBooleansToTrue` option is `false` |
128+
| `nullableBooleanVar !== true` | `!nullableBooleanVar` | Only checked/fixed if the `allowComparingNullableBooleansToTrue` option is `false` |
129+
| `nullableBooleanVar === false` | `nullableBooleanVar ?? true` | Only checked/fixed if the `allowComparingNullableBooleansToFalse` option is `false` |
130+
| `nullableBooleanVar !== false` | `!(nullableBooleanVar ?? true)` | Only checked/fixed if the `allowComparingNullableBooleansToFalse` option is `false` |
131+
39132
## Related to
40133

41134
- TSLint: [no-boolean-literal-compare](https://palantir.github.io/tslint/rules/no-boolean-literal-compare)

packages/eslint-plugin/src/rules/no-unnecessary-boolean-literal-compare.ts

+181-26
Original file line numberDiff line numberDiff line change
@@ -6,16 +6,33 @@ import * as tsutils from 'tsutils';
66
import * as ts from 'typescript';
77
import * as util from '../util';
88

9-
type MessageIds = 'direct' | 'negated';
9+
type MessageIds =
10+
| 'direct'
11+
| 'negated'
12+
| 'comparingNullableToTrueDirect'
13+
| 'comparingNullableToTrueNegated'
14+
| 'comparingNullableToFalse';
15+
16+
type Options = [
17+
{
18+
allowComparingNullableBooleansToTrue?: boolean;
19+
allowComparingNullableBooleansToFalse?: boolean;
20+
},
21+
];
1022

1123
interface BooleanComparison {
1224
expression: TSESTree.Expression;
25+
literalBooleanInComparison: boolean;
1326
forTruthy: boolean;
1427
negated: boolean;
1528
range: [number, number];
1629
}
1730

18-
export default util.createRule<[], MessageIds>({
31+
interface BooleanComparisonWithTypeInformation extends BooleanComparison {
32+
expressionIsNullableBoolean: boolean;
33+
}
34+
35+
export default util.createRule<Options, MessageIds>({
1936
name: 'no-unnecessary-boolean-literal-compare',
2037
meta: {
2138
docs: {
@@ -31,18 +48,42 @@ export default util.createRule<[], MessageIds>({
3148
'This expression unnecessarily compares a boolean value to a boolean instead of using it directly.',
3249
negated:
3350
'This expression unnecessarily compares a boolean value to a boolean instead of negating it.',
51+
comparingNullableToTrueDirect:
52+
'This expression unnecessarily compares a nullable boolean value to true instead of using it directly.',
53+
comparingNullableToTrueNegated:
54+
'This expression unnecessarily compares a nullable boolean value to true instead of negating it.',
55+
comparingNullableToFalse:
56+
'This expression unnecessarily compares a nullable boolean value to false instead of using the ?? operator to provide a default.',
3457
},
35-
schema: [],
58+
schema: [
59+
{
60+
type: 'object',
61+
properties: {
62+
allowComparingNullableBooleansToTrue: {
63+
type: 'boolean',
64+
},
65+
allowComparingNullableBooleansToFalse: {
66+
type: 'boolean',
67+
},
68+
},
69+
additionalProperties: false,
70+
},
71+
],
3672
type: 'suggestion',
3773
},
38-
defaultOptions: [],
39-
create(context) {
74+
defaultOptions: [
75+
{
76+
allowComparingNullableBooleansToTrue: true,
77+
allowComparingNullableBooleansToFalse: true,
78+
},
79+
],
80+
create(context, [options]) {
4081
const parserServices = util.getParserServices(context);
4182
const checker = parserServices.program.getTypeChecker();
4283

4384
function getBooleanComparison(
4485
node: TSESTree.BinaryExpression,
45-
): BooleanComparison | undefined {
86+
): BooleanComparisonWithTypeInformation | undefined {
4687
const comparison = deconstructComparison(node);
4788
if (!comparison) {
4889
return undefined;
@@ -52,16 +93,67 @@ export default util.createRule<[], MessageIds>({
5293
parserServices.esTreeNodeToTSNodeMap.get(comparison.expression),
5394
);
5495

55-
if (
56-
!tsutils.isTypeFlagSet(
57-
expressionType,
58-
ts.TypeFlags.Boolean | ts.TypeFlags.BooleanLiteral,
59-
)
60-
) {
61-
return undefined;
96+
if (isBooleanType(expressionType)) {
97+
return {
98+
...comparison,
99+
expressionIsNullableBoolean: false,
100+
};
101+
}
102+
103+
if (isNullableBoolean(expressionType)) {
104+
return {
105+
...comparison,
106+
expressionIsNullableBoolean: true,
107+
};
62108
}
63109

64-
return comparison;
110+
return undefined;
111+
}
112+
113+
function isBooleanType(expressionType: ts.Type): boolean {
114+
return tsutils.isTypeFlagSet(
115+
expressionType,
116+
ts.TypeFlags.Boolean | ts.TypeFlags.BooleanLiteral,
117+
);
118+
}
119+
120+
/**
121+
* checks if the expressionType is a union that
122+
* 1) contains at least one nullish type (null or undefined)
123+
* 2) contains at least once boolean type (true or false or boolean)
124+
* 3) does not contain any types besides nullish and boolean types
125+
*/
126+
function isNullableBoolean(expressionType: ts.Type): boolean {
127+
if (!expressionType.isUnion()) {
128+
return false;
129+
}
130+
131+
const { types } = expressionType;
132+
133+
const nonNullishTypes = types.filter(
134+
type =>
135+
!tsutils.isTypeFlagSet(
136+
type,
137+
ts.TypeFlags.Undefined | ts.TypeFlags.Null,
138+
),
139+
);
140+
141+
const hasNonNullishType = nonNullishTypes.length > 0;
142+
if (!hasNonNullishType) {
143+
return false;
144+
}
145+
146+
const hasNullableType = nonNullishTypes.length < types.length;
147+
if (!hasNullableType) {
148+
return false;
149+
}
150+
151+
const allNonNullishTypesAreBoolean = nonNullishTypes.every(isBooleanType);
152+
if (!allNonNullishTypesAreBoolean) {
153+
return false;
154+
}
155+
156+
return true;
65157
}
66158

67159
function deconstructComparison(
@@ -83,11 +175,12 @@ export default util.createRule<[], MessageIds>({
83175
continue;
84176
}
85177

86-
const { value } = against;
87-
const negated = node.operator.startsWith('!');
178+
const { value: literalBooleanInComparison } = against;
179+
const negated = !comparisonType.isPositive;
88180

89181
return {
90-
forTruthy: value ? !negated : negated,
182+
literalBooleanInComparison,
183+
forTruthy: literalBooleanInComparison ? !negated : negated,
91184
expression,
92185
negated,
93186
range:
@@ -100,23 +193,85 @@ export default util.createRule<[], MessageIds>({
100193
return undefined;
101194
}
102195

196+
function nodeIsUnaryNegation(node: TSESTree.Node): boolean {
197+
return (
198+
node.type === AST_NODE_TYPES.UnaryExpression &&
199+
node.prefix &&
200+
node.operator === '!'
201+
);
202+
}
203+
103204
return {
104205
BinaryExpression(node): void {
105206
const comparison = getBooleanComparison(node);
207+
if (comparison === undefined) {
208+
return;
209+
}
106210

107-
if (comparison) {
108-
context.report({
109-
fix: function* (fixer) {
110-
yield fixer.removeRange(comparison.range);
211+
if (comparison.expressionIsNullableBoolean) {
212+
if (
213+
comparison.literalBooleanInComparison &&
214+
options.allowComparingNullableBooleansToTrue
215+
) {
216+
return;
217+
}
218+
if (
219+
!comparison.literalBooleanInComparison &&
220+
options.allowComparingNullableBooleansToFalse
221+
) {
222+
return;
223+
}
224+
}
111225

226+
context.report({
227+
fix: function* (fixer) {
228+
yield fixer.removeRange(comparison.range);
229+
230+
// if the expression `exp` isn't nullable, or we're comparing to `true`,
231+
// we can just replace the entire comparison with `exp` or `!exp`
232+
if (
233+
!comparison.expressionIsNullableBoolean ||
234+
comparison.literalBooleanInComparison
235+
) {
112236
if (!comparison.forTruthy) {
113237
yield fixer.insertTextBefore(node, '!');
114238
}
115-
},
116-
messageId: comparison.negated ? 'negated' : 'direct',
117-
node,
118-
});
119-
}
239+
return;
240+
}
241+
242+
// if we're here, then the expression is a nullable boolean and we're
243+
// comparing to a literal `false`
244+
245+
// if we're doing `== false` or `=== false`, then we need to negate the expression
246+
if (!comparison.negated) {
247+
const { parent } = node;
248+
// if the parent is a negation, we can instead just get rid of the parent's negation.
249+
// i.e. instead of resulting in `!(!(exp))`, we can just result in `exp`
250+
if (parent != null && nodeIsUnaryNegation(parent)) {
251+
// remove from the beginning of the parent to the beginning of this node
252+
yield fixer.removeRange([parent.range[0], node.range[0]]);
253+
// remove from the end of the node to the end of the parent
254+
yield fixer.removeRange([node.range[1], parent.range[1]]);
255+
} else {
256+
yield fixer.insertTextBefore(node, '!');
257+
}
258+
}
259+
260+
// provide the default `true`
261+
yield fixer.insertTextBefore(node, '(');
262+
yield fixer.insertTextAfter(node, ' ?? true)');
263+
},
264+
messageId: comparison.expressionIsNullableBoolean
265+
? comparison.literalBooleanInComparison
266+
? comparison.negated
267+
? 'comparingNullableToTrueNegated'
268+
: 'comparingNullableToTrueDirect'
269+
: 'comparingNullableToFalse'
270+
: comparison.negated
271+
? 'negated'
272+
: 'direct',
273+
node,
274+
});
120275
},
121276
};
122277
},

packages/eslint-plugin/src/util/types.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -308,7 +308,7 @@ export function getEqualsKind(operator: string): EqualsKind | undefined {
308308

309309
case '!==':
310310
return {
311-
isPositive: true,
311+
isPositive: false,
312312
isStrict: true,
313313
};
314314

0 commit comments

Comments
 (0)