-
-
Notifications
You must be signed in to change notification settings - Fork 2.7k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(eslint-plugin): add switch-exhaustiveness-check rule (#972)
Co-authored-by: Serg Nesterov <i.am.cust0dian@gmail.com> Co-authored-by: Brad Zacher <brad.zacher@gmail.com>
- Loading branch information
1 parent
7c70323
commit 9e0f6dd
Showing
6 changed files
with
693 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
103 changes: 103 additions & 0 deletions
103
packages/eslint-plugin/docs/rules/switch-exhaustiveness-check.md
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,103 @@ | ||
# Exhaustiveness checking in switch with union type (`switch-exhaustiveness-check`) | ||
|
||
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. | ||
|
||
Examples of **incorrect** code for this rule: | ||
|
||
```ts | ||
type Day = | ||
| 'Monday' | ||
| 'Tuesday' | ||
| 'Wednesday' | ||
| 'Thursday' | ||
| 'Friday' | ||
| 'Saturday' | ||
| 'Sunday'; | ||
|
||
const day = 'Monday' as Day; | ||
let result = 0; | ||
|
||
switch (day) { | ||
case 'Monday': { | ||
result = 1; | ||
break; | ||
} | ||
} | ||
``` | ||
|
||
Examples of **correct** code for this rule: | ||
|
||
```ts | ||
type Day = | ||
| 'Monday' | ||
| 'Tuesday' | ||
| 'Wednesday' | ||
| 'Thursday' | ||
| 'Friday' | ||
| 'Saturday' | ||
| 'Sunday'; | ||
|
||
const day = 'Monday' as Day; | ||
let result = 0; | ||
|
||
switch (day) { | ||
case 'Monday': { | ||
result = 1; | ||
break; | ||
} | ||
case 'Tuesday': { | ||
result = 2; | ||
break; | ||
} | ||
case 'Wednesday': { | ||
result = 3; | ||
break; | ||
} | ||
case 'Thursday': { | ||
result = 4; | ||
break; | ||
} | ||
case 'Friday': { | ||
result = 5; | ||
break; | ||
} | ||
case 'Saturday': { | ||
result = 6; | ||
break; | ||
} | ||
case 'Sunday': { | ||
result = 7; | ||
break; | ||
} | ||
} | ||
``` | ||
|
||
or | ||
|
||
```ts | ||
type Day = | ||
| 'Monday' | ||
| 'Tuesday' | ||
| 'Wednesday' | ||
| 'Thursday' | ||
| 'Friday' | ||
| 'Saturday' | ||
| 'Sunday'; | ||
|
||
const day = 'Monday' as Day; | ||
let result = 0; | ||
|
||
switch (day) { | ||
case 'Monday': { | ||
result = 1; | ||
break; | ||
} | ||
default: { | ||
result = 42; | ||
} | ||
} | ||
``` | ||
|
||
## When Not To Use It | ||
|
||
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. |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
152 changes: 152 additions & 0 deletions
152
packages/eslint-plugin/src/rules/switch-exhaustiveness-check.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,152 @@ | ||
import { TSESLint, TSESTree } from '@typescript-eslint/experimental-utils'; | ||
import * as ts from 'typescript'; | ||
import { | ||
createRule, | ||
getParserServices, | ||
getConstrainedTypeAtLocation, | ||
} from '../util'; | ||
import { isTypeFlagSet, unionTypeParts } from 'tsutils'; | ||
import { isClosingBraceToken, isOpeningBraceToken } from 'eslint-utils'; | ||
|
||
export default createRule({ | ||
name: 'switch-exhaustiveness-check', | ||
meta: { | ||
type: 'suggestion', | ||
docs: { | ||
description: 'Exhaustiveness checking in switch with union type', | ||
category: 'Best Practices', | ||
recommended: false, | ||
requiresTypeChecking: true, | ||
}, | ||
schema: [], | ||
messages: { | ||
switchIsNotExhaustive: | ||
'Switch is not exhaustive. Cases not matched: {{missingBranches}}', | ||
addMissingCases: 'Add branches for missing cases', | ||
}, | ||
}, | ||
defaultOptions: [], | ||
create(context) { | ||
const sourceCode = context.getSourceCode(); | ||
const service = getParserServices(context); | ||
const checker = service.program.getTypeChecker(); | ||
|
||
function getNodeType(node: TSESTree.Node): ts.Type { | ||
const tsNode = service.esTreeNodeToTSNodeMap.get(node); | ||
return getConstrainedTypeAtLocation(checker, tsNode); | ||
} | ||
|
||
function fixSwitch( | ||
fixer: TSESLint.RuleFixer, | ||
node: TSESTree.SwitchStatement, | ||
missingBranchTypes: Array<ts.Type>, | ||
): TSESLint.RuleFix | null { | ||
const lastCase = | ||
node.cases.length > 0 ? node.cases[node.cases.length - 1] : null; | ||
const caseIndent = lastCase | ||
? ' '.repeat(lastCase.loc.start.column) | ||
: // if there are no cases, use indentation of the switch statement | ||
// and leave it to user to format it correctly | ||
' '.repeat(node.loc.start.column); | ||
|
||
const missingCases = []; | ||
for (const missingBranchType of missingBranchTypes) { | ||
// While running this rule on checker.ts of TypeScript project | ||
// the fix introduced a compiler error due to: | ||
// | ||
// type __String = (string & { | ||
// __escapedIdentifier: void; | ||
// }) | (void & { | ||
// __escapedIdentifier: void; | ||
// }) | InternalSymbolName; | ||
// | ||
// The following check fixes it. | ||
if (missingBranchType.isIntersection()) { | ||
continue; | ||
} | ||
|
||
const caseTest = checker.typeToString(missingBranchType); | ||
const errorMessage = `Not implemented yet: ${caseTest} case`; | ||
|
||
missingCases.push( | ||
`case ${caseTest}: { throw new Error('${errorMessage}') }`, | ||
); | ||
} | ||
|
||
const fixString = missingCases | ||
.map(code => `${caseIndent}${code}`) | ||
.join('\n'); | ||
|
||
if (lastCase) { | ||
return fixer.insertTextAfter(lastCase, `\n${fixString}`); | ||
} | ||
|
||
// there were no existing cases | ||
const openingBrace = sourceCode.getTokenAfter( | ||
node.discriminant, | ||
isOpeningBraceToken, | ||
)!; | ||
const closingBrace = sourceCode.getTokenAfter( | ||
node.discriminant, | ||
isClosingBraceToken, | ||
)!; | ||
|
||
return fixer.replaceTextRange( | ||
[openingBrace.range[0], closingBrace.range[1]], | ||
['{', fixString, `${caseIndent}}`].join('\n'), | ||
); | ||
} | ||
|
||
function checkSwitchExhaustive(node: TSESTree.SwitchStatement): void { | ||
const discriminantType = getNodeType(node.discriminant); | ||
|
||
if (discriminantType.isUnion()) { | ||
const unionTypes = unionTypeParts(discriminantType); | ||
const caseTypes: Set<ts.Type> = new Set(); | ||
for (const switchCase of node.cases) { | ||
if (switchCase.test === null) { | ||
// Switch has 'default' branch - do nothing. | ||
return; | ||
} | ||
|
||
caseTypes.add(getNodeType(switchCase.test)); | ||
} | ||
|
||
const missingBranchTypes = unionTypes.filter( | ||
unionType => !caseTypes.has(unionType), | ||
); | ||
|
||
if (missingBranchTypes.length === 0) { | ||
// All cases matched - do nothing. | ||
return; | ||
} | ||
|
||
context.report({ | ||
node: node.discriminant, | ||
messageId: 'switchIsNotExhaustive', | ||
data: { | ||
missingBranches: missingBranchTypes | ||
.map(missingType => | ||
isTypeFlagSet(missingType, ts.TypeFlags.ESSymbolLike) | ||
? `typeof ${missingType.symbol.escapedName}` | ||
: checker.typeToString(missingType), | ||
) | ||
.join(' | '), | ||
}, | ||
suggest: [ | ||
{ | ||
messageId: 'addMissingCases', | ||
fix(fixer): TSESLint.RuleFix | null { | ||
return fixSwitch(fixer, node, missingBranchTypes); | ||
}, | ||
}, | ||
], | ||
}); | ||
} | ||
} | ||
|
||
return { | ||
SwitchStatement: checkSwitchExhaustive, | ||
}; | ||
}, | ||
}); |
Oops, something went wrong.