Skip to content

Commit

Permalink
feat(eslint-plugin): [consistent-return] add new rule (#8289)
Browse files Browse the repository at this point in the history
* feat(eslint-plugin): [consistent-return] add new rule

* apply reviews

* fix docs

* fix order

* docs: remove eslint comment

* handle treatUndefinedAsUnspecified option

* fix rule in rulemap order

* add test case

* fix docs

* add test case

* fix default options

* refactor

* refactor

* fix

* handle nested promise

* fix lint error
  • Loading branch information
yeonjuan committed Feb 23, 2024
1 parent bba28a9 commit 46cef96
Show file tree
Hide file tree
Showing 9 changed files with 685 additions and 0 deletions.
42 changes: 42 additions & 0 deletions packages/eslint-plugin/docs/rules/consistent-return.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
---
description: 'Require `return` statements to either always or never specify values.'
---

> 🛑 This file is source code, not the primary documentation location! 🛑
>
> See **https://typescript-eslint.io/rules/consistent-return** for documentation.
This rule extends the base [`eslint/consistent-return`](https://eslint.org/docs/rules/consistent-return) rule.
This version adds support for functions that return `void` or `Promise<void>`.

<!--tabs-->

### ❌ Incorrect

```ts
function foo(): undefined {}
function bar(flag: boolean): undefined {
if (flag) return foo();
return;
}

async function baz(flag: boolean): Promise<undefined> {
if (flag) return;
return foo();
}
```

### ✅ Correct

```ts
function foo(): void {}
function bar(flag: boolean): void {
if (flag) return foo();
return;
}

async function baz(flag: boolean): Promise<void | number> {
if (flag) return 42;
return;
}
```
2 changes: 2 additions & 0 deletions packages/eslint-plugin/src/configs/all.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ export = {
'@typescript-eslint/class-methods-use-this': 'error',
'@typescript-eslint/consistent-generic-constructors': 'error',
'@typescript-eslint/consistent-indexed-object-style': 'error',
'consistent-return': 'off',
'@typescript-eslint/consistent-return': 'error',
'@typescript-eslint/consistent-type-assertions': 'error',
'@typescript-eslint/consistent-type-definitions': 'error',
'@typescript-eslint/consistent-type-exports': 'error',
Expand Down
1 change: 1 addition & 0 deletions packages/eslint-plugin/src/configs/disable-type-checked.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export = {
parserOptions: { project: false, program: null },
rules: {
'@typescript-eslint/await-thenable': 'off',
'@typescript-eslint/consistent-return': 'off',
'@typescript-eslint/consistent-type-exports': 'off',
'@typescript-eslint/dot-notation': 'off',
'@typescript-eslint/naming-convention': 'off',
Expand Down
128 changes: 128 additions & 0 deletions packages/eslint-plugin/src/rules/consistent-return.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import type { TSESTree } from '@typescript-eslint/utils';
import * as tsutils from 'ts-api-utils';
import * as ts from 'typescript';

import type {
InferMessageIdsTypeFromRule,
InferOptionsTypeFromRule,
} from '../util';
import { createRule, getParserServices, isTypeFlagSet } from '../util';
import { getESLintCoreRule } from '../util/getESLintCoreRule';

const baseRule = getESLintCoreRule('consistent-return');

type Options = InferOptionsTypeFromRule<typeof baseRule>;
type MessageIds = InferMessageIdsTypeFromRule<typeof baseRule>;

type FunctionNode =
| TSESTree.FunctionDeclaration
| TSESTree.FunctionExpression
| TSESTree.ArrowFunctionExpression;

export default createRule<Options, MessageIds>({
name: 'consistent-return',
meta: {
type: 'suggestion',
docs: {
description:
'Require `return` statements to either always or never specify values',
extendsBaseRule: true,
requiresTypeChecking: true,
},
hasSuggestions: baseRule.meta.hasSuggestions,
schema: baseRule.meta.schema,
messages: baseRule.meta.messages,
},
defaultOptions: [{ treatUndefinedAsUnspecified: false }],
create(context, [options]) {
const services = getParserServices(context);
const checker = services.program.getTypeChecker();
const rules = baseRule.create(context);
const functions: FunctionNode[] = [];
const treatUndefinedAsUnspecified =
options?.treatUndefinedAsUnspecified === true;

function enterFunction(node: FunctionNode): void {
functions.push(node);
}

function exitFunction(): void {
functions.pop();
}

function getCurrentFunction(): FunctionNode | null {
return functions[functions.length - 1] ?? null;
}

function isPromiseVoid(node: ts.Node, type: ts.Type): boolean {
if (
tsutils.isThenableType(checker, node, type) &&
tsutils.isTypeReference(type)
) {
const awaitedType = type.typeArguments?.[0];
if (awaitedType) {
if (isTypeFlagSet(awaitedType, ts.TypeFlags.Void)) {
return true;
}
return isPromiseVoid(node, awaitedType);
}
}
return false;
}

function isReturnVoidOrThenableVoid(node: FunctionNode): boolean {
const functionType = services.getTypeAtLocation(node);
const tsNode = services.esTreeNodeToTSNodeMap.get(node);
const callSignatures = functionType.getCallSignatures();

return callSignatures.some(signature => {
const returnType = signature.getReturnType();
if (node.async) {
return isPromiseVoid(tsNode, returnType);
}
return isTypeFlagSet(returnType, ts.TypeFlags.Void);
});
}

return {
...rules,
FunctionDeclaration: enterFunction,
'FunctionDeclaration:exit'(node): void {
exitFunction();
rules['FunctionDeclaration:exit'](node);
},
FunctionExpression: enterFunction,
'FunctionExpression:exit'(node): void {
exitFunction();
rules['FunctionExpression:exit'](node);
},
ArrowFunctionExpression: enterFunction,
'ArrowFunctionExpression:exit'(node): void {
exitFunction();
rules['ArrowFunctionExpression:exit'](node);
},
ReturnStatement(node): void {
const functionNode = getCurrentFunction();
if (
!node.argument &&
functionNode &&
isReturnVoidOrThenableVoid(functionNode)
) {
return;
}
if (treatUndefinedAsUnspecified && node.argument) {
const returnValueType = services.getTypeAtLocation(node.argument);
if (returnValueType.flags === ts.TypeFlags.Undefined) {
rules.ReturnStatement({
...node,
argument: null,
});
return;
}
}

rules.ReturnStatement(node);
},
};
},
});
2 changes: 2 additions & 0 deletions packages/eslint-plugin/src/rules/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import commaDangle from './comma-dangle';
import commaSpacing from './comma-spacing';
import consistentGenericConstructors from './consistent-generic-constructors';
import consistentIndexedObjectStyle from './consistent-indexed-object-style';
import consistentReturn from './consistent-return';
import consistentTypeAssertions from './consistent-type-assertions';
import consistentTypeDefinitions from './consistent-type-definitions';
import consistentTypeExports from './consistent-type-exports';
Expand Down Expand Up @@ -156,6 +157,7 @@ export default {
'comma-spacing': commaSpacing,
'consistent-generic-constructors': consistentGenericConstructors,
'consistent-indexed-object-style': consistentIndexedObjectStyle,
'consistent-return': consistentReturn,
'consistent-type-assertions': consistentTypeAssertions,
'consistent-type-definitions': consistentTypeDefinitions,
'consistent-type-exports': consistentTypeExports,
Expand Down
1 change: 1 addition & 0 deletions packages/eslint-plugin/src/util/getESLintCoreRule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ interface RuleMap {
'block-spacing': typeof import('eslint/lib/rules/block-spacing');
'brace-style': typeof import('eslint/lib/rules/brace-style');
'comma-dangle': typeof import('eslint/lib/rules/comma-dangle');
'consistent-return': typeof import('eslint/lib/rules/consistent-return');
'dot-notation': typeof import('eslint/lib/rules/dot-notation');
indent: typeof import('eslint/lib/rules/indent');
'init-declarations': typeof import('eslint/lib/rules/init-declarations');
Expand Down
Loading

0 comments on commit 46cef96

Please sign in to comment.