Skip to content

Commit a2a8a0a

Browse files
feat(eslint-plugin): [no-unnec-cond] check optional chaining (typescript-eslint#1315)
Co-authored-by: Brad Zacher <brad.zacher@gmail.com>
1 parent f9a9fbf commit a2a8a0a

File tree

4 files changed

+281
-3
lines changed

4 files changed

+281
-3
lines changed

packages/eslint-plugin/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -179,7 +179,7 @@ Then you should add `airbnb` (or `airbnb-base`) to your `extends` section of `.e
179179
| [`@typescript-eslint/no-require-imports`](./docs/rules/no-require-imports.md) | Disallows invocation of `require()` | | | |
180180
| [`@typescript-eslint/no-this-alias`](./docs/rules/no-this-alias.md) | Disallow aliasing `this` | :heavy_check_mark: | | |
181181
| [`@typescript-eslint/no-type-alias`](./docs/rules/no-type-alias.md) | Disallow the use of type aliases | | | |
182-
| [`@typescript-eslint/no-unnecessary-condition`](./docs/rules/no-unnecessary-condition.md) | Prevents conditionals where the type is always truthy or always falsy | | | :thought_balloon: |
182+
| [`@typescript-eslint/no-unnecessary-condition`](./docs/rules/no-unnecessary-condition.md) | Prevents conditionals where the type is always truthy or always falsy | | :wrench: | :thought_balloon: |
183183
| [`@typescript-eslint/no-unnecessary-qualifier`](./docs/rules/no-unnecessary-qualifier.md) | Warns when a namespace qualifier is unnecessary | | :wrench: | :thought_balloon: |
184184
| [`@typescript-eslint/no-unnecessary-type-arguments`](./docs/rules/no-unnecessary-type-arguments.md) | Warns if an explicitly specified type argument is the default for that type parameter | | :wrench: | :thought_balloon: |
185185
| [`@typescript-eslint/no-unnecessary-type-assertion`](./docs/rules/no-unnecessary-type-assertion.md) | Warns if a type assertion does not change the type of an expression | :heavy_check_mark: | :wrench: | :thought_balloon: |

packages/eslint-plugin/docs/rules/no-unnecessary-condition.md

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@ Any expression being used as a condition must be able to evaluate as truthy or f
55
The following expressions are checked:
66

77
- Arguments to the `&&`, `||` and `?:` (ternary) operators
8-
- Conditions for `if`, `for`, `while`, and `do-while` statements.
8+
- Conditions for `if`, `for`, `while`, and `do-while` statements
9+
- Base values of optional chain expressions
910

1011
Examples of **incorrect** code for this rule:
1112

@@ -22,6 +23,11 @@ function foo(arg: 'bar' | 'baz') {
2223
if (arg) {
2324
}
2425
}
26+
27+
function bar<T>(arg: string) {
28+
// arg can never be nullish, so ?. is unnecessary
29+
return arg?.length;
30+
}
2531
```
2632

2733
Examples of **correct** code for this rule:
@@ -39,6 +45,11 @@ function foo(arg: string) {
3945
if (arg) {
4046
}
4147
}
48+
49+
function bar(arg?: string | null) {
50+
// Necessary, since arg might be nullish
51+
return arg?.length;
52+
}
4253
```
4354

4455
## Options

packages/eslint-plugin/src/rules/no-unnecessary-condition.ts

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import {
22
TSESTree,
33
AST_NODE_TYPES,
4+
AST_TOKEN_TYPES,
45
} from '@typescript-eslint/experimental-utils';
56
import ts, { TypeFlags } from 'typescript';
67
import {
@@ -14,6 +15,9 @@ import {
1415
createRule,
1516
getParserServices,
1617
getConstrainedTypeAtLocation,
18+
isNullableType,
19+
nullThrows,
20+
NullThrowsReasons,
1721
} from '../util';
1822

1923
// Truthiness utilities
@@ -65,7 +69,8 @@ export type MessageId =
6569
| 'neverNullish'
6670
| 'alwaysNullish'
6771
| 'literalBooleanExpression'
68-
| 'never';
72+
| 'never'
73+
| 'neverOptionalChain';
6974
export default createRule<Options, MessageId>({
7075
name: 'no-unnecessary-conditionals',
7176
meta: {
@@ -91,6 +96,7 @@ export default createRule<Options, MessageId>({
9196
additionalProperties: false,
9297
},
9398
],
99+
fixable: 'code',
94100
messages: {
95101
alwaysTruthy: 'Unnecessary conditional, value is always truthy.',
96102
alwaysFalsy: 'Unnecessary conditional, value is always falsy.',
@@ -101,6 +107,7 @@ export default createRule<Options, MessageId>({
101107
literalBooleanExpression:
102108
'Unnecessary conditional, both sides of the expression are literal values',
103109
never: 'Unnecessary conditional, value is `never`',
110+
neverOptionalChain: 'Unnecessary optional chain on a non-nullish value',
104111
},
105112
},
106113
defaultOptions: [
@@ -112,6 +119,7 @@ export default createRule<Options, MessageId>({
112119
create(context, [{ allowConstantLoopConditions, ignoreRhs }]) {
113120
const service = getParserServices(context);
114121
const checker = service.program.getTypeChecker();
122+
const sourceCode = context.getSourceCode();
115123

116124
function getNodeType(node: TSESTree.Node): ts.Type {
117125
const tsNode = service.esTreeNodeToTSNodeMap.get(node);
@@ -260,6 +268,57 @@ export default createRule<Options, MessageId>({
260268
checkNode(node.test);
261269
}
262270

271+
function checkOptionalChain(
272+
node: TSESTree.OptionalMemberExpression | TSESTree.OptionalCallExpression,
273+
beforeOperator: TSESTree.Node,
274+
fix: '' | '.',
275+
): void {
276+
// We only care if this step in the chain is optional. If just descend
277+
// from an optional chain, then that's fine.
278+
if (!node.optional) {
279+
return;
280+
}
281+
282+
const type = getNodeType(node);
283+
if (
284+
isTypeFlagSet(type, ts.TypeFlags.Any) ||
285+
isTypeFlagSet(type, ts.TypeFlags.Unknown) ||
286+
isNullableType(type, { allowUndefined: true })
287+
) {
288+
return;
289+
}
290+
291+
const questionDotOperator = nullThrows(
292+
sourceCode.getTokenAfter(
293+
beforeOperator,
294+
token =>
295+
token.type === AST_TOKEN_TYPES.Punctuator && token.value === '?.',
296+
),
297+
NullThrowsReasons.MissingToken('operator', node.type),
298+
);
299+
300+
context.report({
301+
node,
302+
loc: questionDotOperator.loc,
303+
messageId: 'neverOptionalChain',
304+
fix(fixer) {
305+
return fixer.replaceText(questionDotOperator, fix);
306+
},
307+
});
308+
}
309+
310+
function checkOptionalMemberExpression(
311+
node: TSESTree.OptionalMemberExpression,
312+
): void {
313+
checkOptionalChain(node, node.object, '.');
314+
}
315+
316+
function checkOptionalCallExpression(
317+
node: TSESTree.OptionalCallExpression,
318+
): void {
319+
checkOptionalChain(node, node.callee, '');
320+
}
321+
263322
return {
264323
BinaryExpression: checkIfBinaryExpressionIsNecessaryConditional,
265324
ConditionalExpression: checkIfTestExpressionIsNecessaryConditional,
@@ -268,6 +327,8 @@ export default createRule<Options, MessageId>({
268327
IfStatement: checkIfTestExpressionIsNecessaryConditional,
269328
LogicalExpression: checkLogicalExpressionForUnnecessaryConditionals,
270329
WhileStatement: checkIfLoopIsNecessaryConditional,
330+
OptionalMemberExpression: checkOptionalMemberExpression,
331+
OptionalCallExpression: checkOptionalCallExpression,
271332
};
272333
},
273334
});

packages/eslint-plugin/tests/rules/no-unnecessary-condition.test.ts

Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,62 @@ do {} while(true)
121121
`,
122122
options: [{ allowConstantLoopConditions: true }],
123123
},
124+
`
125+
let foo: undefined | { bar: true };
126+
foo?.bar;
127+
`,
128+
`
129+
let foo: null | { bar: true };
130+
foo?.bar;
131+
`,
132+
`
133+
let foo: undefined;
134+
foo?.bar;
135+
`,
136+
`
137+
let foo: undefined;
138+
foo?.bar.baz;
139+
`,
140+
`
141+
let foo: null;
142+
foo?.bar;
143+
`,
144+
`
145+
let anyValue: any;
146+
anyValue?.foo;
147+
`,
148+
`
149+
let unknownValue: unknown;
150+
unknownValue?.foo;
151+
`,
152+
`
153+
let foo: undefined | (() => {});
154+
foo?.();
155+
`,
156+
`
157+
let foo: null | (() => {});
158+
foo?.();
159+
`,
160+
`
161+
let foo: undefined;
162+
foo?.();
163+
`,
164+
`
165+
let foo: undefined;
166+
foo?.().bar;
167+
`,
168+
`
169+
let foo: null;
170+
foo?.();
171+
`,
172+
`
173+
let anyValue: any;
174+
anyValue?.();
175+
`,
176+
`
177+
let unknownValue: unknown;
178+
unknownValue?.();
179+
`,
124180
],
125181
invalid: [
126182
// Ensure that it's checking in all the right places
@@ -267,5 +323,155 @@ do {} while(true)
267323
ruleError(4, 13, 'alwaysTruthy'),
268324
],
269325
},
326+
{
327+
code: `
328+
let foo = { bar: true };
329+
foo?.bar;
330+
foo ?. bar;
331+
foo ?.
332+
bar;
333+
foo
334+
?. bar;
335+
`,
336+
output: `
337+
let foo = { bar: true };
338+
foo.bar;
339+
foo . bar;
340+
foo .
341+
bar;
342+
foo
343+
. bar;
344+
`,
345+
errors: [
346+
{
347+
messageId: 'neverOptionalChain',
348+
line: 3,
349+
column: 4,
350+
endLine: 3,
351+
endColumn: 6,
352+
},
353+
{
354+
messageId: 'neverOptionalChain',
355+
line: 4,
356+
column: 5,
357+
endLine: 4,
358+
endColumn: 7,
359+
},
360+
{
361+
messageId: 'neverOptionalChain',
362+
line: 5,
363+
column: 5,
364+
endLine: 5,
365+
endColumn: 7,
366+
},
367+
{
368+
messageId: 'neverOptionalChain',
369+
line: 8,
370+
column: 3,
371+
endLine: 8,
372+
endColumn: 5,
373+
},
374+
],
375+
},
376+
{
377+
code: `
378+
let foo = () => {};
379+
foo?.();
380+
foo ?. ();
381+
foo ?.
382+
();
383+
foo
384+
?. ();
385+
`,
386+
output: `
387+
let foo = () => {};
388+
foo();
389+
foo ();
390+
foo${' '}
391+
();
392+
foo
393+
();
394+
`,
395+
errors: [
396+
{
397+
messageId: 'neverOptionalChain',
398+
line: 3,
399+
column: 4,
400+
endLine: 3,
401+
endColumn: 6,
402+
},
403+
{
404+
messageId: 'neverOptionalChain',
405+
line: 4,
406+
column: 5,
407+
endLine: 4,
408+
endColumn: 7,
409+
},
410+
{
411+
messageId: 'neverOptionalChain',
412+
line: 5,
413+
column: 5,
414+
endLine: 5,
415+
endColumn: 7,
416+
},
417+
{
418+
messageId: 'neverOptionalChain',
419+
line: 8,
420+
column: 3,
421+
endLine: 8,
422+
endColumn: 5,
423+
},
424+
],
425+
},
426+
{
427+
code: `
428+
let foo = () => {};
429+
foo?.(bar);
430+
foo ?. (bar);
431+
foo ?.
432+
(bar);
433+
foo
434+
?. (bar);
435+
`,
436+
output: `
437+
let foo = () => {};
438+
foo(bar);
439+
foo (bar);
440+
foo${' '}
441+
(bar);
442+
foo
443+
(bar);
444+
`,
445+
errors: [
446+
{
447+
messageId: 'neverOptionalChain',
448+
line: 3,
449+
column: 4,
450+
endLine: 3,
451+
endColumn: 6,
452+
},
453+
{
454+
messageId: 'neverOptionalChain',
455+
line: 4,
456+
column: 5,
457+
endLine: 4,
458+
endColumn: 7,
459+
},
460+
{
461+
messageId: 'neverOptionalChain',
462+
line: 5,
463+
column: 5,
464+
endLine: 5,
465+
endColumn: 7,
466+
},
467+
{
468+
messageId: 'neverOptionalChain',
469+
line: 8,
470+
column: 3,
471+
endLine: 8,
472+
endColumn: 5,
473+
},
474+
],
475+
},
270476
],
271477
});

0 commit comments

Comments
 (0)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy