diff --git a/packages/eslint-plugin/src/rules/prefer-type-alias.ts b/packages/eslint-plugin/src/rules/prefer-type-alias.ts new file mode 100644 index 000000000000..3863d781f1a7 --- /dev/null +++ b/packages/eslint-plugin/src/rules/prefer-type-alias.ts @@ -0,0 +1,92 @@ +import * as util from '../util'; +import { RuleFix } from 'ts-eslint'; + +export default util.createRule({ + name: 'prefer-type-alias', + meta: { + type: 'suggestion', + docs: { + description: 'Prefer a type alias over an interface declaration.', + category: 'Stylistic Issues', + recommended: false, + tslintName: 'prefer-type-alias', + }, + messages: { + preferTypeAlias: 'Expected a `type` instead of an `interface`', + }, + schema: [], + fixable: 'code', + }, + defaultOptions: [], + create(context) { + const sourceCode = context.getSourceCode(); + + return { + TSInterfaceDeclaration: node => { + context.report({ + node: node.id, + messageId: 'preferTypeAlias', + fix(fixer) { + const typeNode = node.typeParameters || node.id; + const fixes: RuleFix[] = []; + + const interfaceKeyword = sourceCode.getFirstToken(node); + const extendsKeyword = sourceCode.getTokenAfter(interfaceKeyword!, { + filter: token => + token.type === 'Keyword' && token.value === 'extends', + }); + const headBracket = sourceCode.getTokenAfter(interfaceKeyword!, { + filter: token => + token.type === 'Punctuator' && token.value === '{', + }); + + fixes.push(fixer.replaceText(interfaceKeyword!, 'type')); + fixes.push( + fixer.replaceTextRange( + [typeNode.range[1], node.body.range[0]], + ' ', + ), + ); + fixes.push(fixer.insertTextBefore(headBracket!, '= ')); + + if (extendsKeyword) { + const interfaceIdentifier = sourceCode.getTokenAfter( + extendsKeyword, + { + filter: token => token.type === 'Identifier', + }, + ); + + const [tailBracket] = sourceCode.getLastTokens(node, { + filter: token => + token.type === 'Punctuator' && token.value === '}', + }); + + // NOTE: insertion `& Keyword` to tail + fixes.push(fixer.insertTextAfter(tailBracket, ' & ')); + fixes.push( + fixer.insertTextAfter(tailBracket, interfaceIdentifier!.value), + ); + + // NOTE: remove `extends` & interface name + fixes.push( + fixer.removeRange([ + extendsKeyword.range[0], + extendsKeyword.range[1] + 1, + ]), + ); + fixes.push( + fixer.removeRange([ + interfaceIdentifier!.range[0], + interfaceIdentifier!.range[1] + 1, // include space after Identifier like `A ` + ]), + ); + } + + return fixes; + }, + }); + }, + }; + }, +}); diff --git a/packages/eslint-plugin/tests/rules/prefer-type-alias.test.ts b/packages/eslint-plugin/tests/rules/prefer-type-alias.test.ts new file mode 100644 index 000000000000..a1e661428d29 --- /dev/null +++ b/packages/eslint-plugin/tests/rules/prefer-type-alias.test.ts @@ -0,0 +1,72 @@ +import rule from '../../src/rules/prefer-type-alias'; +import { RuleTester } from '../RuleTester'; + +const ruleTester = new RuleTester({ + parser: '@typescript-eslint/parser', +}); + +ruleTester.run('prefer-type-alias', rule, { + valid: [ + `type U = string;`, + `type V = { x: number; } | { y: string; };`, + ` +type Record = { + [K in T]: U; +} +`, + ], + invalid: [ + { + code: `interface T { x: number; }`, + output: `type T = { x: number; }`, + errors: [ + { + messageId: 'preferTypeAlias', + line: 1, + column: 11, + }, + ], + }, + { + code: `interface T{ x: number; }`, + output: `type T = { x: number; }`, + errors: [ + { + messageId: 'preferTypeAlias', + line: 1, + column: 11, + }, + ], + }, + { + code: `interface T { x: number; }`, + output: `type T = { x: number; }`, + errors: [ + { + messageId: 'preferTypeAlias', + line: 1, + column: 11, + }, + ], + }, + { + code: ` +export interface W { + x: T, +}; +`, + output: ` +export type W = { + x: T, +}; +`, + errors: [ + { + messageId: 'preferTypeAlias', + line: 2, + column: 18, + }, + ], + }, + ], +});