Skip to content

Commit 9e0f6dd

Browse files
dretscust0dianbradzacher
authored
feat(eslint-plugin): add switch-exhaustiveness-check rule (#972)
Co-authored-by: Serg Nesterov <[email protected]> Co-authored-by: Brad Zacher <[email protected]>
1 parent 7c70323 commit 9e0f6dd

File tree

6 files changed

+693
-0
lines changed

6 files changed

+693
-0
lines changed

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

+1
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,7 @@ Pro Tip: For larger codebases you may want to consider splitting our linting int
148148
| [`@typescript-eslint/restrict-plus-operands`](./docs/rules/restrict-plus-operands.md) | When adding two variables, operands must both be of type number or of type string | | | :thought_balloon: |
149149
| [`@typescript-eslint/restrict-template-expressions`](./docs/rules/restrict-template-expressions.md) | Enforce template literal expressions to be of string type | | | :thought_balloon: |
150150
| [`@typescript-eslint/strict-boolean-expressions`](./docs/rules/strict-boolean-expressions.md) | Restricts the types allowed in boolean expressions | | | :thought_balloon: |
151+
| [`@typescript-eslint/switch-exhaustiveness-check`](./docs/rules/switch-exhaustiveness-check.md) | Exhaustiveness checking in switch with union type | | | :thought_balloon: |
151152
| [`@typescript-eslint/triple-slash-reference`](./docs/rules/triple-slash-reference.md) | Sets preference level for triple slash directives versus ES6-style import declarations | :heavy_check_mark: | | |
152153
| [`@typescript-eslint/type-annotation-spacing`](./docs/rules/type-annotation-spacing.md) | Require consistent spacing around type annotations | :heavy_check_mark: | :wrench: | |
153154
| [`@typescript-eslint/typedef`](./docs/rules/typedef.md) | Requires type annotations to exist | | | |
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
# Exhaustiveness checking in switch with union type (`switch-exhaustiveness-check`)
2+
3+
Union type may have a lot of parts. It's easy to forget to consider all cases in switch. This rule reminds which parts are missing. If domain of the problem requires to have only a partial switch, developer may _explicitly_ add a default clause.
4+
5+
Examples of **incorrect** code for this rule:
6+
7+
```ts
8+
type Day =
9+
| 'Monday'
10+
| 'Tuesday'
11+
| 'Wednesday'
12+
| 'Thursday'
13+
| 'Friday'
14+
| 'Saturday'
15+
| 'Sunday';
16+
17+
const day = 'Monday' as Day;
18+
let result = 0;
19+
20+
switch (day) {
21+
case 'Monday': {
22+
result = 1;
23+
break;
24+
}
25+
}
26+
```
27+
28+
Examples of **correct** code for this rule:
29+
30+
```ts
31+
type Day =
32+
| 'Monday'
33+
| 'Tuesday'
34+
| 'Wednesday'
35+
| 'Thursday'
36+
| 'Friday'
37+
| 'Saturday'
38+
| 'Sunday';
39+
40+
const day = 'Monday' as Day;
41+
let result = 0;
42+
43+
switch (day) {
44+
case 'Monday': {
45+
result = 1;
46+
break;
47+
}
48+
case 'Tuesday': {
49+
result = 2;
50+
break;
51+
}
52+
case 'Wednesday': {
53+
result = 3;
54+
break;
55+
}
56+
case 'Thursday': {
57+
result = 4;
58+
break;
59+
}
60+
case 'Friday': {
61+
result = 5;
62+
break;
63+
}
64+
case 'Saturday': {
65+
result = 6;
66+
break;
67+
}
68+
case 'Sunday': {
69+
result = 7;
70+
break;
71+
}
72+
}
73+
```
74+
75+
or
76+
77+
```ts
78+
type Day =
79+
| 'Monday'
80+
| 'Tuesday'
81+
| 'Wednesday'
82+
| 'Thursday'
83+
| 'Friday'
84+
| 'Saturday'
85+
| 'Sunday';
86+
87+
const day = 'Monday' as Day;
88+
let result = 0;
89+
90+
switch (day) {
91+
case 'Monday': {
92+
result = 1;
93+
break;
94+
}
95+
default: {
96+
result = 42;
97+
}
98+
}
99+
```
100+
101+
## When Not To Use It
102+
103+
If program doesn't have union types with many parts. Downside of this rule is the need for type information, so it's slower than regular rules.

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

+1
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@
9595
"space-before-function-paren": "off",
9696
"@typescript-eslint/space-before-function-paren": "error",
9797
"@typescript-eslint/strict-boolean-expressions": "error",
98+
"@typescript-eslint/switch-exhaustiveness-check": "error",
9899
"@typescript-eslint/triple-slash-reference": "error",
99100
"@typescript-eslint/type-annotation-spacing": "error",
100101
"@typescript-eslint/typedef": "error",

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

+2
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ import returnAwait from './return-await';
7979
import semi from './semi';
8080
import spaceBeforeFunctionParen from './space-before-function-paren';
8181
import strictBooleanExpressions from './strict-boolean-expressions';
82+
import switchExhaustivenessCheck from './switch-exhaustiveness-check';
8283
import tripleSlashReference from './triple-slash-reference';
8384
import typeAnnotationSpacing from './type-annotation-spacing';
8485
import typedef from './typedef';
@@ -167,6 +168,7 @@ export default {
167168
semi: semi,
168169
'space-before-function-paren': spaceBeforeFunctionParen,
169170
'strict-boolean-expressions': strictBooleanExpressions,
171+
'switch-exhaustiveness-check': switchExhaustivenessCheck,
170172
'triple-slash-reference': tripleSlashReference,
171173
'type-annotation-spacing': typeAnnotationSpacing,
172174
typedef: typedef,
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
import { TSESLint, TSESTree } from '@typescript-eslint/experimental-utils';
2+
import * as ts from 'typescript';
3+
import {
4+
createRule,
5+
getParserServices,
6+
getConstrainedTypeAtLocation,
7+
} from '../util';
8+
import { isTypeFlagSet, unionTypeParts } from 'tsutils';
9+
import { isClosingBraceToken, isOpeningBraceToken } from 'eslint-utils';
10+
11+
export default createRule({
12+
name: 'switch-exhaustiveness-check',
13+
meta: {
14+
type: 'suggestion',
15+
docs: {
16+
description: 'Exhaustiveness checking in switch with union type',
17+
category: 'Best Practices',
18+
recommended: false,
19+
requiresTypeChecking: true,
20+
},
21+
schema: [],
22+
messages: {
23+
switchIsNotExhaustive:
24+
'Switch is not exhaustive. Cases not matched: {{missingBranches}}',
25+
addMissingCases: 'Add branches for missing cases',
26+
},
27+
},
28+
defaultOptions: [],
29+
create(context) {
30+
const sourceCode = context.getSourceCode();
31+
const service = getParserServices(context);
32+
const checker = service.program.getTypeChecker();
33+
34+
function getNodeType(node: TSESTree.Node): ts.Type {
35+
const tsNode = service.esTreeNodeToTSNodeMap.get(node);
36+
return getConstrainedTypeAtLocation(checker, tsNode);
37+
}
38+
39+
function fixSwitch(
40+
fixer: TSESLint.RuleFixer,
41+
node: TSESTree.SwitchStatement,
42+
missingBranchTypes: Array<ts.Type>,
43+
): TSESLint.RuleFix | null {
44+
const lastCase =
45+
node.cases.length > 0 ? node.cases[node.cases.length - 1] : null;
46+
const caseIndent = lastCase
47+
? ' '.repeat(lastCase.loc.start.column)
48+
: // if there are no cases, use indentation of the switch statement
49+
// and leave it to user to format it correctly
50+
' '.repeat(node.loc.start.column);
51+
52+
const missingCases = [];
53+
for (const missingBranchType of missingBranchTypes) {
54+
// While running this rule on checker.ts of TypeScript project
55+
// the fix introduced a compiler error due to:
56+
//
57+
// type __String = (string & {
58+
// __escapedIdentifier: void;
59+
// }) | (void & {
60+
// __escapedIdentifier: void;
61+
// }) | InternalSymbolName;
62+
//
63+
// The following check fixes it.
64+
if (missingBranchType.isIntersection()) {
65+
continue;
66+
}
67+
68+
const caseTest = checker.typeToString(missingBranchType);
69+
const errorMessage = `Not implemented yet: ${caseTest} case`;
70+
71+
missingCases.push(
72+
`case ${caseTest}: { throw new Error('${errorMessage}') }`,
73+
);
74+
}
75+
76+
const fixString = missingCases
77+
.map(code => `${caseIndent}${code}`)
78+
.join('\n');
79+
80+
if (lastCase) {
81+
return fixer.insertTextAfter(lastCase, `\n${fixString}`);
82+
}
83+
84+
// there were no existing cases
85+
const openingBrace = sourceCode.getTokenAfter(
86+
node.discriminant,
87+
isOpeningBraceToken,
88+
)!;
89+
const closingBrace = sourceCode.getTokenAfter(
90+
node.discriminant,
91+
isClosingBraceToken,
92+
)!;
93+
94+
return fixer.replaceTextRange(
95+
[openingBrace.range[0], closingBrace.range[1]],
96+
['{', fixString, `${caseIndent}}`].join('\n'),
97+
);
98+
}
99+
100+
function checkSwitchExhaustive(node: TSESTree.SwitchStatement): void {
101+
const discriminantType = getNodeType(node.discriminant);
102+
103+
if (discriminantType.isUnion()) {
104+
const unionTypes = unionTypeParts(discriminantType);
105+
const caseTypes: Set<ts.Type> = new Set();
106+
for (const switchCase of node.cases) {
107+
if (switchCase.test === null) {
108+
// Switch has 'default' branch - do nothing.
109+
return;
110+
}
111+
112+
caseTypes.add(getNodeType(switchCase.test));
113+
}
114+
115+
const missingBranchTypes = unionTypes.filter(
116+
unionType => !caseTypes.has(unionType),
117+
);
118+
119+
if (missingBranchTypes.length === 0) {
120+
// All cases matched - do nothing.
121+
return;
122+
}
123+
124+
context.report({
125+
node: node.discriminant,
126+
messageId: 'switchIsNotExhaustive',
127+
data: {
128+
missingBranches: missingBranchTypes
129+
.map(missingType =>
130+
isTypeFlagSet(missingType, ts.TypeFlags.ESSymbolLike)
131+
? `typeof ${missingType.symbol.escapedName}`
132+
: checker.typeToString(missingType),
133+
)
134+
.join(' | '),
135+
},
136+
suggest: [
137+
{
138+
messageId: 'addMissingCases',
139+
fix(fixer): TSESLint.RuleFix | null {
140+
return fixSwitch(fixer, node, missingBranchTypes);
141+
},
142+
},
143+
],
144+
});
145+
}
146+
}
147+
148+
return {
149+
SwitchStatement: checkSwitchExhaustive,
150+
};
151+
},
152+
});

0 commit comments

Comments
 (0)