Skip to content

feat(eslint-plugin): prefer-consistent-enums rule #3891

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

Closed
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/eslint-plugin/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,7 @@ Pro Tip: For larger codebases you may want to consider splitting our linting int
| [`@typescript-eslint/no-var-requires`](./docs/rules/no-var-requires.md) | Disallows the use of require statements except in import statements | :white_check_mark: | | |
| [`@typescript-eslint/non-nullable-type-assertion-style`](./docs/rules/non-nullable-type-assertion-style.md) | Prefers a non-null assertion over explicit type cast when possible | | :wrench: | :thought_balloon: |
| [`@typescript-eslint/prefer-as-const`](./docs/rules/prefer-as-const.md) | Prefer usage of `as const` over literal type | :white_check_mark: | :wrench: | |
| [`@typescript-eslint/prefer-consistent-enums`](./docs/rules/prefer-consistent-enums.md) | Prefer consistent enum members | | | |
| [`@typescript-eslint/prefer-enum-initializers`](./docs/rules/prefer-enum-initializers.md) | Prefer initializing each enums member value | | | |
| [`@typescript-eslint/prefer-for-of`](./docs/rules/prefer-for-of.md) | Prefer a ‘for-of’ loop over a standard ‘for’ loop if the index is only used to access the array being iterated | | | |
| [`@typescript-eslint/prefer-function-type`](./docs/rules/prefer-function-type.md) | Use function types instead of interfaces with call signatures | | :wrench: | |
Expand Down
75 changes: 75 additions & 0 deletions packages/eslint-plugin/docs/rules/prefer-consistent-enums.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
# Prefer consistent enum members (`prefer-consistent-enums`)

This rule recommends having each `enum`s member type to be the same.

## Rule Details

You can iterate over enums using `Object.keys` / `Object.values`.

If all enum members are strings — result is consistent and number of items will match number of enum members:

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

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

But if enum will have some members that are initialized with numbers, or not initialized at all — iteration over that enum will have additional auto generated items

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

Object.values(Status); // ["Pending", 0, "open", "closed"]
```

Examples of **incorrect** code for this rule:

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

enum Direction {
Up = 0,
Down,
}

enum Color {
Red = 5,
Green = 'Green'
Blue = 'Blue',
}
```

Examples of **correct** code for this rule:

```ts
enum Status {
Open = 'Open',
Close = 'Close',
}

enum Direction {
Up = 1,
Down = 2,
}

enum Color {
Red = 'Red',
Green = 'Green',
Blue = 'Blue',
}
```

## When Not To Use It

