diff --git a/packages/eslint-plugin/README.md b/packages/eslint-plugin/README.md index 5f9822ed12f..b3360692178 100644 --- a/packages/eslint-plugin/README.md +++ b/packages/eslint-plugin/README.md @@ -150,6 +150,7 @@ Pro Tip: For larger codebases you may want to consider splitting our linting int | [`@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: | | | [`@typescript-eslint/prefer-includes`](./docs/rules/prefer-includes.md) | Enforce `includes` method over `indexOf` method | | :wrench: | :thought_balloon: | +| [`@typescript-eslint/prefer-literal-enum-member`](./docs/rules/prefer-literal-enum-member.md) | Require that all enum members be literal values to prevent unintended enum member name shadow issues | | | | | [`@typescript-eslint/prefer-namespace-keyword`](./docs/rules/prefer-namespace-keyword.md) | Require the use of the `namespace` keyword instead of the `module` keyword to declare custom TypeScript modules | :heavy_check_mark: | :wrench: | | | [`@typescript-eslint/prefer-nullish-coalescing`](./docs/rules/prefer-nullish-coalescing.md) | Enforce the usage of the nullish coalescing operator instead of logical chaining | | | :thought_balloon: | | [`@typescript-eslint/prefer-optional-chain`](./docs/rules/prefer-optional-chain.md) | Prefer using concise optional chain expressions instead of chained logical ands | | | | diff --git a/packages/eslint-plugin/docs/rules/prefer-literal-enum-member.md b/packages/eslint-plugin/docs/rules/prefer-literal-enum-member.md new file mode 100644 index 00000000000..e2350f35711 --- /dev/null +++ b/packages/eslint-plugin/docs/rules/prefer-literal-enum-member.md @@ -0,0 +1,51 @@ +# Require that all enum members be literal values to prevent unintended enum member name shadow issues (`prefer-literal-enum-member`) + +TypeScript allows the value of an enum member to be many different kinds of valid JavaScript expressions. However, because enums create their own scope whereby each enum member becomes a variable in that scope, unexpected values could be used at runtime. Example: + +```ts +const imOutside = 2; +const b = 2; +enum Foo { + outer = imOutside, + a = 1, + b = a, + c = b, + // does c == Foo.b == Foo.c == 1? + // or does c == b == 2? +} +``` + +The answer is that `Foo.c` will be `1` at runtime. The [playground](https://www.typescriptlang.org/play/#src=const%20imOutside%20%3D%202%3B%0D%0Aconst%20b%20%3D%202%3B%0D%0Aenum%20Foo%20%7B%0D%0A%20%20%20%20outer%20%3D%20imOutside%2C%0D%0A%20%20%20%20a%20%3D%201%2C%0D%0A%20%20%20%20b%20%3D%20a%2C%0D%0A%20%20%20%20c%20%3D%20b%2C%0D%0A%20%20%20%20%2F%2F%20does%20c%20%3D%3D%20Foo.b%20%3D%3D%20Foo.c%20%3D%3D%201%3F%0D%0A%20%20%20%20%2F%2F%20or%20does%20c%20%3D%3D%20b%20%3D%3D%202%3F%0D%0A%7D) illustrates this quite nicely. + +## Rule Details + +This rule is meant to prevent unexpected results in code by requiring the use of literal values as enum members to prevent unexpected runtime behavior. Template literals, arrays, objects, constructors, and all other expression types can end up using a variable from its scope or the parent scope, which can result in the same unexpected behavior at runtime. + +Examples of **incorrect** code for this rule: + +```ts +const str = 'Test'; +enum Invalid { + A = str, // Variable assignment + B = {}, // Object assignment + C = `A template literal string`, // Template literal + D = new Set(1, 2, 3), // Constructor in assignment + E = 2 + 2, // Expression assignment +} +``` + +Examples of **correct** code for this rule: + +```ts +enum Valid { + A, + B = 'TestStr', // A regular string + C = 4, // A number + D = null, + E = /some_regex/, +} +``` + +## When Not To Use It + +If you want use anything other than simple literals as an enum value. diff --git a/packages/eslint-plugin/src/configs/all.ts b/packages/eslint-plugin/src/configs/all.ts index 68923e26e8a..e78feb7d44a 100644 --- a/packages/eslint-plugin/src/configs/all.ts +++ b/packages/eslint-plugin/src/configs/all.ts @@ -100,6 +100,7 @@ export = { '@typescript-eslint/prefer-for-of': 'error', '@typescript-eslint/prefer-function-type': 'error', '@typescript-eslint/prefer-includes': 'error', + '@typescript-eslint/prefer-literal-enum-member': 'error', '@typescript-eslint/prefer-namespace-keyword': 'error', '@typescript-eslint/prefer-nullish-coalescing': 'error', '@typescript-eslint/prefer-optional-chain': 'error', diff --git a/packages/eslint-plugin/src/rules/index.ts b/packages/eslint-plugin/src/rules/index.ts index 37ad1da4806..73083bedfe4 100644 --- a/packages/eslint-plugin/src/rules/index.ts +++ b/packages/eslint-plugin/src/rules/index.ts @@ -17,7 +17,9 @@ import explicitMemberAccessibility from './explicit-member-accessibility'; import explicitModuleBoundaryTypes from './explicit-module-boundary-types'; import funcCallSpacing from './func-call-spacing'; import indent from './indent'; +import initDeclarations from './init-declarations'; import keywordSpacing from './keyword-spacing'; +import linesBetweenClassMembers from './lines-between-class-members'; import memberDelimiterStyle from './member-delimiter-style'; import memberOrdering from './member-ordering'; import methodSignatureStyle from './method-signature-style'; @@ -35,10 +37,12 @@ import noExtraParens from './no-extra-parens'; import noExtraSemi from './no-extra-semi'; import noFloatingPromises from './no-floating-promises'; import noForInArray from './no-for-in-array'; +import preferLiteralEnumMember from './prefer-literal-enum-member'; import noImpliedEval from './no-implied-eval'; import noInferrableTypes from './no-inferrable-types'; import noInvalidThis from './no-invalid-this'; import noInvalidVoidType from './no-invalid-void-type'; +import noLossOfPrecision from './no-loss-of-precision'; import noMagicNumbers from './no-magic-numbers'; import noMisusedNew from './no-misused-new'; import noMisusedPromises from './no-misused-promises'; @@ -85,7 +89,6 @@ import requireAwait from './require-await'; import restrictPlusOperands from './restrict-plus-operands'; import restrictTemplateExpressions from './restrict-template-expressions'; import returnAwait from './return-await'; -import initDeclarations from './init-declarations'; import semi from './semi'; import spaceBeforeFunctionParen from './space-before-function-paren'; import strictBooleanExpressions from './strict-boolean-expressions'; @@ -95,8 +98,6 @@ import typeAnnotationSpacing from './type-annotation-spacing'; import typedef from './typedef'; import unboundMethod from './unbound-method'; import unifiedSignatures from './unified-signatures'; -import linesBetweenClassMembers from './lines-between-class-members'; -import noLossOfPrecision from './no-loss-of-precision'; export default { 'adjacent-overload-signatures': adjacentOverloadSignatures, @@ -171,6 +172,7 @@ export default { 'prefer-for-of': preferForOf, 'prefer-function-type': preferFunctionType, 'prefer-includes': preferIncludes, + 'prefer-literal-enum-member': preferLiteralEnumMember, 'prefer-namespace-keyword': preferNamespaceKeyword, 'prefer-nullish-coalescing': preferNullishCoalescing, 'prefer-optional-chain': preferOptionalChain, diff --git a/packages/eslint-plugin/src/rules/prefer-literal-enum-member.ts b/packages/eslint-plugin/src/rules/prefer-literal-enum-member.ts new file mode 100644 index 00000000000..8f95fd5946b --- /dev/null +++ b/packages/eslint-plugin/src/rules/prefer-literal-enum-member.ts @@ -0,0 +1,37 @@ +import { AST_NODE_TYPES } from '@typescript-eslint/experimental-utils'; +import { createRule } from '../util'; + +export default createRule<[], 'notLiteral'>({ + name: 'prefer-literal-enum-member', + meta: { + type: 'suggestion', + docs: { + description: + 'Require that all enum members be literal values to prevent unintended enum member name shadow issues', + category: 'Best Practices', + recommended: false, + requiresTypeChecking: false, + }, + messages: { + notLiteral: `Explicit enum value must only be a literal value (string, number, boolean, etc).`, + }, + schema: [], + }, + defaultOptions: [], + create(context) { + return { + TSEnumMember(node): void { + // If there is no initializer, then this node is just the name of the member, so ignore. + if ( + node.initializer != null && + node.initializer.type !== AST_NODE_TYPES.Literal + ) { + context.report({ + node: node.id, + messageId: 'notLiteral', + }); + } + }, + }; + }, +}); diff --git a/packages/eslint-plugin/tests/rules/prefer-literal-enum-member.test.ts b/packages/eslint-plugin/tests/rules/prefer-literal-enum-member.test.ts new file mode 100644 index 00000000000..4e309edc357 --- /dev/null +++ b/packages/eslint-plugin/tests/rules/prefer-literal-enum-member.test.ts @@ -0,0 +1,206 @@ +import rule from '../../src/rules/prefer-literal-enum-member'; +import { RuleTester, noFormat } from '../RuleTester'; + +const ruleTester = new RuleTester({ + parser: '@typescript-eslint/parser', +}); + +ruleTester.run('prefer-literal-enum-member', rule, { + valid: [ + ` +enum ValidRegex { + A = /test/, +} + `, + ` +enum ValidString { + A = 'test', +} + `, + ` +enum ValidNumber { + A = 42, +} + `, + ` +enum ValidNull { + A = null, +} + `, + ` +enum ValidPlain { + A, +} + `, + ` +enum ValidQuotedKey { + 'a', +} + `, + ` +enum ValidQuotedKeyWithAssignment { + 'a' = 1, +} + `, + noFormat` +enum ValidKeyWithComputedSyntaxButNoComputedKey { + ['a'], +} + `, + ], + invalid: [ + { + code: ` +enum InvalidObject { + A = {}, +} + `, + errors: [ + { + messageId: 'notLiteral', + line: 3, + column: 3, + }, + ], + }, + { + code: ` +enum InvalidArray { + A = [], +} + `, + errors: [ + { + messageId: 'notLiteral', + line: 3, + column: 3, + }, + ], + }, + { + code: ` +enum InvalidTemplateLiteral { + A = \`a\`, +} + `, + errors: [ + { + messageId: 'notLiteral', + line: 3, + column: 3, + }, + ], + }, + { + code: ` +enum InvalidConstructor { + A = new Set(), +} + `, + errors: [ + { + messageId: 'notLiteral', + line: 3, + column: 3, + }, + ], + }, + { + code: ` +enum InvalidExpression { + A = 2 + 2, +} + `, + errors: [ + { + messageId: 'notLiteral', + line: 3, + column: 3, + }, + ], + }, + { + code: ` +const variable = 'Test'; +enum InvalidVariable { + A = 'TestStr', + B = 2, + C, + V = variable, +} + `, + errors: [ + { + messageId: 'notLiteral', + line: 7, + column: 3, + }, + ], + }, + { + code: ` +enum InvalidEnumMember { + A = 'TestStr', + B = A, +} + `, + errors: [ + { + messageId: 'notLiteral', + line: 4, + column: 3, + }, + ], + }, + { + code: ` +const Valid = { A: 2 }; +enum InvalidObjectMember { + A = 'TestStr', + B = Valid.A, +} + `, + errors: [ + { + messageId: 'notLiteral', + line: 5, + column: 3, + }, + ], + }, + { + code: ` +enum Valid { + A, +} +enum InvalidEnumMember { + A = 'TestStr', + B = Valid.A, +} + `, + errors: [ + { + messageId: 'notLiteral', + line: 7, + column: 3, + }, + ], + }, + { + code: ` +const obj = { a: 1 }; +enum InvalidSpread { + A = 'TestStr', + B = { ...a }, +} + `, + errors: [ + { + messageId: 'notLiteral', + line: 5, + column: 3, + }, + ], + }, + ], +});