Skip to content

Commit a9cd399

Browse files
authored
feat(eslint-plugin): add prefer-nullish-coalescing (#1069)
1 parent b91b0ba commit a9cd399

File tree

7 files changed

+761
-3
lines changed

7 files changed

+761
-3
lines changed

Diff for: packages/eslint-plugin/README.md

+1
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,7 @@ Then you should add `airbnb` (or `airbnb-base`) to your `extends` section of `.e
194194
| [`@typescript-eslint/prefer-function-type`](./docs/rules/prefer-function-type.md) | Use function types instead of interfaces with call signatures | | :wrench: | |
195195
| [`@typescript-eslint/prefer-includes`](./docs/rules/prefer-includes.md) | Enforce `includes` method over `indexOf` method | :heavy_check_mark: | :wrench: | :thought_balloon: |
196196
| [`@typescript-eslint/prefer-namespace-keyword`](./docs/rules/prefer-namespace-keyword.md) | Require the use of the `namespace` keyword instead of the `module` keyword to declare custom TypeScript modules | :heavy_check_mark: | :wrench: | |
197+
| [`@typescript-eslint/prefer-nullish-coalescing`](./docs/rules/prefer-nullish-coalescing.md) | Enforce the usage of the nullish coalescing operator instead of logical chaining | | :wrench: | :thought_balloon: |
197198
| [`@typescript-eslint/prefer-optional-chain`](./docs/rules/prefer-optional-chain.md) | Prefer using concise optional chain expressions instead of chained logical ands | | :wrench: | |
198199
| [`@typescript-eslint/prefer-readonly`](./docs/rules/prefer-readonly.md) | Requires that private members are marked as `readonly` if they're never modified outside of the constructor | | :wrench: | :thought_balloon: |
199200
| [`@typescript-eslint/prefer-regexp-exec`](./docs/rules/prefer-regexp-exec.md) | Prefer RegExp#exec() over String#match() if no global flag is provided | :heavy_check_mark: | | :thought_balloon: |
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
# Enforce the usage of the nullish coalescing operator instead of logical chaining (prefer-nullish-coalescing)
2+
3+
TypeScript 3.7 added support for the nullish coalescing operator.
4+
This operator allows you to safely cascade a value when dealing with `null` or `undefined`.
5+
6+
```ts
7+
function myFunc(foo: string | null) {
8+
return foo ?? 'a string';
9+
}
10+
11+
// is equivalent to
12+
13+
function myFunc(foo: string | null) {
14+
return foo !== null && foo !== undefined ? foo : 'a string';
15+
}
16+
```
17+
18+
Because the nullish coalescing operator _only_ coalesces when the original value is `null` or `undefined`, it is much safer than relying upon logical OR operator chaining `||`; which coalesces on any _falsey_ value:
19+
20+
```ts
21+
const emptyString = '';
22+
23+
const nullish1 = emptyString ?? 'unsafe';
24+
const logical1 = emptyString || 'unsafe';
25+
26+
// nullish1 === ''
27+
// logical1 === 'unsafe'
28+
29+
declare const nullString: string | null;
30+
31+
const nullish2 = nullString ?? 'safe';
32+
const logical2 = nullString || 'safe';
33+
34+
// nullish2 === 'safe'
35+
// logical2 === 'safe'
36+
```
37+
38+
## Rule Details
39+
40+
This rule aims enforce the usage of the safer operator.
41+
42+
## Options
43+
44+
```ts
45+
type Options = [
46+
{
47+
ignoreConditionalTests?: boolean;
48+
ignoreMixedLogicalExpressions?: boolean;
49+
},
50+
];
51+
52+
const defaultOptions = [
53+
{
54+
ignoreConditionalTests: true,
55+
ignoreMixedLogicalExpressions: true;
56+
},
57+
];
58+
```
59+
60+
### ignoreConditionalTests
61+
62+
Setting this option to `true` (the default) will cause the rule to ignore any cases that are located within a conditional test.
63+
64+
Generally expressions within conditional tests intentionally use the falsey fallthrough behaviour of the logical or operator, meaning that fixing the operator to the nullish coalesce operator could cause bugs.
65+
66+
If you're looking to enforce stricter conditional tests, you should consider using the `strict-boolean-expressions` rule.
67+
68+
Incorrect code for `ignoreConditionalTests: false`, and correct code for `ignoreConditionalTests: true`:
69+
70+
```ts
71+
declare const a: string | null;
72+
declare const b: string | null;
73+
74+
if (a || b) {
75+
}
76+
while (a || b) {}
77+
do {} while (a || b);
78+
for (let i = 0; a || b; i += 1) {}
79+
a || b ? true : false;
80+
```
81+
82+
Correct code for `ignoreConditionalTests: false`:
83+
84+
```ts
85+
declare const a: string | null;
86+
declare const b: string | null;
87+
88+
if (a ?? b) {
89+
}
90+
while (a ?? b) {}
91+
do {} while (a ?? b);
92+
for (let i = 0; a ?? b; i += 1) {}
93+
a ?? b ? true : false;
94+
```
95+
96+
### ignoreMixedLogicalExpressions
97+
98+
Setting this option to `true` (the default) will cause the rule to ignore any logical or expressions thare are part of a mixed logical expression (with `&&`).
99+
100+
Generally expressions within mixed logical expressions intentionally use the falsey fallthrough behaviour of the logical or operator, meaning that fixing the operator to the nullish coalesce operator could cause bugs.
101+
102+
If you're looking to enforce stricter conditional tests, you should consider using the `strict-boolean-expressions` rule.
103+
104+
Incorrect code for `ignoreMixedLogicalExpressions: false`, and correct code for `ignoreMixedLogicalExpressions: true`:
105+
106+
```ts
107+
declare const a: string | null;
108+
declare const b: string | null;
109+
declare const c: string | null;
110+
declare const d: string | null;
111+
112+
a || (b && c);
113+
(a && b) || c || d;
114+
a || (b && c) || d;
115+
a || (b && c && d);
116+
```
117+
118+
Correct code for `ignoreMixedLogicalExpressions: false`:
119+
120+
```ts
121+
declare const a: string | null;
122+
declare const b: string | null;
123+
declare const c: string | null;
124+
declare const d: string | null;
125+
126+
a ?? (b && c);
127+
(a && b) ?? c ?? d;
128+
a ?? (b && c) ?? d;
129+
a ?? (b && c && d);
130+
```
131+
132+
**_NOTE:_** Errors for this specific case will be presented as suggestions, instead of fixes. This is because it is not always safe to automatically convert `||` to `??` within a mixed logical expression, as we cannot tell the intended precedence of the operator. Note that by design, `??` requires parentheses when used with `&&` or `||` in the same expression.
133+
134+
## When Not To Use It
135+
136+
If you are not using TypeScript 3.7 (or greater), then you will not be able to use this rule, as the operator is not supported.
137+
138+
## Further Reading
139+
140+
- [TypeScript 3.7 Release Notes](https://www.typescriptlang.org/docs/handbook/release-notes/typescript-3-7.html)
141+
- [Nullish Coalescing Operator Proposal](https://github.com/tc39/proposal-nullish-coalescing/)

Diff for: packages/eslint-plugin/src/configs/all.json

+1
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@
6767
"@typescript-eslint/prefer-function-type": "error",
6868
"@typescript-eslint/prefer-includes": "error",
6969
"@typescript-eslint/prefer-namespace-keyword": "error",
70+
"@typescript-eslint/prefer-nullish-coalescing": "error",
7071
"@typescript-eslint/prefer-optional-chain": "error",
7172
"@typescript-eslint/prefer-readonly": "error",
7273
"@typescript-eslint/prefer-regexp-exec": "error",

Diff for: packages/eslint-plugin/src/rules/index.ts

+2
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ import preferForOf from './prefer-for-of';
5252
import preferFunctionType from './prefer-function-type';
5353
import preferIncludes from './prefer-includes';
5454
import preferNamespaceKeyword from './prefer-namespace-keyword';
55+
import preferNullishCoalescing from './prefer-nullish-coalescing';
5556
import preferOptionalChain from './prefer-optional-chain';
5657
import preferReadonly from './prefer-readonly';
5758
import preferRegexpExec from './prefer-regexp-exec';
@@ -127,6 +128,7 @@ export default {
127128
'prefer-function-type': preferFunctionType,
128129
'prefer-includes': preferIncludes,
129130
'prefer-namespace-keyword': preferNamespaceKeyword,
131+
'prefer-nullish-coalescing': preferNullishCoalescing,
130132
'prefer-optional-chain': preferOptionalChain,
131133
'prefer-readonly': preferReadonly,
132134
'prefer-regexp-exec': preferRegexpExec,
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
import {
2+
AST_NODE_TYPES,
3+
AST_TOKEN_TYPES,
4+
TSESLint,
5+
TSESTree,
6+
} from '@typescript-eslint/experimental-utils';
7+
import ts from 'typescript';
8+
import * as util from '../util';
9+
10+
export type Options = [
11+
{
12+
ignoreConditionalTests?: boolean;
13+
ignoreMixedLogicalExpressions?: boolean;
14+
},
15+
];
16+
export type MessageIds = 'preferNullish';
17+
18+
export default util.createRule<Options, MessageIds>({
19+
name: 'prefer-nullish-coalescing',
20+
meta: {
21+
type: 'suggestion',
22+
docs: {
23+
description:
24+
'Enforce the usage of the nullish coalescing operator instead of logical chaining',
25+
category: 'Best Practices',
26+
recommended: false,
27+
requiresTypeChecking: true,
28+
},
29+
fixable: 'code',
30+
messages: {
31+
preferNullish:
32+
'Prefer using nullish coalescing operator (`??`) instead of a logical or (`||`), as it is a safer operator.',
33+
},
34+
schema: [
35+
{
36+
type: 'object',
37+
properties: {
38+
ignoreConditionalTests: {
39+
type: 'boolean',
40+
},
41+
ignoreMixedLogicalExpressions: {
42+
type: 'boolean',
43+
},
44+
},
45+
additionalProperties: false,
46+
},
47+
],
48+
},
49+
defaultOptions: [
50+
{
51+
ignoreConditionalTests: true,
52+
ignoreMixedLogicalExpressions: true,
53+
},
54+
],
55+
create(context, [{ ignoreConditionalTests, ignoreMixedLogicalExpressions }]) {
56+
const parserServices = util.getParserServices(context);
57+
const sourceCode = context.getSourceCode();
58+
const checker = parserServices.program.getTypeChecker();
59+
60+
return {
61+
'LogicalExpression[operator = "||"]'(
62+
node: TSESTree.LogicalExpression,
63+
): void {
64+
const tsNode = parserServices.esTreeNodeToTSNodeMap.get<
65+
ts.BinaryExpression
66+
>(node);
67+
const type = checker.getTypeAtLocation(tsNode.left);
68+
const isNullish = util.isNullableType(type, { allowUndefined: true });
69+
if (!isNullish) {
70+
return;
71+
}
72+
73+
if (ignoreConditionalTests === true && isConditionalTest(node)) {
74+
return;
75+
}
76+
77+
const isMixedLogical = isMixedLogicalExpression(node);
78+
if (ignoreMixedLogicalExpressions === true && isMixedLogical) {
79+
return;
80+
}
81+
82+
const barBarOperator = sourceCode.getTokenAfter(
83+
node.left,
84+
token =>
85+
token.type === AST_TOKEN_TYPES.Punctuator &&
86+
token.value === node.operator,
87+
)!; // there _must_ be an operator
88+
89+
const fixer = isMixedLogical
90+
? // suggestion instead for cases where we aren't sure if the fixer is completely safe
91+
({
92+
suggest: [
93+
{
94+
messageId: 'preferNullish',
95+
fix(fixer: TSESLint.RuleFixer): TSESLint.RuleFix {
96+
return fixer.replaceText(barBarOperator, '??');
97+
},
98+
},
99+
],
100+
} as const)
101+
: {
102+
fix(fixer: TSESLint.RuleFixer): TSESLint.RuleFix {
103+
return fixer.replaceText(barBarOperator, '??');
104+
},
105+
};
106+
107+
context.report({
108+
node: barBarOperator,
109+
messageId: 'preferNullish',
110+
...fixer,
111+
});
112+
},
113+
};
114+
},
115+
});
116+
117+
function isConditionalTest(node: TSESTree.Node): boolean {
118+
const parents = new Set<TSESTree.Node | null>([node]);
119+
let current = node.parent;
120+
while (current) {
121+
parents.add(current);
122+
123+
if (
124+
(current.type === AST_NODE_TYPES.ConditionalExpression ||
125+
current.type === AST_NODE_TYPES.DoWhileStatement ||
126+
current.type === AST_NODE_TYPES.IfStatement ||
127+
current.type === AST_NODE_TYPES.ForStatement ||
128+
current.type === AST_NODE_TYPES.WhileStatement) &&
129+
parents.has(current.test)
130+
) {
131+
return true;
132+
}
133+
134+
if (
135+
[
136+
AST_NODE_TYPES.ArrowFunctionExpression,
137+
AST_NODE_TYPES.FunctionExpression,
138+
].includes(current.type)
139+
) {
140+
/**
141+
* This is a weird situation like:
142+
* `if (() => a || b) {}`
143+
* `if (function () { return a || b }) {}`
144+
*/
145+
return false;
146+
}
147+
148+
current = current.parent;
149+
}
150+
151+
return false;
152+
}
153+
154+
function isMixedLogicalExpression(node: TSESTree.LogicalExpression): boolean {
155+
const seen = new Set<TSESTree.Node | undefined>();
156+
const queue = [node.parent, node.left, node.right];
157+
for (const current of queue) {
158+
if (seen.has(current)) {
159+
continue;
160+
}
161+
seen.add(current);
162+
163+
if (current && current.type === AST_NODE_TYPES.LogicalExpression) {
164+
if (current.operator === '&&') {
165+
return true;
166+
} else if (current.operator === '||') {
167+
// check the pieces of the node to catch cases like `a || b || c && d`
168+
queue.push(current.parent, current.left, current.right);
169+
}
170+
}
171+
}
172+
173+
return false;
174+
}

0 commit comments

Comments
 (0)