If you don't iterate over `enum`s you can safely disable this rule.
1 change: 1 addition & 0 deletions packages/eslint-plugin/src/configs/all.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ export = {
'padding-line-between-statements': 'off',
'@typescript-eslint/padding-line-between-statements': 'error',
'@typescript-eslint/prefer-as-const': 'error',
'@typescript-eslint/prefer-consistent-enums': 'error',
'@typescript-eslint/prefer-enum-initializers': 'error',
'@typescript-eslint/prefer-for-of': 'error',
'@typescript-eslint/prefer-function-type': 'error',
Expand Down
2 changes: 2 additions & 0 deletions packages/eslint-plugin/src/rules/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ import nonNullableTypeAssertionStyle from './non-nullable-type-assertion-style';
import objectCurlySpacing from './object-curly-spacing';
import paddingLineBetweenStatements from './padding-line-between-statements';
import preferAsConst from './prefer-as-const';
import preferConsistentEnums from './prefer-consistent-enums';
import preferEnumInitializers from './prefer-enum-initializers';
import preferForOf from './prefer-for-of';
import preferFunctionType from './prefer-function-type';
Expand Down Expand Up @@ -205,6 +206,7 @@ export default {
'object-curly-spacing': objectCurlySpacing,
'padding-line-between-statements': paddingLineBetweenStatements,
'prefer-as-const': preferAsConst,
'prefer-consistent-enums': preferConsistentEnums,
'prefer-enum-initializers': preferEnumInitializers,
'prefer-for-of': preferForOf,
'prefer-function-type': preferFunctionType,
Expand Down
161 changes: 161 additions & 0 deletions packages/eslint-plugin/src/rules/prefer-consistent-enums.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
import { TSESLint, TSESTree } from '@typescript-eslint/experimental-utils';
import * as util from '../util';

type MessageIds =
| 'nonConsistentEnum'
| 'nonConsistentEnumSuggestion'
| 'nonConsistentEnumSuggestionNoInitializer';
const NO_INITIALIZER = 'noInitializer';

export default util.createRule<[], MessageIds>({
name: 'prefer-consistent-enums',
meta: {
type: 'suggestion',
docs: {
description: 'Prefer consistent enum members',
category: 'Possible Errors',
recommended: false,
requiresTypeChecking: false,
},
messages: {
nonConsistentEnum: `All enum members of {{ name }} must be same type (string, number, boolean, etc).`,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

boolean

I'm under the impression TypeScript enums can't have boolean typed values: only string or number.

nonConsistentEnumSuggestion: `Can be fixed to {{ name }} = {{ suggested }}`,
nonConsistentEnumSuggestionNoInitializer: `Can be fixed to {{ name }}`,
},
schema: [],
},
defaultOptions: [],
create(context) {
const sourceCode = context.getSourceCode();

function TSEnumDeclaration(node: TSESTree.TSEnumDeclaration): void {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: out of curiosity, is there a reason to have this as a standalone function instead of an inline member of the returned object?

return {
  TSEnumDeclaration(node: TSESTree.TSEnumDeclaration): void {
    // ...
  }
}

const enumName = sourceCode.getText(node.id);
const { members } = node;

let enumType: string;
let lastNumericValue: number;

members.forEach((member, index) => {
let memberType: string | undefined;
let memberValue: TSESTree.Literal['value'] | undefined;

/**
* Getting enum member initializer type
* If it's number — get its value to suggest new one
* If it's binary expression — treat it like number but without getting its value
*/
if (member.initializer) {
switch (member.initializer.type) {
case TSESTree.AST_NODE_TYPES.Literal:
memberValue = member.initializer.value;
memberType = typeof member.initializer.value;
if (memberType === 'number') {
lastNumericValue = member.initializer.value as number;
}
break;
case TSESTree.AST_NODE_TYPES.BinaryExpression:
memberType = 'number';
break;
}
} else {
memberType = NO_INITIALIZER;
}

if (!memberType) {
return;
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Indeed, this check isn't covered in tests. Perhaps it'd be good to have a test that includes an invalid enum value such as a boolean or array?


/**
* If it's first enum member — remember its type and continue to next one
*/
if (!enumType) {
enumType = memberType;
return;
}

/**
* If initializers types dont match — suggest change
*/
if (enumType !== memberType) {
Comment on lines +76 to +79
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Teeny nit: consider making this also return early, to reduce nesting later on? This function is pretty nested as-is.

Suggested change
/**
* If initializers types dont match suggest change
*/
if (enumType !== memberType) {
/**
* If initializers types match don't suggest change
*/
if (enumType === memberType) {
return;
}

(not a blocker IMO)

const name = sourceCode.getText(member.id);

/**
* If base enum type is string — transforming initializer to string
* or create new one base on enum member name
*/
if (enumType === 'string') {
context.report({
node: member,
messageId: 'nonConsistentEnum',
data: { name: enumName },
suggest: [
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤔 I'm a little -1 on suggesting such an unsafe change. In theory folks shouldn't rely on enum values but in practice it happens. eslint/eslint#7873

I'll defer to another reviewer's opinion if they feel strongly on it.

{
messageId: 'nonConsistentEnumSuggestion',
data: { name, suggested: `'${memberValue ?? name}'` },
fix: (fixer): TSESLint.RuleFix =>
fixer.replaceText(
member,
`${name} = '${memberValue ?? name}'`,
),
},
],
});
return;
}

/**
* If base enum type is number — suggest replacing initializer
* with last numeric identifier + 1 or just enum member index
*/
if (enumType === 'number') {
const newIndex =
typeof lastNumericValue !== 'undefined'
? lastNumericValue + 1
: index + 1;
context.report({
node: member,
messageId: 'nonConsistentEnum',
data: { name: enumName },
suggest: [
{
messageId: 'nonConsistentEnumSuggestion',
data: { name, suggested: newIndex },
fix: (fixer): TSESLint.RuleFix =>
fixer.replaceText(member, `${name} = ${newIndex}`),
},
],
});
return;
}

/**
* If enum have no initializers — suggest removing one
*/
if (enumType === NO_INITIALIZER) {
context.report({
node: member,
messageId: 'nonConsistentEnum',
data: { name: enumName },
suggest: [
{
messageId: 'nonConsistentEnumSuggestionNoInitializer',
data: { name },
fix: (fixer): TSESLint.RuleFix =>
fixer.replaceText(member, `${name}`),
},
],
});
}

/**
* No suggestions for other enum types
*/
}
});
}

return {
TSEnumDeclaration,
};
},
});
Loading