Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(eslint-plugin): [switch-exhaustiveness-check] add an option to warn against a default case on an already exhaustive switch #7539

Merged
merged 50 commits into from Dec 29, 2023
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
Show all changes
50 commits
Select commit Hold shift + click to select a range
af5eda0
feat: switch-exhaustiveness-check checks for dangerous default case
Zamiell Aug 26, 2023
12a7635
fix: spelling
Zamiell Aug 26, 2023
729378f
fix: comment
Zamiell Aug 26, 2023
329c99e
fix: docs
Zamiell Aug 26, 2023
7fc2823
Merge branch 'main' into switch
Zamiell Sep 4, 2023
4a5002e
Merge branch 'main' into switch
Zamiell Sep 8, 2023
1c2f06c
Merge branch 'main' into switch
Zamiell Oct 12, 2023
0e5afe5
feat: allowDefaultCase option
Zamiell Oct 12, 2023
99ef806
fix: tests
Zamiell Oct 12, 2023
c7ca234
fix: lint
Zamiell Oct 12, 2023
5709fe3
fix: prettier
Zamiell Oct 12, 2023
074905f
Merge branch 'main' into switch
Zamiell Nov 13, 2023
81b4400
Merge branch 'main' into switch
Zamiell Dec 6, 2023
7729224
refactor: finish merge
Zamiell Dec 6, 2023
592de1d
fix: format
Zamiell Dec 6, 2023
26a9919
fix: lint
Zamiell Dec 6, 2023
e865d6b
chore: update docs
Zamiell Dec 6, 2023
5be7b48
chore: update docs
Zamiell Dec 6, 2023
d18f0f1
chore: format
Zamiell Dec 6, 2023
4091daf
fix: test
Zamiell Dec 6, 2023
d68f7b7
fix: tests
Zamiell Dec 6, 2023
1fa4494
fix: tests
Zamiell Dec 6, 2023
22c1503
fix: tests
Zamiell Dec 6, 2023
c928b18
fix: test
Zamiell Dec 6, 2023
c32204e
fix: test
Zamiell Dec 6, 2023
6ea1b32
fix: tests
Zamiell Dec 6, 2023
090a737
Update packages/eslint-plugin/src/rules/switch-exhaustiveness-check.ts
Zamiell Dec 6, 2023
b93f501
fix: double options in docs
Zamiell Dec 6, 2023
0fe1cd9
Update packages/eslint-plugin/docs/rules/switch-exhaustiveness-check.md
Zamiell Dec 6, 2023
cab680a
feat: simplify code flow
Zamiell Dec 6, 2023
8ad5037
Update packages/eslint-plugin/src/rules/switch-exhaustiveness-check.ts
Zamiell Dec 6, 2023
4aa247b
fix: grammar
Zamiell Dec 6, 2023
9a63489
Update packages/eslint-plugin/src/rules/switch-exhaustiveness-check.ts
Zamiell Dec 6, 2023
30b6695
fix: wording on option
Zamiell Dec 6, 2023
5a3bf3c
Update packages/eslint-plugin/docs/rules/switch-exhaustiveness-check.md
Zamiell Dec 6, 2023
e1e8554
docs: add playground link
Zamiell Dec 6, 2023
7dbafe5
Update packages/eslint-plugin/src/rules/switch-exhaustiveness-check.ts
Zamiell Dec 6, 2023
d0611cb
chore: add punctuation
Zamiell Dec 6, 2023
2c6dfb4
Update packages/eslint-plugin/src/rules/switch-exhaustiveness-check.ts
Zamiell Dec 6, 2023
279aa9c
chore: remove comment
Zamiell Dec 6, 2023
29284b2
refactor: rename option
Zamiell Dec 6, 2023
8e24cfb
fix: prettier
Zamiell Dec 6, 2023
9fda18d
fix: lint
Zamiell Dec 6, 2023
24327c4
fix: tests
Zamiell Dec 6, 2023
c4a7646
Merge branch 'main' into switch
Zamiell Dec 7, 2023
e5f0587
refactor: better metadata
Zamiell Dec 7, 2023
75e3015
fix: tests
Zamiell Dec 7, 2023
6e427b4
refactor: rename interface
Zamiell Dec 12, 2023
8d8bba6
refactor: make interface readonly
Zamiell Dec 12, 2023
fbc3f3e
Merge branch 'main' into switch
Zamiell Dec 12, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
Expand Up @@ -6,11 +6,12 @@ description: 'Require switch-case statements to be exhaustive with union type.'
>
> See **https://typescript-eslint.io/rules/switch-exhaustiveness-check** for documentation.

