Skip to content

Commit

Permalink
feat(eslint-plugin): no-unsafe-enum-comparison handles switch cases (#…
Browse files Browse the repository at this point in the history
…7898)

* feat: no-unsafe-enum-comparison handles switch cases

* fix: lint

* Update packages/eslint-plugin/docs/rules/no-unsafe-enum-comparison.md

Co-authored-by: Josh Goldberg ✨ <git@joshuakgoldberg.com>

* Update packages/eslint-plugin/docs/rules/no-unsafe-enum-comparison.md

Co-authored-by: Josh Goldberg ✨ <git@joshuakgoldberg.com>

* refactor: mismatched --> mismatchedCondition

* refactor: add more tests

* Update packages/eslint-plugin/docs/rules/no-unsafe-enum-comparison.md

Co-authored-by: Josh Goldberg ✨ <git@joshuakgoldberg.com>

* fix: assertion

* fix: compiler error

* fix: prettier

---------

Co-authored-by: James <5511220+Zamiell@users.noreply.github.com>
  • Loading branch information
JoshuaKGoldberg and Zamiell committed Nov 13, 2023
1 parent 99a026f commit 72cb9e4
Show file tree
Hide file tree
Showing 4 changed files with 267 additions and 96 deletions.
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,
});
}
},
};
},
});

0 comments on commit 72cb9e4

Please sign in to comment.