Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(eslint-plugin): [no-unnecessary-boolean-literal-compare] add option to check nullable booleans #1983

Merged
merged 11 commits into from Jun 20, 2020
Expand Up @@ -36,6 +36,48 @@ if (someUndefinedCondition === false) {
}
```

## Options

The rule accepts an options object with the following properties:

```ts
type Options = {
// if true, only comparisons that compare a boolean literal to a boolean will be checked.
// if false, comparisons that compare a boolean literal to a nullable boolean variable will also be checked
allowComparingNullableBooleans?: boolean;
};

const defaults = {
allowComparingNullableBooleans: true,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
allowComparingNullableBooleans: true,
allowComparingNullableBooleans: false,

};
```

### `allowComparingNullableBooleans`

Examples of **incorrect** code for this rule with `{ allowComparingNullableBooleans: false }`:

```ts
declare const someUndefinedCondition: boolean | undefined;
if (someUndefinedCondition === true) {
}

declare const someNullCondition: boolean | null;
if (someNullCondition !== false) {
}
```

Examples of **correct** code for this rule with `{ allowComparingNullableBooleans: false }`:

```ts
declare const someUndefinedCondition: boolean | undefined;
if (someUndefinedCondition) {
}

declare const someNullCondition: boolean | null;
if (someNullCondition ?? true) {
}
```

## Related to

- TSLint: [no-boolean-literal-compare](https://palantir.github.io/tslint/rules/no-boolean-literal-compare)
Expand Up @@ -6,16 +6,32 @@ import * as tsutils from 'tsutils';
import * as ts from 'typescript';
import * as util from '../util';

type MessageIds = 'direct' | 'negated';
type MessageIds =
| 'direct'
| 'negated'
| 'comparingNullableToTrueDirect'
| 'comparingNullableToTrueNegated'
| 'comparingNullableToFalse';

type Options = [
{
allowComparingNullableBooleans?: boolean;
},
];

interface BooleanComparison {
expression: TSESTree.Expression;
literalBooleanInComparison: boolean;
forTruthy: boolean;
negated: boolean;
range: [number, number];
}

export default util.createRule<[], MessageIds>({
interface BooleanComparisonWithTypeInformation extends BooleanComparison {
expressionIsNullableBoolean: boolean;
}

export default util.createRule<Options, MessageIds>({
name: 'no-unnecessary-boolean-literal-compare',
meta: {
docs: {
Expand All @@ -31,18 +47,38 @@ export default util.createRule<[], MessageIds>({
'This expression unnecessarily compares a boolean value to a boolean instead of using it directly.',
negated:
'This expression unnecessarily compares a boolean value to a boolean instead of negating it.',
comparingNullableToTrueDirect:
'This expression unnecessarily compares a nullable boolean value to true instead of using it directly.',
comparingNullableToTrueNegated:
'This expression unnecessarily compares a nullable boolean value to true instead of negating it.',
comparingNullableToFalse:
'This expression unnecessarily compares a nullable boolean value to false instead of using the ?? operator to provide a default.',
},
schema: [],
schema: [
{
type: 'object',
properties: {
allowComparingNullableBooleans: {
type: 'boolean',
},
},
additionalProperties: false,
},
],
type: 'suggestion',
},
defaultOptions: [],
create(context) {
defaultOptions: [
{
allowComparingNullableBooleans: true,
zachkirsch marked this conversation as resolved.
Show resolved Hide resolved
},
],
create(context, [options]) {
const parserServices = util.getParserServices(context);
const checker = parserServices.program.getTypeChecker();

function getBooleanComparison(
node: TSESTree.BinaryExpression,
): BooleanComparison | undefined {
): BooleanComparisonWithTypeInformation | undefined {
const comparison = deconstructComparison(node);
if (!comparison) {
return undefined;
Expand All @@ -52,16 +88,67 @@ export default util.createRule<[], MessageIds>({
parserServices.esTreeNodeToTSNodeMap.get(comparison.expression),
);

if (
!tsutils.isTypeFlagSet(
expressionType,
ts.TypeFlags.Boolean | ts.TypeFlags.BooleanLiteral,
)
) {
return undefined;
if (isBooleanType(expressionType)) {
return {
...comparison,
expressionIsNullableBoolean: false,
};
}

return comparison;
if (isNullableBoolean(expressionType)) {
return {
...comparison,
expressionIsNullableBoolean: true,
};
}

return undefined;
}

function isBooleanType(expressionType: ts.Type): boolean {
return tsutils.isTypeFlagSet(
expressionType,
ts.TypeFlags.Boolean | ts.TypeFlags.BooleanLiteral,
);
}

/**
* checks if the expressionType is a union that
* 1) contains at least one nullish type (null or undefined)
* 2) contains at least once boolean type (true or false or boolean)
* 3) does not contain any types besides nullish and boolean types
*/
function isNullableBoolean(expressionType: ts.Type): boolean {
if (!expressionType.isUnion()) {
return false;
}

const { types } = expressionType;

const nonNullishTypes = types.filter(
type =>
!tsutils.isTypeFlagSet(
type,
ts.TypeFlags.Undefined | ts.TypeFlags.Null,
),
);

const hasNonNullishType = nonNullishTypes.length > 0;
if (!hasNonNullishType) {
return false;
}

const hasNullableType = nonNullishTypes.length < types.length;
if (!hasNullableType) {
return false;
}

const allNonNullishTypesAreBoolean = nonNullishTypes.every(isBooleanType);
if (!allNonNullishTypesAreBoolean) {
return false;
}

return true;
}

function deconstructComparison(
Expand All @@ -83,11 +170,12 @@ export default util.createRule<[], MessageIds>({
continue;
}

const { value } = against;
const negated = node.operator.startsWith('!');
const { value: literalBooleanInComparison } = against;
const negated = !comparisonType.isPositive;

return {
forTruthy: value ? !negated : negated,
literalBooleanInComparison,
forTruthy: literalBooleanInComparison ? !negated : negated,
expression,
negated,
range:
Expand All @@ -100,23 +188,77 @@ export default util.createRule<[], MessageIds>({
return undefined;
}

function nodeIsUnaryNegation(node: TSESTree.Node): boolean {
return (
node.type === AST_NODE_TYPES.UnaryExpression &&
node.prefix &&
node.operator === '!'
);
}

return {
BinaryExpression(node): void {
const comparison = getBooleanComparison(node);
if (comparison === undefined) {
return;
}

if (
comparison.expressionIsNullableBoolean &&
options.allowComparingNullableBooleans
) {
return;
}

if (comparison) {
context.report({
fix: function* (fixer) {
yield fixer.removeRange(comparison.range);
context.report({
fix: function* (fixer) {
yield fixer.removeRange(comparison.range);

// if the expression `exp` isn't nullable, or we're comparing to `true`,
// we can just replace the entire comparison with `exp` or `!exp`
if (
!comparison.expressionIsNullableBoolean ||
comparison.literalBooleanInComparison
) {
if (!comparison.forTruthy) {
yield fixer.insertTextBefore(node, '!');
}
},
messageId: comparison.negated ? 'negated' : 'direct',
node,
});
}
return;
}

// if we're here, then the expression is a nullable boolean and we're
// comparing to a literal `false`

// if we're doing `== false` or `=== false`, then we need to negate the expression
if (!comparison.negated) {
const { parent } = node;
// if the parent is a negation, we can instead just get rid of the parent's negation.
// i.e. instead of resulting in `!(!(exp))`, we can just result in `exp`
if (parent != null && nodeIsUnaryNegation(parent)) {
// remove from the beginning of the parent to the beginning of this node
yield fixer.removeRange([parent.range[0], node.range[0]]);
// remove from the end of the node to the end of the parent
yield fixer.removeRange([node.range[1], parent.range[1]]);
} else {
yield fixer.insertTextBefore(node, '!');
}
}

// provide the default `true`
yield fixer.insertTextBefore(node, '(');
yield fixer.insertTextAfter(node, ' ?? true)');
},
messageId: comparison.expressionIsNullableBoolean
? comparison.literalBooleanInComparison
? comparison.negated
? 'comparingNullableToTrueNegated'
: 'comparingNullableToTrueDirect'
: 'comparingNullableToFalse'
: comparison.negated
? 'negated'
: 'direct',
node,
});
},
};
},
Expand Down
2 changes: 1 addition & 1 deletion packages/eslint-plugin/src/util/types.ts
Expand Up @@ -305,7 +305,7 @@ export function getEqualsKind(operator: string): EqualsKind | undefined {

case '!==':
return {
isPositive: true,
isPositive: false,
isStrict: true,
};

Expand Down