diff --git a/packages/eslint-plugin/README.md b/packages/eslint-plugin/README.md index 5030ca291751..daacab20afdb 100644 --- a/packages/eslint-plugin/README.md +++ b/packages/eslint-plugin/README.md @@ -144,6 +144,7 @@ Then you should add `airbnb` (or `airbnb-base`) to your `extends` section of `.e | [`@typescript-eslint/no-use-before-define`](./docs/rules/no-use-before-define.md) | Disallow the use of variables before they are defined | :heavy_check_mark: | | | [`@typescript-eslint/no-useless-constructor`](./docs/rules/no-useless-constructor.md) | Disallow unnecessary constructors | | | | [`@typescript-eslint/no-var-requires`](./docs/rules/no-var-requires.md) | Disallows the use of require statements except in import statements (`no-var-requires` from TSLint) | :heavy_check_mark: | | +| [`@typescript-eslint/prefer-function-type`](./docs/rules/prefer-function-type.md) | Use function types instead of interfaces with call signatures (`callable-types` from TSLint) | | :wrench: | | [`@typescript-eslint/prefer-interface`](./docs/rules/prefer-interface.md) | Prefer an interface declaration over a type literal (type T = { ... }) (`interface-over-type-literal` from TSLint) | :heavy_check_mark: | :wrench: | | [`@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. (`no-internal-module` from TSLint) | :heavy_check_mark: | :wrench: | | [`@typescript-eslint/promise-function-async`](./docs/rules/promise-function-async.md) | Requires any function or method that returns a Promise to be marked async. (`promise-function-async` from TSLint) | :heavy_check_mark: | | diff --git a/packages/eslint-plugin/ROADMAP.md b/packages/eslint-plugin/ROADMAP.md index 86f0224f0af5..9d4c2ef5a9f1 100644 --- a/packages/eslint-plugin/ROADMAP.md +++ b/packages/eslint-plugin/ROADMAP.md @@ -132,7 +132,7 @@ | [`arrow-parens`] | 🌟 | [`arrow-parens`][arrow-parens] | | [`arrow-return-shorthand`] | 🌟 | [`arrow-body-style`][arrow-body-style] | | [`binary-expression-operand-order`] | 🌟 | [`yoda`][yoda] | -| [`callable-types`] | 🛑 | N/A | +| [`callable-types`] | ✅ | [`@typescript-eslint/prefer-function-type`] | | [`class-name`] | ✅ | [`@typescript-eslint/class-name-casing`] | | [`comment-format`] | 🌟 | [`capitalized-comments`][capitalized-comments] & [`spaced-comment`][spaced-comment] | | [`completed-docs`] | 🔌 | [`eslint-plugin-jsdoc`][plugin:jsdoc] | @@ -587,6 +587,7 @@ Relevant plugins: [`chai-expect-keywords`](https://github.com/gavinaiken/eslint- [`@typescript-eslint/member-delimiter-style`]: https://github.com/typescript-eslint/typescript-eslint/blob/master/packages/eslint-plugin/docs/rules/member-delimiter-style.md [`@typescript-eslint/prefer-interface`]: https://github.com/typescript-eslint/typescript-eslint/blob/master/packages/eslint-plugin/docs/rules/prefer-interface.md [`@typescript-eslint/no-array-constructor`]: https://github.com/typescript-eslint/typescript-eslint/blob/master/packages/eslint-plugin/docs/rules/no-array-constructor.md +[`@typescript-eslint/prefer-function-type`]: https://github.com/typescript-eslint/typescript-eslint/blob/master/packages/eslint-plugin/docs/rules/prefer-function-type.md [`@typescript-eslint/no-for-in-array`]: https://github.com/typescript-eslint/typescript-eslint/blob/master/packages/eslint-plugin/docs/rules/no-for-in-array.md diff --git a/packages/eslint-plugin/docs/rules/prefer-function-type.md b/packages/eslint-plugin/docs/rules/prefer-function-type.md new file mode 100644 index 000000000000..7cbf2be0480d --- /dev/null +++ b/packages/eslint-plugin/docs/rules/prefer-function-type.md @@ -0,0 +1,57 @@ +# Use function types instead of interfaces with call signatures (prefer-function-type) + +## Rule Details + +This rule suggests using a function type instead of an interface or object type literal with a single call signature. + +Examples of **incorrect** code for this rule: + +```ts +interface Foo { + (): string; +} +``` + +```ts +function foo(bar: { (): number }): number { + return bar(); +} +``` + +```ts +interface Foo extends Function { + (): void; +} +``` + +Examples of **correct** code for this rule: + +```ts +interface Foo { + (): void; + bar: number; +} +``` + +```ts +function foo(bar: { (): string; baz: number }): string { + return bar(); +} +``` + +```ts +interface Foo { + bar: string; +} +interface Bar extends Foo { + (): void; +} +``` + +## When Not To Use It + +If you specifically want to use an interface or type literal with a single call signature for stylistic reasons, you can disable this rule. + +## Further Reading + +- TSLint: [`callable-types`](https://palantir.github.io/tslint/rules/callable-types/) diff --git a/packages/eslint-plugin/lib/rules/prefer-function-type.js b/packages/eslint-plugin/lib/rules/prefer-function-type.js new file mode 100644 index 000000000000..f591179932fb --- /dev/null +++ b/packages/eslint-plugin/lib/rules/prefer-function-type.js @@ -0,0 +1,171 @@ +/** + * @fileoverview Use function types instead of interfaces with call signatures + * @author Benjamin Lichtman + */ +'use strict'; +const util = require('../util'); + +/** + * @typedef {import("eslint").Rule.RuleModule} RuleModule + * @typedef {import("estree").Node} ESTreeNode + */ + +//------------------------------------------------------------------------------ +// Rule Definition +//------------------------------------------------------------------------------ + +/** + * @type {RuleModule} + */ +module.exports = { + meta: { + docs: { + description: + 'Use function types instead of interfaces with call signatures', + category: 'TypeScript', + recommended: false, + extraDescription: [util.tslintRule('prefer-function-type')], + url: util.metaDocsUrl('prefer-function-type') + }, + fixable: 'code', + messages: { + functionTypeOverCallableType: + "{{ type }} has only a call signature - use '{{ sigSuggestion }}' instead." + }, + schema: [], + type: 'suggestion' + }, + + create(context) { + const sourceCode = context.getSourceCode(); + + //---------------------------------------------------------------------- + // Helpers + //---------------------------------------------------------------------- + + /** + * Checks if there is no supertype or if the supertype is 'Function' + * @param {ESTreeNode} node The node being checked + * @returns {boolean} Returns true iff there is no supertype or if the supertype is 'Function' + */ + function noSupertype(node) { + if (!node.extends || node.extends.length === 0) { + return true; + } + if (node.extends.length !== 1) { + return false; + } + const expr = node.extends[0].expression; + + return expr.type === 'Identifier' && expr.name === 'Function'; + } + + /** + * @param {ESTreeNode} parent The parent of the call signature causing the diagnostic + * @returns {boolean} true iff the parent node needs to be wrapped for readability + */ + function shouldWrapSuggestion(parent) { + switch (parent.type) { + case 'TSUnionType': + case 'TSIntersectionType': + case 'TSArrayType': + return true; + default: + return false; + } + } + + /** + * @param {ESTreeNode} call The call signature causing the diagnostic + * @param {ESTreeNode} parent The parent of the call + * @returns {string} The suggestion to report + */ + function renderSuggestion(call, parent) { + const start = call.range[0]; + const colonPos = call.returnType.range[0] - start; + const text = sourceCode.getText().slice(start, call.range[1]); + + let suggestion = `${text.slice(0, colonPos)} =>${text.slice( + colonPos + 1 + )}`; + + if (shouldWrapSuggestion(parent.parent)) { + suggestion = `(${suggestion})`; + } + if (parent.type === 'TSInterfaceDeclaration') { + if (typeof parent.typeParameters !== 'undefined') { + return `type ${sourceCode + .getText() + .slice( + parent.id.range[0], + parent.typeParameters.range[1] + )} = ${suggestion}`; + } + return `type ${parent.id.name} = ${suggestion}`; + } + return suggestion.endsWith(';') ? suggestion.slice(0, -1) : suggestion; + } + + /** + * @param {ESTreeNode} member The TypeElement being checked + * @param {ESTreeNode} node The parent of member being checked + * @returns {void} + */ + function checkMember(member, node) { + if ( + (member.type === 'TSCallSignatureDeclaration' || + member.type === 'TSConstructSignatureDeclaration') && + typeof member.returnType !== 'undefined' + ) { + const suggestion = renderSuggestion(member, node); + const fixStart = + node.type === 'TSTypeLiteral' + ? node.range[0] + : sourceCode + .getTokens(node) + .filter( + token => + token.type === 'Keyword' && token.value === 'interface' + )[0].range[0]; + + context.report({ + node: member, + messageId: 'functionTypeOverCallableType', + data: { + type: node.type === 'TSTypeLiteral' ? 'Type literal' : 'Interface', + sigSuggestion: suggestion + }, + fix(fixer) { + return fixer.replaceTextRange( + [fixStart, node.range[1]], + suggestion + ); + } + }); + } + } + + //---------------------------------------------------------------------- + // Public + //---------------------------------------------------------------------- + + return { + /** + * @param {TSInterfaceDeclaration} node The node being checked + * @returns {void} + */ + TSInterfaceDeclaration(node) { + if (noSupertype(node) && node.body.body.length === 1) { + checkMember(node.body.body[0], node); + } + }, + /** + * @param {TSTypeLiteral} node The node being checked + * @returns {void} + */ + 'TSTypeLiteral[members.length = 1]'(node) { + checkMember(node.members[0], node); + } + }; + } +}; diff --git a/packages/eslint-plugin/tests/lib/rules/prefer-function-type.js b/packages/eslint-plugin/tests/lib/rules/prefer-function-type.js new file mode 100644 index 000000000000..d0587afcaf7c --- /dev/null +++ b/packages/eslint-plugin/tests/lib/rules/prefer-function-type.js @@ -0,0 +1,147 @@ +/** + * @fileoverview Use function types instead of interfaces with call signatures + * @author Benjamin Lichtman + */ +'use strict'; + +//------------------------------------------------------------------------------ +// Requirements +//------------------------------------------------------------------------------ + +var rule = require('../../../lib/rules/prefer-function-type'), + RuleTester = require('eslint').RuleTester; + +//------------------------------------------------------------------------------ +// Tests +//------------------------------------------------------------------------------ + +const parserOptions = { + ecmaVersion: 2015 +}; +var ruleTester = new RuleTester({ + parserOptions, + parser: '@typescript-eslint/parser' +}); +ruleTester.run('prefer-function-type', rule, { + valid: [ + ` +interface Foo { + (): void; + bar: number; +}`, + ` +type Foo = { + (): void; + bar: number; +}`, + ` +function foo(bar: { (): string, baz: number }): string { + return bar(); +}`, + ` +interface Foo { + bar: string; +} +interface Bar extends Foo { + (): void; +}`, + ` +interface Foo { + bar: string; +} +interface Bar extends Function, Foo { + (): void; +}` + ], + + invalid: [ + { + code: ` +interface Foo { + (): string; +}`, + errors: [ + { + messageId: 'functionTypeOverCallableType', + type: 'TSCallSignatureDeclaration' + } + ], + output: ` +type Foo = () => string;` + }, + { + code: ` +type Foo = { + (): string; +}`, + errors: [ + { + messageId: 'functionTypeOverCallableType', + type: 'TSCallSignatureDeclaration' + } + ], + output: ` +type Foo = () => string` + }, + { + code: ` +function foo(bar: { (s: string): number }): number { + return bar("hello"); +}`, + errors: [ + { + messageId: 'functionTypeOverCallableType', + type: 'TSCallSignatureDeclaration' + } + ], + output: ` +function foo(bar: (s: string) => number): number { + return bar("hello"); +}` + }, + { + code: ` +function foo(bar: { (s: string): number } | undefined): number { + return bar("hello"); +}`, + errors: [ + { + messageId: 'functionTypeOverCallableType', + type: 'TSCallSignatureDeclaration' + } + ], + output: ` +function foo(bar: ((s: string) => number) | undefined): number { + return bar("hello"); +}` + }, + { + code: ` +interface Foo extends Function { + (): void; +}`, + errors: [ + { + messageId: 'functionTypeOverCallableType', + type: 'TSCallSignatureDeclaration' + } + ], + output: ` +type Foo = () => void;` + }, + { + code: ` +interface Foo { + (bar: T): string; +}`, + errors: [ + { + messageId: 'functionTypeOverCallableType', + type: 'TSCallSignatureDeclaration' + } + ], + output: ` +type Foo = (bar: T) => string;` + } + ] +});