When working with union types in TypeScript, it's common to want to write a `switch` statement intended to contain a `case` for each constituent (possible type in the union).
However, if the union type changes, it's easy to forget to modify the cases to account for any new types.
When working with union types (or enums) in TypeScript, it is common to write a `switch` statement intended to contain a `case` for each constituent (possible type in the union). However, if the union type (or enum) is added to, it is easy to forget to modify the switch statements throughout the codebase to account for the new addition.

This rule reports when a `switch` statement over a value typed as a union of literals is missing a case for any of those literal types and does not have a `default` clause.

Additionally, this rule also reports when a `switch` statement has a case for everything in a union and _also_ contains a `default` case. `default` cases in this situation are obviously superfluous, as they would contain dead code. But beyond being superfluous, these kind of `default` cases are actively harmful: if a new value is added to the switch statement union, the `default` statement would prevent this rule from alerting you that you need to handle the new case.

## Examples

<!--tabs-->
Expand Down
187 changes: 133 additions & 54 deletions packages/eslint-plugin/src/rules/switch-exhaustiveness-check.ts
Expand Up @@ -11,6 +11,22 @@ import {
requiresQuoting,
} from '../util';

interface SwitchStatementMetadata {
/** The name of the union that is inside of the switch statement. */
Zamiell marked this conversation as resolved.
Show resolved Hide resolved
symbolName: string | undefined;

/**
* If the length of this array is equal to 0, then the switch statement is
* exhaustive.
*/
missingBranchTypes: ts.Type[];

/**
* The node representing the `default` case on the switch statement, if any.
*/
defaultCase: TSESTree.SwitchCase | undefined;
}

