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): no-unsafe-enum-comparison handles switch cases #7898

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
22 changes: 12 additions & 10 deletions packages/eslint-plugin/docs/rules/no-unsafe-enum-comparison.md
Expand Up @@ -6,21 +6,23 @@ description: 'Disallow comparing an enum value with a non-enum value.'
>
> See **https://typescript-eslint.io/rules/no-unsafe-enum-comparison** for documentation.

The TypeScript compiler can be surprisingly lenient when working with enums.
For example, it will allow you to compare enum values against numbers even though they might not have any type overlap:
The TypeScript compiler can be surprisingly lenient when working with enums. String enums are widely considered to be safer than number enums, but even string enums have some pitfalls. For example, it is allowed to compare enum values against literals:

```ts
enum Fruit {
Apple,
Banana,
enum Vegetable {
Asparagus = 'asparagus',
}

declare let fruit: Fruit;
declare const vegetable: Vegetable;

fruit === 999; // No error
vegetable === 'asparagus'; // No error
```

This rule flags when an enum typed value is compared to a non-enum `number`.
The above code snippet should instead be written as `vegetable === Vegetable.Asparagus`. Allowing literals in comparisons subverts the point of using enums in the first place. By enforcing comparisons with properly typed enums:

- It makes a codebase more resilient to enum members changing values.
- It allows for code IDEs to use the "Rename Symbol" feature to quickly rename an enum.
- It aligns code to the proper enum semantics of referring to them by name and treating their values as implementation details.

## Examples

Expand All @@ -35,7 +37,7 @@ enum Fruit {

declare let fruit: Fruit;

fruit === 999;
fruit === 0;
```

```ts
Expand All @@ -57,7 +59,7 @@ enum Fruit {

declare let fruit: Fruit;

fruit === Fruit.Banana;
fruit === Fruit.Apple;
```

```ts
Expand Down
Expand Up @@ -43,7 +43,7 @@ export default createRule({
function checkRequiresGenericDeclarationDisambiguation(
filename: string,
): boolean {
const pathExt = extname(filename).toLocaleLowerCase();
const pathExt = extname(filename).toLocaleLowerCase() as ts.Extension;
switch (pathExt) {
case ts.Extension.Cts:
case ts.Extension.Mts:
Expand Down
129 changes: 82 additions & 47 deletions packages/eslint-plugin/src/rules/no-unsafe-enum-comparison.ts
Expand Up @@ -51,7 +51,9 @@ export default createRule({
requiresTypeChecking: true,
},
messages: {
mismatched:
mismatchedCase:
'The case statement does not have a shared enum type with the switch predicate.',
mismatchedCondition:
'The two values in this comparison do not have a shared enum type.',
replaceValueWithEnum: 'Replace with an enum value comparison.',
},
Expand All @@ -62,56 +64,63 @@ export default createRule({
const parserServices = getParserServices(context);
const typeChecker = parserServices.program.getTypeChecker();

return {
'BinaryExpression[operator=/^[<>!=]?={0,2}$/]'(
node: TSESTree.BinaryExpression,
): void {
const left = parserServices.getTypeAtLocation(node.left);
const right = parserServices.getTypeAtLocation(node.right);

// Allow comparisons that don't have anything to do with enums:
//
// ```ts
// 1 === 2;
// ```
const leftEnumTypes = getEnumTypes(typeChecker, left);
const rightEnumTypes = new Set(getEnumTypes(typeChecker, right));
if (leftEnumTypes.length === 0 && rightEnumTypes.size === 0) {
return;
}
function isMismatchedComparison(
leftType: ts.Type,
rightType: ts.Type,
): boolean {
// Allow comparisons that don't have anything to do with enums:
//
// ```ts
// 1 === 2;
// ```
const leftEnumTypes = getEnumTypes(typeChecker, leftType);
const rightEnumTypes = new Set(getEnumTypes(typeChecker, rightType));
if (leftEnumTypes.length === 0 && rightEnumTypes.size === 0) {
return false;
}

// Allow comparisons that share an enum type:
//
// ```ts
// Fruit.Apple === Fruit.Banana;
// ```
for (const leftEnumType of leftEnumTypes) {
if (rightEnumTypes.has(leftEnumType)) {
return;
}
// Allow comparisons that share an enum type:
//
// ```ts
// Fruit.Apple === Fruit.Banana;
// ```
for (const leftEnumType of leftEnumTypes) {
if (rightEnumTypes.has(leftEnumType)) {
return false;
}
}

const leftTypeParts = tsutils.unionTypeParts(leftType);
const rightTypeParts = tsutils.unionTypeParts(rightType);

const leftTypeParts = tsutils.unionTypeParts(left);
const rightTypeParts = tsutils.unionTypeParts(right);

// If a type exists in both sides, we consider this comparison safe:
//
// ```ts
// declare const fruit: Fruit.Apple | 0;
// fruit === 0;
// ```
for (const leftTypePart of leftTypeParts) {
if (rightTypeParts.includes(leftTypePart)) {
return;
}
// If a type exists in both sides, we consider this comparison safe:
//
// ```ts
// declare const fruit: Fruit.Apple | 0;
// fruit === 0;
// ```
for (const leftTypePart of leftTypeParts) {
if (rightTypeParts.includes(leftTypePart)) {
return false;
}
}

return (
typeViolates(leftTypeParts, rightType) ||
typeViolates(rightTypeParts, leftType)
);
}

return {
'BinaryExpression[operator=/^[<>!=]?={0,2}$/]'(
node: TSESTree.BinaryExpression,
): void {
const leftType = parserServices.getTypeAtLocation(node.left);
const rightType = parserServices.getTypeAtLocation(node.right);

if (
typeViolates(leftTypeParts, right) ||
typeViolates(rightTypeParts, left)
) {
if (isMismatchedComparison(leftType, rightType)) {
context.report({
messageId: 'mismatched',
messageId: 'mismatchedCondition',
node,
suggest: [
{
Expand All @@ -123,7 +132,7 @@ export default createRule({
// Fruit.Apple === 'apple'; // Fruit.Apple === Fruit.Apple
// ```
const leftEnumKey = getEnumKeyForLiteral(
getEnumLiterals(left),
getEnumLiterals(leftType),
getStaticValue(node.right)?.value,
);

Expand All @@ -138,7 +147,7 @@ export default createRule({
// 'apple' === Fruit.Apple; // Fruit.Apple === Fruit.Apple
// ```
const rightEnumKey = getEnumKeyForLiteral(
getEnumLiterals(right),
getEnumLiterals(rightType),
getStaticValue(node.left)?.value,
);

Expand All @@ -153,6 +162,32 @@ export default createRule({
});
}
},

SwitchCase(node): void {
// Ignore `default` cases.
if (node.test == null) {
return;
}

const { parent } = node;

/**
* @see https://github.com/typescript-eslint/typescript-eslint/issues/6225
*/
const switchStatement = parent as TSESTree.SwitchStatement;

const leftType = parserServices.getTypeAtLocation(
switchStatement.discriminant,
);
const rightType = parserServices.getTypeAtLocation(node.test);

if (isMismatchedComparison(leftType, rightType)) {
context.report({
messageId: 'mismatchedCase',
node,
});
}
},
};
},
});