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): add consistent-indexed-object-style rule #2401

Merged
merged 11 commits into from Sep 28, 2020
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/eslint-plugin/README.md
Expand Up @@ -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:<rule-flag>` 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: | |
Expand Down
@@ -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<string, unknown>;
```

## Options

- `"record"`: Set to `"always"` to only allow the `Record` type. Set to `"never"` to only allow index signatures. (Defaults to `"always"`)

For example:

```CJSON
{
"@typescript-eslint/consistent-type-definitions": ["error", "never"]
}
```

## Rule details

This rule enforces a consistent way to define records.

Examples of **incorrect** code with `always` option.

```ts
interface Foo {
[key: string]: unknown;
}

type Foo = {
[key: string]: unknown;
};
```

Examples of **correct** code with `always` option.

```ts
type Foo = Record<string, unknown>;
```

Examples of **incorrect** code with `never` option.

```ts
type Foo = Record<string, unknown>;
```

Examples of **correct** code with `never` option.

```ts
interface Foo {
[key: string]: unknown;
}

type Foo = {
[key: string]: unknown;
};
```
1 change: 1 addition & 0 deletions packages/eslint-plugin/src/configs/all.ts
Expand Up @@ -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',
Expand Down
118 changes: 118 additions & 0 deletions packages/eslint-plugin/src/rules/consistent-indexed-object-style.ts
@@ -0,0 +1,118 @@
import { createRule } from '../util';
import {
AST_NODE_TYPES,
TSESTree,
} from '@typescript-eslint/experimental-utils';

export default createRule({
remcohaszing marked this conversation as resolved.
Show resolved Hide resolved
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: ['always', 'never'],
bradzacher marked this conversation as resolved.
Show resolved Hide resolved
},
],
},
defaultOptions: ['always'],
create(context) {
const sourceCode = context.getSourceCode();

if (context.options[0] === 'never') {
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} }`);
},
});
},
};
}

/**
* Convert an index signature node to record code as string.
*/
function toRecord(node: TSESTree.TSIndexSignature): string {
const parameter = node.parameters[0] as TSESTree.Identifier;
bradzacher marked this conversation as resolved.
Show resolved Hide resolved
const key = sourceCode.getText(parameter.typeAnnotation!.typeAnnotation);
const value = sourceCode.getText(node.typeAnnotation!.typeAnnotation);
remcohaszing marked this conversation as resolved.
Show resolved Hide resolved
return `Record<${key}, ${value}>`;
}

return {
TSTypeLiteral(node): void {
if (node.members.length !== 1) {
return;
}

const [member] = node.members;

if (member.type !== AST_NODE_TYPES.TSIndexSignature) {
return;
}

context.report({
node,
messageId: 'preferRecord',
fix(fixer) {
return fixer.replaceText(node, toRecord(member));
},
});
},

TSInterfaceDeclaration(node): void {
const { body } = node.body;

if (body.length !== 1) {
return;
}

const [index] = body;
if (index.type !== AST_NODE_TYPES.TSIndexSignature) {
return;
}

context.report({
node,
messageId: 'preferRecord',
fix(fixer) {
const { name } = node.id;
return fixer.replaceText(
node,
`type ${name} = ${toRecord(index)};`,
);
},
});
},
};
},
});
2 changes: 2 additions & 0 deletions packages/eslint-plugin/src/rules/index.ts
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
Expand Down