export default createRule({
name: 'switch-exhaustiveness-check',
meta: {
Expand All @@ -25,6 +41,8 @@ export default createRule({
messages: {
switchIsNotExhaustive:
'Switch is not exhaustive. Cases not matched: {{missingBranches}}',
dangerousDefaultCase:
'The switch statement is exhaustive, so the default case is superfluous and will obfuscate future additions to the union.',
Zamiell marked this conversation as resolved.
Show resolved Hide resolved
addMissingCases: 'Add branches for missing cases.',
},
},
Expand All @@ -35,6 +53,104 @@ export default createRule({
const checker = services.program.getTypeChecker();
const compilerOptions = services.program.getCompilerOptions();

/**
* @returns Metadata about whether the switch is exhaustive (or `undefined`
* if the switch case is not a union).
*/
function getSwitchStatementMetadata(
node: TSESTree.SwitchStatement,
): SwitchStatementMetadata | undefined {
const discriminantType = getConstrainedTypeAtLocation(
services,
node.discriminant,
);
if (!discriminantType.isUnion()) {
return undefined;
}

const caseTypes = new Set<ts.Type>();
for (const switchCase of node.cases) {
// If the `test` property of the switch case is `null`, then we are on a
// `default` case.
if (switchCase.test == null) {
continue;
}

const caseType = getConstrainedTypeAtLocation(
services,
switchCase.test,
);
caseTypes.add(caseType);
Zamiell marked this conversation as resolved.
Show resolved Hide resolved
}

const unionTypes = tsutils.unionTypeParts(discriminantType);
const missingBranchTypes = unionTypes.filter(
unionType => !caseTypes.has(unionType),
);

/**
* Convert `ts.__String | undefined` to `string | undefined` for
* simplicity.
*/
Zamiell marked this conversation as resolved.
Show resolved Hide resolved
const symbolName = discriminantType.getSymbol()?.escapedName as
| string
| undefined;

/**
* The `test` property of a `SwitchCase` node will usually be a `Literal`
* node. However, on a `default` case, it will be equal to `null`.
*/
Zamiell marked this conversation as resolved.
Show resolved Hide resolved
const defaultCase = node.cases.find(
switchCase => switchCase.test == null,
);

return {
missingBranchTypes,
symbolName,
defaultCase,
};
}

function checkSwitchExhaustive(
node: TSESTree.SwitchStatement,
switchStatementMetadata: SwitchStatementMetadata,
): void {
const { missingBranchTypes, symbolName, defaultCase } =
switchStatementMetadata;
Zamiell marked this conversation as resolved.
Show resolved Hide resolved

// We only trigger the rule if a `default` case does not exist, since that
// would disqualify the switch statement from having cases that exactly
// match the members of a union.
if (missingBranchTypes.length > 0 && defaultCase === undefined) {
context.report({
node: node.discriminant,
messageId: 'switchIsNotExhaustive',
data: {
missingBranches: missingBranchTypes
.map(missingType =>
tsutils.isTypeFlagSet(missingType, ts.TypeFlags.ESSymbolLike)
? `typeof ${missingType.getSymbol()?.escapedName as string}`
: checker.typeToString(missingType),
)
.join(' | '),
},
suggest: [
{
messageId: 'addMissingCases',
fix(fixer): TSESLint.RuleFix | null {
return fixSwitch(
fixer,
node,
missingBranchTypes,
symbolName?.toString(),
);
},
},
],
});
}
}

function fixSwitch(
fixer: TSESLint.RuleFixer,
node: TSESTree.SwitchStatement,
Expand Down Expand Up @@ -107,67 +223,30 @@ export default createRule({
);
}

function checkSwitchExhaustive(node: TSESTree.SwitchStatement): void {
const discriminantType = getConstrainedTypeAtLocation(
services,
node.discriminant,
);
const symbolName = discriminantType.getSymbol()?.escapedName;

if (discriminantType.isUnion()) {
const unionTypes = tsutils.unionTypeParts(discriminantType);
const caseTypes = new Set<ts.Type>();
for (const switchCase of node.cases) {
if (switchCase.test == null) {
// Switch has 'default' branch - do nothing.
return;
}

caseTypes.add(
getConstrainedTypeAtLocation(services, switchCase.test),
);
}

const missingBranchTypes = unionTypes.filter(
unionType => !caseTypes.has(unionType),
);

if (missingBranchTypes.length === 0) {
// All cases matched - do nothing.
return;
}
function checkSwitchDangerousDefaultCase(
Zamiell marked this conversation as resolved.
Show resolved Hide resolved
node: TSESTree.SwitchStatement,
switchStatementMetadata: SwitchStatementMetadata,
): void {
const { missingBranchTypes, defaultCase } = switchStatementMetadata;

if (missingBranchTypes.length === 0 && defaultCase !== undefined) {
context.report({
node: node.discriminant,
messageId: 'switchIsNotExhaustive',
data: {
missingBranches: missingBranchTypes
.map(missingType =>
tsutils.isTypeFlagSet(missingType, ts.TypeFlags.ESSymbolLike)
? `typeof ${missingType.getSymbol()?.escapedName as string}`
: checker.typeToString(missingType),
)
.join(' | '),
},
suggest: [
{
messageId: 'addMissingCases',
fix(fixer): TSESLint.RuleFix | null {
return fixSwitch(
fixer,
node,
missingBranchTypes,
symbolName?.toString(),
);
},
},
],
node: defaultCase,
messageId: 'dangerousDefaultCase',
});
}
}

return {
SwitchStatement: checkSwitchExhaustive,
Zamiell marked this conversation as resolved.
Show resolved Hide resolved
SwitchStatement(node): void {
const switchStatementMetadata = getSwitchStatementMetadata(node);
if (switchStatementMetadata === undefined) {
return;
}

checkSwitchExhaustive(node, switchStatementMetadata);
checkSwitchDangerousDefaultCase(node, switchStatementMetadata);
},
};
},
});
Expand Up @@ -602,5 +602,29 @@ function test(arg: Enum): string {
},
],
},
{
// has dangerous default case
code: `
type MyUnion = 'foo' | 'bar' | 'baz';

declare const myUnion: MyUnion;

switch (myUnion) {
case 'foo':
case 'bar':
case 'baz': {
break;
}
default: {
break;
}
}
`,
errors: [
{
messageId: 'dangerousDefaultCase',
},
],
},
],
});