Skip to content

Commit b95c4cf

Browse files
feat(eslint-plugin): add prefer-function-type rule (#222)
feat(eslint-plugin): add prefer-function-type rule
1 parent 317405a commit b95c4cf

File tree

5 files changed

+378
-1
lines changed

5 files changed

+378
-1
lines changed

Diff for: packages/eslint-plugin/README.md

+1
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,7 @@ Then you should add `airbnb` (or `airbnb-base`) to your `extends` section of `.e
144144
| [`@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: | |
145145
| [`@typescript-eslint/no-useless-constructor`](./docs/rules/no-useless-constructor.md) | Disallow unnecessary constructors | | |
146146
| [`@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: | |
147+
| [`@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: |
147148
| [`@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: |
148149
| [`@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: |
149150
| [`@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 for: packages/eslint-plugin/ROADMAP.md

+2-1
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,7 @@
132132
| [`arrow-parens`] | 🌟 | [`arrow-parens`][arrow-parens] |
133133
| [`arrow-return-shorthand`] | 🌟 | [`arrow-body-style`][arrow-body-style] |
134134
| [`binary-expression-operand-order`] | 🌟 | [`yoda`][yoda] |
135-
| [`callable-types`] | 🛑 | N/A |
135+
| [`callable-types`] | | [`@typescript-eslint/prefer-function-type`] |
136136
| [`class-name`] || [`@typescript-eslint/class-name-casing`] |
137137
| [`comment-format`] | 🌟 | [`capitalized-comments`][capitalized-comments] & [`spaced-comment`][spaced-comment] |
138138
| [`completed-docs`] | 🔌 | [`eslint-plugin-jsdoc`][plugin:jsdoc] |
@@ -587,6 +587,7 @@ Relevant plugins: [`chai-expect-keywords`](https://github.com/gavinaiken/eslint-
587587
[`@typescript-eslint/member-delimiter-style`]: https://github.com/typescript-eslint/typescript-eslint/blob/master/packages/eslint-plugin/docs/rules/member-delimiter-style.md
588588
[`@typescript-eslint/prefer-interface`]: https://github.com/typescript-eslint/typescript-eslint/blob/master/packages/eslint-plugin/docs/rules/prefer-interface.md
589589
[`@typescript-eslint/no-array-constructor`]: https://github.com/typescript-eslint/typescript-eslint/blob/master/packages/eslint-plugin/docs/rules/no-array-constructor.md
590+
[`@typescript-eslint/prefer-function-type`]: https://github.com/typescript-eslint/typescript-eslint/blob/master/packages/eslint-plugin/docs/rules/prefer-function-type.md
590591
[`@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
591592

592593
<!-- eslint-plugin-import -->
+57
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
# Use function types instead of interfaces with call signatures (prefer-function-type)
2+
3+
## Rule Details
4+
5+
This rule suggests using a function type instead of an interface or object type literal with a single call signature.
6+
7+
Examples of **incorrect** code for this rule:
8+
9+
```ts
10+
interface Foo {
11+
(): string;
12+
}
13+
```
14+
15+
```ts
16+
function foo(bar: { (): number }): number {
17+
return bar();
18+
}
19+
```
20+
21+
```ts
22+
interface Foo extends Function {
23+
(): void;
24+
}
25+
```
26+
27+
Examples of **correct** code for this rule:
28+
29+
```ts
30+
interface Foo {
31+
(): void;
32+
bar: number;
33+
}
34+
```
35+
36+
```ts
37+
function foo(bar: { (): string; baz: number }): string {
38+
return bar();
39+
}
40+
```
41+
42+
```ts
43+
interface Foo {
44+
bar: string;
45+
}
46+
interface Bar extends Foo {
47+
(): void;
48+
}
49+
```
50+
51+
## When Not To Use It
52+
53+
If you specifically want to use an interface or type literal with a single call signature for stylistic reasons, you can disable this rule.
54+
55+
## Further Reading
56+
57+
- TSLint: [`callable-types`](https://palantir.github.io/tslint/rules/callable-types/)
+171
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
/**
2+
* @fileoverview Use function types instead of interfaces with call signatures
3+
* @author Benjamin Lichtman
4+
*/
5+
'use strict';
6+
const util = require('../util');
7+
8+
/**
9+
* @typedef {import("eslint").Rule.RuleModule} RuleModule
10+
* @typedef {import("estree").Node} ESTreeNode
11+
*/
12+
13+
//------------------------------------------------------------------------------
14+
// Rule Definition
15+
//------------------------------------------------------------------------------
16+
17+
/**
18+
* @type {RuleModule}
19+
*/
20+
module.exports = {
21+
meta: {
22+
docs: {
23+
description:
24+
'Use function types instead of interfaces with call signatures',
25+
category: 'TypeScript',
26+
recommended: false,
27+
extraDescription: [util.tslintRule('prefer-function-type')],
28+
url: util.metaDocsUrl('prefer-function-type')
29+
},
30+
fixable: 'code',
31+
messages: {
32+
functionTypeOverCallableType:
33+
"{{ type }} has only a call signature - use '{{ sigSuggestion }}' instead."
34+
},
35+
schema: [],
36+
type: 'suggestion'
37+
},
38+
39+
create(context) {
40+
const sourceCode = context.getSourceCode();
41+
42+
//----------------------------------------------------------------------
43+
// Helpers
44+
//----------------------------------------------------------------------
45+
46+
/**
47+
* Checks if there is no supertype or if the supertype is 'Function'
48+
* @param {ESTreeNode} node The node being checked
49+
* @returns {boolean} Returns true iff there is no supertype or if the supertype is 'Function'
50+
*/
51+
function noSupertype(node) {
52+
if (!node.extends || node.extends.length === 0) {
53+
return true;
54+
}
55+
if (node.extends.length !== 1) {
56+
return false;
57+
}
58+
const expr = node.extends[0].expression;
59+
60+
return expr.type === 'Identifier' && expr.name === 'Function';
61+
}
62+
63+
/**
64+
* @param {ESTreeNode} parent The parent of the call signature causing the diagnostic
65+
* @returns {boolean} true iff the parent node needs to be wrapped for readability
66+
*/
67+
function shouldWrapSuggestion(parent) {
68+
switch (parent.type) {
69+
case 'TSUnionType':
70+
case 'TSIntersectionType':
71+
case 'TSArrayType':
72+
return true;
73+
default:
74+
return false;
75+
}
76+
}
77+
78+
/**
79+
* @param {ESTreeNode} call The call signature causing the diagnostic
80+
* @param {ESTreeNode} parent The parent of the call
81+
* @returns {string} The suggestion to report
82+
*/
83+
function renderSuggestion(call, parent) {
84+
const start = call.range[0];
85+
const colonPos = call.returnType.range[0] - start;
86+
const text = sourceCode.getText().slice(start, call.range[1]);
87+
88+
let suggestion = `${text.slice(0, colonPos)} =>${text.slice(
89+
colonPos + 1
90+
)}`;
91+
92+
if (shouldWrapSuggestion(parent.parent)) {
93+
suggestion = `(${suggestion})`;
94+
}
95+
if (parent.type === 'TSInterfaceDeclaration') {
96+
if (typeof parent.typeParameters !== 'undefined') {
97+
return `type ${sourceCode
98+
.getText()
99+
.slice(
100+
parent.id.range[0],
101+
parent.typeParameters.range[1]
102+
)} = ${suggestion}`;
103+
}
104+
return `type ${parent.id.name} = ${suggestion}`;
105+
}
106+
return suggestion.endsWith(';') ? suggestion.slice(0, -1) : suggestion;
107+
}
108+
109+
/**
110+
* @param {ESTreeNode} member The TypeElement being checked
111+
* @param {ESTreeNode} node The parent of member being checked
112+
* @returns {void}
113+
*/
114+
function checkMember(member, node) {
115+
if (
116+
(member.type === 'TSCallSignatureDeclaration' ||
117+
member.type === 'TSConstructSignatureDeclaration') &&
118+
typeof member.returnType !== 'undefined'
119+
) {
120+
const suggestion = renderSuggestion(member, node);
121+
const fixStart =
122+
node.type === 'TSTypeLiteral'
123+
? node.range[0]
124+
: sourceCode
125+
.getTokens(node)
126+
.filter(
127+
token =>
128+
token.type === 'Keyword' && token.value === 'interface'
129+
)[0].range[0];
130+
131+
context.report({
132+
node: member,
133+
messageId: 'functionTypeOverCallableType',
134+
data: {
135+
type: node.type === 'TSTypeLiteral' ? 'Type literal' : 'Interface',
136+
sigSuggestion: suggestion
137+
},
138+
fix(fixer) {
139+
return fixer.replaceTextRange(
140+
[fixStart, node.range[1]],
141+
suggestion
142+
);
143+
}
144+
});
145+
}
146+
}
147+
148+
//----------------------------------------------------------------------
149+
// Public
150+
//----------------------------------------------------------------------
151+
152+
return {
153+
/**
154+
* @param {TSInterfaceDeclaration} node The node being checked
155+
* @returns {void}
156+
*/
157+
TSInterfaceDeclaration(node) {
158+
if (noSupertype(node) && node.body.body.length === 1) {
159+
checkMember(node.body.body[0], node);
160+
}
161+
},
162+
/**
163+
* @param {TSTypeLiteral} node The node being checked
164+
* @returns {void}
165+
*/
166+
'TSTypeLiteral[members.length = 1]'(node) {
167+
checkMember(node.members[0], node);
168+
}
169+
};
170+
}
171+
};

0 commit comments

Comments
 (0)