Skip to content

Commit

Permalink
feat(eslint-plugin): add prefer-function-type rule (#222)
Browse files Browse the repository at this point in the history
feat(eslint-plugin): add prefer-function-type rule
  • Loading branch information
uniqueiniquity authored Feb 8, 2019
1 parent 317405a commit b95c4cf
Show file tree
Hide file tree
Showing 5 changed files with 378 additions and 1 deletion.
1 change: 1 addition & 0 deletions packages/eslint-plugin/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: | |
Expand Down
3 changes: 2 additions & 1 deletion packages/eslint-plugin/ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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] |
Expand Down Expand Up @@ -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

<!-- eslint-plugin-import -->
Expand Down
57 changes: 57 additions & 0 deletions packages/eslint-plugin/docs/rules/prefer-function-type.md
Original file line number Diff line number Diff line change
@@ -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/)
171 changes: 171 additions & 0 deletions packages/eslint-plugin/lib/rules/prefer-function-type.js
Original file line number Diff line number Diff line change
@@ -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);
}
};
}
};
Loading

0 comments on commit b95c4cf

Please sign in to comment.