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-mixed-enums] add rule #6102

Merged
merged 13 commits into from Feb 16, 2023
85 changes: 85 additions & 0 deletions packages/eslint-plugin/docs/rules/no-mixed-enums.md
@@ -0,0 +1,85 @@
---
description: 'Disallow enums from having both number and string members.'
---

> 🛑 This file is source code, not the primary documentation location! 🛑
>
> See **https://typescript-eslint.io/rules/no-mixed-enums** for documentation.

TypeScript enums are allowed to assign numeric or string values to their members.
Most enums contain either all numbers or all strings, but in theory you can mix-and-match within the same enum.
Mixing enum member types is generally considered confusing and a bad practice.

## Examples

<!--tabs-->

### ❌ Incorrect

```ts
enum Status {
Unknown,
Closed = 1,
Open = 'open',
}
```

### ✅ Correct (Explicit Numbers)

```ts
enum Status {
Unknown = 0,
Closed = 1,
Open = 2
JoshuaKGoldberg marked this conversation as resolved.
Show resolved Hide resolved
```

### ✅ Correct (Implicit Numbers)

```ts
enum Status {
Unknown,
Closed,
Open
JoshuaKGoldberg marked this conversation as resolved.
Show resolved Hide resolved
```

### ✅ Correct (Strings)

```ts
enum Status {
Unknown = 'unknown',
Closed = 'closed',
Open = 'open',
JoshuaKGoldberg marked this conversation as resolved.
Show resolved Hide resolved
```

## Iteration Pitfalls of Mixed Enum Member Values

Enum values may be iterated over using `Object.entries`/`Object.keys`/`Object.values`.

If all enum members are strings, the number of items will match the number of enum members:

```ts
enum Status {
Closed = 'closed',
Open = 'open',
}

// ['closed', 'open']
Object.values(Status);
```

But if the enum contains members that are initialized with numbers -including implicitly initialized numbers— then iteration over that enum will include those numbers as well:

```ts
enum Status {
Unknown,
Closed = 1,
Open = 'open',
}

// ["Unknown", "Closed", 0, 1, "open"]
Object.values(Status);
```

## When Not To Use It

If you don't mind the confusion of mixed enum member values and don't iterate over enums, you can safely disable this rule.
1 change: 1 addition & 0 deletions packages/eslint-plugin/src/configs/all.ts
Expand Up @@ -84,6 +84,7 @@ export = {
'@typescript-eslint/no-meaningless-void-operator': 'error',
'@typescript-eslint/no-misused-new': 'error',
'@typescript-eslint/no-misused-promises': 'error',
'@typescript-eslint/no-mixed-enums': 'error',
'@typescript-eslint/no-namespace': 'error',
'@typescript-eslint/no-non-null-asserted-nullish-coalescing': 'error',
'@typescript-eslint/no-non-null-asserted-optional-chain': 'error',
Expand Down
1 change: 1 addition & 0 deletions packages/eslint-plugin/src/configs/strict.ts
Expand Up @@ -21,6 +21,7 @@ export = {
'@typescript-eslint/no-extraneous-class': 'warn',
'@typescript-eslint/no-invalid-void-type': 'warn',
'@typescript-eslint/no-meaningless-void-operator': 'warn',
'@typescript-eslint/no-mixed-enums': 'warn',
'@typescript-eslint/no-non-null-asserted-nullish-coalescing': 'warn',
'no-throw-literal': 'off',
'@typescript-eslint/no-throw-literal': 'warn',
Expand Down
2 changes: 2 additions & 0 deletions packages/eslint-plugin/src/rules/index.ts
Expand Up @@ -58,6 +58,7 @@ import noMagicNumbers from './no-magic-numbers';
import noMeaninglessVoidOperator from './no-meaningless-void-operator';
import noMisusedNew from './no-misused-new';
import noMisusedPromises from './no-misused-promises';
import noMixedEnums from './no-mixed-enums';
import noNamespace from './no-namespace';
import noNonNullAssertedNullishCoalescing from './no-non-null-asserted-nullish-coalescing';
import noNonNullAssertedOptionalChain from './no-non-null-asserted-optional-chain';
Expand Down Expand Up @@ -191,6 +192,7 @@ export default {
'no-meaningless-void-operator': noMeaninglessVoidOperator,
'no-misused-new': noMisusedNew,
'no-misused-promises': noMisusedPromises,
'no-mixed-enums': noMixedEnums,
'no-namespace': noNamespace,
'no-non-null-asserted-nullish-coalescing': noNonNullAssertedNullishCoalescing,
'no-non-null-asserted-optional-chain': noNonNullAssertedOptionalChain,
Expand Down
83 changes: 83 additions & 0 deletions packages/eslint-plugin/src/rules/no-mixed-enums.ts
@@ -0,0 +1,83 @@
import type { TSESTree } from '@typescript-eslint/utils';
import * as tsutils from 'tsutils';
import * as ts from 'typescript';

import * as util from '../util';

type AllowedType = ts.TypeFlags.Number | ts.TypeFlags.String;
JoshuaKGoldberg marked this conversation as resolved.
Show resolved Hide resolved

export default util.createRule({
name: 'no-mixed-enums',
meta: {
docs: {
description: 'Disallow enums from having both number and string members',
recommended: 'strict',
requiresTypeChecking: true,
},
messages: {
mixed: `Mixing number and string enums can be confusing.`,
},
schema: [],
type: 'problem',
},
defaultOptions: [],
create(context) {
const parserServices = util.getParserServices(context);
const typeChecker = parserServices.program.getTypeChecker();

function getAllowedType(node: ts.Node): AllowedType {
return tsutils.isTypeFlagSet(
typeChecker.getTypeAtLocation(node),
ts.TypeFlags.StringLike,
)
? ts.TypeFlags.String
: ts.TypeFlags.Number;
}

function getAllowedTypeOfEnum(
node: TSESTree.TSEnumDeclaration,
): AllowedType | undefined {
const tsNode = parserServices.esTreeNodeToTSNodeMap.get(node.id);
const declarations = typeChecker
.getSymbolAtLocation(tsNode)!
.getDeclarations()!;

for (const declaration of declarations) {
for (const member of (declaration as ts.EnumDeclaration).members) {
return member.initializer
? getAllowedType(member.initializer)
: ts.TypeFlags.Number;
}
}

return undefined;
}

function getAllowedTypeOfInitializer(
initializer: TSESTree.Expression | undefined,
): ts.TypeFlags.Number | ts.TypeFlags.String {
return initializer
? getAllowedType(parserServices.esTreeNodeToTSNodeMap.get(initializer))
: ts.TypeFlags.Number;
}

return {
TSEnumDeclaration(node: TSESTree.TSEnumDeclaration): void {
const allowedType = getAllowedTypeOfEnum(node);
if (allowedType === undefined) {
return;
}

for (const member of node.members) {
if (getAllowedTypeOfInitializer(member.initializer) !== allowedType) {
context.report({
messageId: 'mixed',
node: member.initializer ?? member,
});
return;
}
}
},
};
},
});