From d7dc108580cdcb9890ac0539e7223aedbff4a0ed Mon Sep 17 00:00:00 2001 From: Remco Haszing Date: Mon, 28 Sep 2020 20:22:29 +0200 Subject: [PATCH] feat(eslint-plugin): add `consistent-indexed-object-style` rule (#2401) --- packages/eslint-plugin/README.md | 1 + .../rules/consistent-indexed-object-style.md | 67 ++++++ packages/eslint-plugin/src/configs/all.ts | 1 + .../rules/consistent-indexed-object-style.ts | 122 +++++++++++ packages/eslint-plugin/src/rules/index.ts | 2 + .../consistent-indexed-object-style.test.ts | 200 ++++++++++++++++++ 6 files changed, 393 insertions(+) create mode 100644 packages/eslint-plugin/docs/rules/consistent-indexed-object-style.md create mode 100644 packages/eslint-plugin/src/rules/consistent-indexed-object-style.ts create mode 100644 packages/eslint-plugin/tests/rules/consistent-indexed-object-style.test.ts diff --git a/packages/eslint-plugin/README.md b/packages/eslint-plugin/README.md index 37a27ca9511..e043549326a 100644 --- a/packages/eslint-plugin/README.md +++ b/packages/eslint-plugin/README.md @@ -104,6 +104,7 @@ Pro Tip: For larger codebases you may want to consider splitting our linting int | [`@typescript-eslint/ban-tslint-comment`](./docs/rules/ban-tslint-comment.md) | Bans `// tslint:` comments from being used | | :wrench: | | | [`@typescript-eslint/ban-types`](./docs/rules/ban-types.md) | Bans specific types from being used | :heavy_check_mark: | :wrench: | | | [`@typescript-eslint/class-literal-property-style`](./docs/rules/class-literal-property-style.md) | Ensures that literals on classes are exposed in a consistent style | | :wrench: | | +| [`@typescript-eslint/consistent-indexed-object-style`](./docs/rules/consistent-indexed-object-style.md) | Enforce or disallow the use of the record type | | :wrench: | | | [`@typescript-eslint/consistent-type-assertions`](./docs/rules/consistent-type-assertions.md) | Enforces consistent usage of type assertions | | | | | [`@typescript-eslint/consistent-type-definitions`](./docs/rules/consistent-type-definitions.md) | Consistent with type definition either `interface` or `type` | | :wrench: | | | [`@typescript-eslint/consistent-type-imports`](./docs/rules/consistent-type-imports.md) | Enforces consistent usage of type imports | | :wrench: | | diff --git a/packages/eslint-plugin/docs/rules/consistent-indexed-object-style.md b/packages/eslint-plugin/docs/rules/consistent-indexed-object-style.md new file mode 100644 index 00000000000..9ed81aed7c2 --- /dev/null +++ b/packages/eslint-plugin/docs/rules/consistent-indexed-object-style.md @@ -0,0 +1,67 @@ +# Enforce or disallow the use of the record type (`consistent-indexed-object-style`) + +TypeScript supports defining object show keys can be flexible using an index signature. TypeScript also has a builtin type named `Record` to create an empty object defining only an index signature. For example, the following types are equal: + +```ts +interface Foo { + [key: string]: unknown; +} + +type Foo = { + [key: string]: unknown; +}; + +type Foo = Record; +``` + +## Options + +- `"record"`: Set to `"record"` to only allow the `Record` type. Set to `"index-signature"` to only allow index signatures. (Defaults to `"record"`) + +For example: + +```CJSON +{ + "@typescript-eslint/consistent-type-definitions": ["error", "index-signature"] +} +``` + +## Rule details + +This rule enforces a consistent way to define records. + +Examples of **incorrect** code with `record` option. + +```ts +interface Foo { + [key: string]: unknown; +} + +type Foo = { + [key: string]: unknown; +}; +``` + +Examples of **correct** code with `record` option. + +```ts +type Foo = Record; +``` + +Examples of **incorrect** code with `index-signature` option. + +```ts +type Foo = Record; +``` + +Examples of **correct** code with `index-signature` option. + +```ts +interface Foo { + [key: string]: unknown; +} + +type Foo = { + [key: string]: unknown; +}; +``` diff --git a/packages/eslint-plugin/src/configs/all.ts b/packages/eslint-plugin/src/configs/all.ts index 84b4e93fc90..1de48a81f6e 100644 --- a/packages/eslint-plugin/src/configs/all.ts +++ b/packages/eslint-plugin/src/configs/all.ts @@ -16,6 +16,7 @@ export = { '@typescript-eslint/class-literal-property-style': 'error', 'comma-spacing': 'off', '@typescript-eslint/comma-spacing': 'error', + '@typescript-eslint/consistent-indexed-object-style': 'error', '@typescript-eslint/consistent-type-assertions': 'error', '@typescript-eslint/consistent-type-definitions': 'error', '@typescript-eslint/consistent-type-imports': 'error', diff --git a/packages/eslint-plugin/src/rules/consistent-indexed-object-style.ts b/packages/eslint-plugin/src/rules/consistent-indexed-object-style.ts new file mode 100644 index 00000000000..021f5038770 --- /dev/null +++ b/packages/eslint-plugin/src/rules/consistent-indexed-object-style.ts @@ -0,0 +1,122 @@ +import { createRule } from '../util'; +import { + AST_NODE_TYPES, + TSESTree, +} from '@typescript-eslint/experimental-utils'; + +type MessageIds = 'preferRecord' | 'preferIndexSignature'; +type Options = ['record' | 'index-signature']; + +export default createRule({ + name: 'consistent-indexed-object-style', + meta: { + type: 'suggestion', + docs: { + description: 'Enforce or disallow the use of the record type', + category: 'Stylistic Issues', + // too opinionated to be recommended + recommended: false, + }, + messages: { + preferRecord: 'A record is preferred over an index signature', + preferIndexSignature: 'An index signature is preferred over a record.', + }, + fixable: 'code', + schema: [ + { + enum: ['record', 'index-signature'], + }, + ], + }, + defaultOptions: ['record'], + create(context) { + const sourceCode = context.getSourceCode(); + + if (context.options[0] === 'index-signature') { + return { + TSTypeReference(node): void { + const typeName = node.typeName; + if (typeName.type !== AST_NODE_TYPES.Identifier) { + return; + } + if (typeName.name !== 'Record') { + return; + } + + const params = node.typeParameters?.params; + if (params?.length !== 2) { + return; + } + + context.report({ + node, + messageId: 'preferIndexSignature', + fix(fixer) { + const key = sourceCode.getText(params[0]); + const type = sourceCode.getText(params[1]); + return fixer.replaceText(node, `{ [key: ${key}]: ${type} }`); + }, + }); + }, + }; + } + + function checkMembers( + members: TSESTree.TypeElement[], + node: TSESTree.Node, + prefix: string, + postfix: string, + ): void { + if (members.length !== 1) { + return; + } + const [member] = members; + + if (member.type !== AST_NODE_TYPES.TSIndexSignature) { + return; + } + + const [parameter] = member.parameters; + + if (!parameter) { + return; + } + + if (parameter.type !== AST_NODE_TYPES.Identifier) { + return; + } + const keyType = parameter.typeAnnotation; + if (!keyType) { + return; + } + + const valueType = member.typeAnnotation; + if (!valueType) { + return; + } + + context.report({ + node, + messageId: 'preferRecord', + fix(fixer) { + const key = sourceCode.getText(keyType.typeAnnotation); + const value = sourceCode.getText(valueType.typeAnnotation); + return fixer.replaceText( + node, + `${prefix}Record<${key}, ${value}>${postfix}`, + ); + }, + }); + } + + return { + TSTypeLiteral(node): void { + checkMembers(node.members, node, '', ''); + }, + + TSInterfaceDeclaration(node): void { + checkMembers(node.body.body, node, `type ${node.id.name} = `, ';'); + }, + }; + }, +}); diff --git a/packages/eslint-plugin/src/rules/index.ts b/packages/eslint-plugin/src/rules/index.ts index 8b5049e2502..10d849873a0 100644 --- a/packages/eslint-plugin/src/rules/index.ts +++ b/packages/eslint-plugin/src/rules/index.ts @@ -9,6 +9,7 @@ import classLiteralPropertyStyle from './class-literal-property-style'; import commaDangle from './comma-dangle'; import commaSpacing from './comma-spacing'; import confusingNonNullAssertionLikeNotEqual from './no-confusing-non-null-assertion'; +import consistentIndexedObjectStyle from './consistent-indexed-object-style'; import consistentTypeAssertions from './consistent-type-assertions'; import consistentTypeDefinitions from './consistent-type-definitions'; import consistentTypeImports from './consistent-type-imports'; @@ -117,6 +118,7 @@ export default { 'class-literal-property-style': classLiteralPropertyStyle, 'comma-dangle': commaDangle, 'comma-spacing': commaSpacing, + 'consistent-indexed-object-style': consistentIndexedObjectStyle, 'consistent-type-assertions': consistentTypeAssertions, 'consistent-type-definitions': consistentTypeDefinitions, 'consistent-type-imports': consistentTypeImports, diff --git a/packages/eslint-plugin/tests/rules/consistent-indexed-object-style.test.ts b/packages/eslint-plugin/tests/rules/consistent-indexed-object-style.test.ts new file mode 100644 index 00000000000..328f0cbf5dd --- /dev/null +++ b/packages/eslint-plugin/tests/rules/consistent-indexed-object-style.test.ts @@ -0,0 +1,200 @@ +import rule from '../../src/rules/consistent-indexed-object-style'; +import { RuleTester } from '../RuleTester'; + +const ruleTester = new RuleTester({ + parser: '@typescript-eslint/parser', +}); + +ruleTester.run('consistent-indexed-object-style', rule, { + valid: [ + // 'record' (default) + // Record + 'type Foo = Record;', + + // Interface + 'interface Foo {}', + ` +interface Foo { + bar: string; +} + `, + ` +interface Foo { + bar: string; + [key: string]: any; +} + `, + ` +interface Foo { + [key: string]: any; + bar: string; +} + `, + + // Type literal + 'type Foo = {};', + ` +type Foo = { + bar: string; + [key: string]: any; +}; + `, + ` +type Foo = { + bar: string; +}; + `, + ` +type Foo = { + [key: string]: any; + bar: string; +}; + `, + + // Generic + ` +type Foo = Generic<{ + [key: string]: any; + bar: string; +}>; + `, + + // Function types + 'function foo(arg: { [key: string]: any; bar: string }) {}', + 'function foo(): { [key: string]: any; bar: string } {}', + + // Invalid syntax allowed by the parser + 'type Foo = { [key: string] };', + 'type Foo = { [] };', + ` +interface Foo { + [key: string]; +} + `, + ` +interface Foo { + []; +} + `, + + // 'index-signature' + // Unhandled type + { + code: 'type Foo = Misc;', + options: ['index-signature'], + }, + + // Invalid record + { + code: 'type Foo = Record;', + options: ['index-signature'], + }, + { + code: 'type Foo = Record;', + options: ['index-signature'], + }, + { + code: 'type Foo = Record;', + options: ['index-signature'], + }, + + // Type literal + { + code: 'type Foo = { [key: string]: any };', + options: ['index-signature'], + }, + + // Generic + { + code: 'type Foo = Generic<{ [key: string]: any }>;', + options: ['index-signature'], + }, + + // Function types + { + code: 'function foo(arg: { [key: string]: any }) {}', + options: ['index-signature'], + }, + { + code: 'function foo(): { [key: string]: any } {}', + options: ['index-signature'], + }, + + // Namespace + { + code: 'type T = A.B;', + options: ['index-signature'], + }, + ], + invalid: [ + // Interface + { + code: ` +interface Foo { + [key: string]: any; +} + `, + output: ` +type Foo = Record; + `, + errors: [{ messageId: 'preferRecord', line: 2, column: 1 }], + }, + + // Type literal + { + code: 'type Foo = { [key: string]: any };', + output: 'type Foo = Record;', + errors: [{ messageId: 'preferRecord', line: 1, column: 12 }], + }, + + // Generic + { + code: 'type Foo = Generic<{ [key: string]: any }>;', + output: 'type Foo = Generic>;', + errors: [{ messageId: 'preferRecord', line: 1, column: 20 }], + }, + + // Function types + { + code: 'function foo(arg: { [key: string]: any }) {}', + output: 'function foo(arg: Record) {}', + errors: [{ messageId: 'preferRecord', line: 1, column: 19 }], + }, + { + code: 'function foo(): { [key: string]: any } {}', + output: 'function foo(): Record {}', + errors: [{ messageId: 'preferRecord', line: 1, column: 17 }], + }, + + // Never + // Type literal + { + code: 'type Foo = Record;', + options: ['index-signature'], + output: 'type Foo = { [key: string]: any };', + errors: [{ messageId: 'preferIndexSignature', line: 1, column: 12 }], + }, + + // Generic + { + code: 'type Foo = Generic>;', + options: ['index-signature'], + output: 'type Foo = Generic<{ [key: string]: any }>;', + errors: [{ messageId: 'preferIndexSignature', line: 1, column: 20 }], + }, + + // Function types + { + code: 'function foo(arg: Record) {}', + options: ['index-signature'], + output: 'function foo(arg: { [key: string]: any }) {}', + errors: [{ messageId: 'preferIndexSignature', line: 1, column: 19 }], + }, + { + code: 'function foo(): Record {}', + options: ['index-signature'], + output: 'function foo(): { [key: string]: any } {}', + errors: [{ messageId: 'preferIndexSignature', line: 1, column: 17 }], + }, + ], +});