-
-
Notifications
You must be signed in to change notification settings - Fork 2.8k
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
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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. |
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).`, | ||||||||||||||||||||||
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 { | ||||||||||||||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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?
|
||||||||||||||||||||||
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; | ||||||||||||||||||||||
} | ||||||||||||||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
(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: [ | ||||||||||||||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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, | ||||||||||||||||||||||
}; | ||||||||||||||||||||||
}, | ||||||||||||||||||||||
}); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm under the impression TypeScript enums can't have
boolean
typed values: onlystring
ornumber
.