Skip to content

Commit

Permalink
feat(eslint-plugin): add switch-exhaustiveness-check rule (#972)
Browse files Browse the repository at this point in the history
Co-authored-by: Serg Nesterov <i.am.cust0dian@gmail.com>
Co-authored-by: Brad Zacher <brad.zacher@gmail.com>
  • Loading branch information
3 people committed Feb 3, 2020
1 parent 7c70323 commit 9e0f6dd
Show file tree
Hide file tree
Showing 6 changed files with 693 additions and 0 deletions.
1 change: 1 addition & 0 deletions packages/eslint-plugin/README.md
Expand Up @@ -148,6 +148,7 @@ Pro Tip: For larger codebases you may want to consider splitting our linting int
| [`@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: |
| [`@typescript-eslint/restrict-template-expressions`](./docs/rules/restrict-template-expressions.md) | Enforce template literal expressions to be of string type | | | :thought_balloon: |
| [`@typescript-eslint/strict-boolean-expressions`](./docs/rules/strict-boolean-expressions.md) | Restricts the types allowed in boolean expressions | | | :thought_balloon: |
| [`@typescript-eslint/switch-exhaustiveness-check`](./docs/rules/switch-exhaustiveness-check.md) | Exhaustiveness checking in switch with union type | | | :thought_balloon: |
| [`@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: | | |
| [`@typescript-eslint/type-annotation-spacing`](./docs/rules/type-annotation-spacing.md) | Require consistent spacing around type annotations | :heavy_check_mark: | :wrench: | |
| [`@typescript-eslint/typedef`](./docs/rules/typedef.md) | Requires type annotations to exist | | | |
Expand Down
103 changes: 103 additions & 0 deletions packages/eslint-plugin/docs/rules/switch-exhaustiveness-check.md
@@ -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.
1 change: 1 addition & 0 deletions packages/eslint-plugin/src/configs/all.json
Expand Up @@ -95,6 +95,7 @@
"space-before-function-paren": "off",
"@typescript-eslint/space-before-function-paren": "error",
"@typescript-eslint/strict-boolean-expressions": "error",
"@typescript-eslint/switch-exhaustiveness-check": "error",
"@typescript-eslint/triple-slash-reference": "error",
"@typescript-eslint/type-annotation-spacing": "error",
"@typescript-eslint/typedef": "error",
Expand Down
2 changes: 2 additions & 0 deletions packages/eslint-plugin/src/rules/index.ts
Expand Up @@ -79,6 +79,7 @@ import returnAwait from './return-await';
import semi from './semi';
import spaceBeforeFunctionParen from './space-before-function-paren';
import strictBooleanExpressions from './strict-boolean-expressions';
import switchExhaustivenessCheck from './switch-exhaustiveness-check';
import tripleSlashReference from './triple-slash-reference';
import typeAnnotationSpacing from './type-annotation-spacing';
import typedef from './typedef';
Expand Down Expand Up @@ -167,6 +168,7 @@ export default {
semi: semi,
'space-before-function-paren': spaceBeforeFunctionParen,
'strict-boolean-expressions': strictBooleanExpressions,
'switch-exhaustiveness-check': switchExhaustivenessCheck,
'triple-slash-reference': tripleSlashReference,
'type-annotation-spacing': typeAnnotationSpacing,
typedef: typedef,
Expand Down
152 changes: 152 additions & 0 deletions packages/eslint-plugin/src/rules/switch-exhaustiveness-check.ts
@@ -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,
};
},
});

0 comments on commit 9e0f6dd

Please sign in to comment.