Skip to content

Commit dfd35e3

Browse files
auvreddanvk
authored andcommitted
fix(eslint-plugin): [switch-exhaustiveness-check] better support for intersections, infinite types, non-union values (typescript-eslint#8250)
* feat(eslint-plugin): [switch-exhaustiveness-check] better support for intersections, infinite types, non-union values * chore: try to fix weird diff with main * refactor: no need to collect missing branches in function * fix: provide valid fixes for unique symbols * fix: valid fixes for unique symbols + few test cases for enums
1 parent cffeff1 commit dfd35e3

File tree

2 files changed

+1255
-90
lines changed

2 files changed

+1255
-90
lines changed

packages/eslint-plugin/src/rules/switch-exhaustiveness-check.ts

Lines changed: 60 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,8 @@ import {
1414

1515
interface SwitchMetadata {
1616
readonly symbolName: string | undefined;
17-
readonly missingBranchTypes: ts.Type[];
1817
readonly defaultCase: TSESTree.SwitchCase | undefined;
19-
readonly isUnion: boolean;
18+
readonly missingLiteralBranchTypes: ts.Type[];
2019
readonly containsNonLiteralType: boolean;
2120
}
2221

@@ -109,16 +108,6 @@ export default createRule<Options, MessageIds>({
109108
const containsNonLiteralType =
110109
doesTypeContainNonLiteralType(discriminantType);
111110

112-
if (!discriminantType.isUnion()) {
113-
return {
114-
symbolName,
115-
missingBranchTypes: [],
116-
defaultCase,
117-
isUnion: false,
118-
containsNonLiteralType,
119-
};
120-
}
121-
122111
const caseTypes = new Set<ts.Type>();
123112
for (const switchCase of node.cases) {
124113
// If the `test` property of the switch case is `null`, then we are on a
@@ -134,54 +123,47 @@ export default createRule<Options, MessageIds>({
134123
caseTypes.add(caseType);
135124
}
136125

137-
const unionTypes = tsutils.unionTypeParts(discriminantType);
138-
const missingBranchTypes = unionTypes.filter(
139-
unionType => !caseTypes.has(unionType),
140-
);
126+
const missingLiteralBranchTypes: ts.Type[] = [];
127+
128+
for (const unionPart of tsutils.unionTypeParts(discriminantType)) {
129+
for (const intersectionPart of tsutils.intersectionTypeParts(
130+
unionPart,
131+
)) {
132+
if (
133+
caseTypes.has(intersectionPart) ||
134+
!isTypeLiteralLikeType(intersectionPart)
135+
) {
136+
continue;
137+
}
138+
139+
missingLiteralBranchTypes.push(intersectionPart);
140+
}
141+
}
141142

142143
return {
143144
symbolName,
144-
missingBranchTypes,
145+
missingLiteralBranchTypes,
145146
defaultCase,
146-
isUnion: true,
147147
containsNonLiteralType,
148148
};
149149
}
150150

151-
/**
152-
* For example:
153-
*
154-
* - `"foo" | "bar"` is a type with all literal types.
155-
* - `"foo" | number` is a type that contains non-literal types.
156-
*
157-
* Default cases are never superfluous in switches with non-literal types.
158-
*/
159-
function doesTypeContainNonLiteralType(type: ts.Type): boolean {
160-
const types = tsutils.unionTypeParts(type);
161-
return types.some(
162-
type =>
163-
!isFlagSet(
164-
type.getFlags(),
165-
ts.TypeFlags.Literal | ts.TypeFlags.Undefined | ts.TypeFlags.Null,
166-
),
167-
);
168-
}
169-
170151
function checkSwitchExhaustive(
171152
node: TSESTree.SwitchStatement,
172153
switchMetadata: SwitchMetadata,
173154
): void {
174-
const { missingBranchTypes, symbolName, defaultCase } = switchMetadata;
155+
const { missingLiteralBranchTypes, symbolName, defaultCase } =
156+
switchMetadata;
175157

176158
// We only trigger the rule if a `default` case does not exist, since that
177159
// would disqualify the switch statement from having cases that exactly
178160
// match the members of a union.
179-
if (missingBranchTypes.length > 0 && defaultCase === undefined) {
161+
if (missingLiteralBranchTypes.length > 0 && defaultCase === undefined) {
180162
context.report({
181163
node: node.discriminant,
182164
messageId: 'switchIsNotExhaustive',
183165
data: {
184-
missingBranches: missingBranchTypes
166+
missingBranches: missingLiteralBranchTypes
185167
.map(missingType =>
186168
tsutils.isTypeFlagSet(missingType, ts.TypeFlags.ESSymbolLike)
187169
? `typeof ${missingType.getSymbol()?.escapedName as string}`
@@ -196,7 +178,7 @@ export default createRule<Options, MessageIds>({
196178
return fixSwitch(
197179
fixer,
198180
node,
199-
missingBranchTypes,
181+
missingLiteralBranchTypes,
200182
symbolName?.toString(),
201183
);
202184
},
@@ -227,24 +209,13 @@ export default createRule<Options, MessageIds>({
227209
continue;
228210
}
229211

230-
// While running this rule on the "checker.ts" file of TypeScript, the
231-
// the fix introduced a compiler error due to:
232-
//
233-
// ```ts
234-
// type __String = (string & {
235-
// __escapedIdentifier: void;
236-
// }) | (void & {
237-
// __escapedIdentifier: void;
238-
// }) | InternalSymbolName;
239-
// ```
240-
//
241-
// The following check fixes it.
242-
if (missingBranchType.isIntersection()) {
243-
continue;
244-
}
245-
246212
const missingBranchName = missingBranchType.getSymbol()?.escapedName;
247-
let caseTest = checker.typeToString(missingBranchType);
213+
let caseTest = tsutils.isTypeFlagSet(
214+
missingBranchType,
215+
ts.TypeFlags.ESSymbolLike,
216+
)
217+
? missingBranchName!
218+
: checker.typeToString(missingBranchType);
248219

249220
if (
250221
symbolName &&
@@ -298,11 +269,11 @@ export default createRule<Options, MessageIds>({
298269
return;
299270
}
300271

301-
const { missingBranchTypes, defaultCase, containsNonLiteralType } =
272+
const { missingLiteralBranchTypes, defaultCase, containsNonLiteralType } =
302273
switchMetadata;
303274

304275
if (
305-
missingBranchTypes.length === 0 &&
276+
missingLiteralBranchTypes.length === 0 &&
306277
defaultCase !== undefined &&
307278
!containsNonLiteralType
308279
) {
@@ -321,9 +292,9 @@ export default createRule<Options, MessageIds>({
321292
return;
322293
}
323294

324-
const { isUnion, defaultCase } = switchMetadata;
295+
const { defaultCase, containsNonLiteralType } = switchMetadata;
325296

326-
if (!isUnion && defaultCase === undefined) {
297+
if (containsNonLiteralType && defaultCase === undefined) {
327298
context.report({
328299
node: node.discriminant,
329300
messageId: 'switchIsNotExhaustive',
@@ -354,6 +325,31 @@ export default createRule<Options, MessageIds>({
354325
},
355326
});
356327

357-
function isFlagSet(flags: number, flag: number): boolean {
358-
return (flags & flag) !== 0;
328+
function isTypeLiteralLikeType(type: ts.Type): boolean {
329+
return tsutils.isTypeFlagSet(
330+
type,
331+
ts.TypeFlags.Literal |
332+
ts.TypeFlags.Undefined |
333+
ts.TypeFlags.Null |
334+
ts.TypeFlags.UniqueESSymbol,
335+
);
336+
}
337+
338+
/**
339+
* For example:
340+
*
341+
* - `"foo" | "bar"` is a type with all literal types.
342+
* - `"foo" | number` is a type that contains non-literal types.
343+
* - `"foo" & { bar: 1 }` is a type that contains non-literal types.
344+
*
345+
* Default cases are never superfluous in switches with non-literal types.
346+
*/
347+
function doesTypeContainNonLiteralType(type: ts.Type): boolean {
348+
return tsutils
349+
.unionTypeParts(type)
350+
.some(type =>
351+
tsutils
352+
.intersectionTypeParts(type)
353+
.every(subType => !isTypeLiteralLikeType(subType)),
354+
);
359355
}

0 commit comments

Comments
 (0)