diff --git a/packages/eslint-plugin/docs/rules/consistent-return.md b/packages/eslint-plugin/docs/rules/consistent-return.md new file mode 100644 index 00000000000..03f74840e26 --- /dev/null +++ b/packages/eslint-plugin/docs/rules/consistent-return.md @@ -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`. + + + +### ❌ Incorrect + +```ts +function foo(): undefined {} +function bar(flag: boolean): undefined { + if (flag) return foo(); + return; +} + +async function baz(flag: boolean): Promise { + 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 { + if (flag) return 42; + return; +} +``` diff --git a/packages/eslint-plugin/src/configs/all.ts b/packages/eslint-plugin/src/configs/all.ts index 4c30f33ed9e..c66afe869db 100644 --- a/packages/eslint-plugin/src/configs/all.ts +++ b/packages/eslint-plugin/src/configs/all.ts @@ -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', diff --git a/packages/eslint-plugin/src/configs/disable-type-checked.ts b/packages/eslint-plugin/src/configs/disable-type-checked.ts index 48cf79d56e2..f322a903637 100644 --- a/packages/eslint-plugin/src/configs/disable-type-checked.ts +++ b/packages/eslint-plugin/src/configs/disable-type-checked.ts @@ -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', diff --git a/packages/eslint-plugin/src/rules/consistent-return.ts b/packages/eslint-plugin/src/rules/consistent-return.ts new file mode 100644 index 00000000000..5d4cc3fb925 --- /dev/null +++ b/packages/eslint-plugin/src/rules/consistent-return.ts @@ -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; +type MessageIds = InferMessageIdsTypeFromRule; + +type FunctionNode = + | TSESTree.FunctionDeclaration + | TSESTree.FunctionExpression + | TSESTree.ArrowFunctionExpression; + +export default createRule({ + 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); + }, + }; + }, +}); diff --git a/packages/eslint-plugin/src/rules/index.ts b/packages/eslint-plugin/src/rules/index.ts index e497019debe..6d94605135b 100644 --- a/packages/eslint-plugin/src/rules/index.ts +++ b/packages/eslint-plugin/src/rules/index.ts @@ -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'; @@ -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, diff --git a/packages/eslint-plugin/src/util/getESLintCoreRule.ts b/packages/eslint-plugin/src/util/getESLintCoreRule.ts index c3349011de5..be28069d287 100644 --- a/packages/eslint-plugin/src/util/getESLintCoreRule.ts +++ b/packages/eslint-plugin/src/util/getESLintCoreRule.ts @@ -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'); diff --git a/packages/eslint-plugin/tests/rules/consistent-return.test.ts b/packages/eslint-plugin/tests/rules/consistent-return.test.ts new file mode 100644 index 00000000000..1489b835d8b --- /dev/null +++ b/packages/eslint-plugin/tests/rules/consistent-return.test.ts @@ -0,0 +1,457 @@ +import { RuleTester } from '@typescript-eslint/rule-tester'; +import { AST_NODE_TYPES } from '@typescript-eslint/utils'; + +import rule from '../../src/rules/consistent-return'; +import { getFixturesRootDir } from '../RuleTester'; + +const rootDir = getFixturesRootDir(); +const ruleTester = new RuleTester({ + parser: '@typescript-eslint/parser', + parserOptions: { + ecmaVersion: 2021, + tsconfigRootDir: rootDir, + project: './tsconfig.json', + }, +}); + +ruleTester.run('consistent-return', rule, { + valid: [ + // base rule + ` + function foo() { + return; + } + `, + ` + const foo = (flag: boolean) => { + if (flag) return true; + return false; + }; + `, + ` + class A { + foo() { + if (a) return true; + return false; + } + } + `, + { + code: ` + const foo = (flag: boolean) => { + if (flag) return; + else return undefined; + }; + `, + options: [{ treatUndefinedAsUnspecified: true }], + }, + // void + ` + declare function bar(): void; + function foo(flag: boolean): void { + if (flag) { + return bar(); + } + return; + } + `, + ` + declare function bar(): void; + const foo = (flag: boolean): void => { + if (flag) { + return; + } + return bar(); + }; + `, + ` + function foo(flag?: boolean): number | void { + if (flag) { + return 42; + } + return; + } + `, + ` + function foo(): boolean; + function foo(flag: boolean): void; + function foo(flag?: boolean): boolean | void { + if (flag) { + return; + } + return true; + } + `, + ` + class Foo { + baz(): void {} + bar(flag: boolean): void { + if (flag) return baz(); + return; + } + } + `, + ` + declare function bar(): void; + function foo(flag: boolean): void { + function fn(): string { + return '1'; + } + if (flag) { + return bar(); + } + return; + } + `, + ` + class Foo { + foo(flag: boolean): void { + const bar = (): void => { + if (flag) return; + return this.foo(); + }; + if (flag) { + return this.bar(); + } + return; + } + } + `, + // async + ` + declare function bar(): void; + async function foo(flag?: boolean): Promise { + if (flag) { + return bar(); + } + return; + } + `, + ` + declare function bar(): Promise; + async function foo(flag?: boolean): Promise> { + if (flag) { + return bar(); + } + return; + } + `, + ` + async function foo(flag?: boolean): Promise> { + if (flag) { + return undefined; + } + return; + } + `, + ` + type PromiseVoidNumber = Promise; + async function foo(flag?: boolean): PromiseVoidNumber { + if (flag) { + return 42; + } + return; + } + `, + ` + class Foo { + baz(): void {} + async bar(flag: boolean): Promise { + if (flag) return baz(); + return; + } + } + `, + { + code: ` + declare const undef: undefined; + function foo(flag: boolean) { + if (flag) { + return undef; + } + return 'foo'; + } + `, + options: [ + { + treatUndefinedAsUnspecified: false, + }, + ], + }, + { + code: ` + function foo(flag: boolean): undefined { + if (flag) { + return undefined; + } + return; + } + `, + options: [ + { + treatUndefinedAsUnspecified: true, + }, + ], + }, + { + code: ` + declare const undef: undefined; + function foo(flag: boolean): undefined { + if (flag) { + return undef; + } + return; + } + `, + options: [ + { + treatUndefinedAsUnspecified: true, + }, + ], + }, + ], + invalid: [ + { + code: ` + function foo(flag: boolean): any { + if (flag) return true; + else return; + } + `, + errors: [ + { + messageId: 'missingReturnValue', + data: { name: "Function 'foo'" }, + type: AST_NODE_TYPES.ReturnStatement, + line: 4, + column: 16, + endLine: 4, + endColumn: 23, + }, + ], + }, + { + code: ` + function bar(): undefined {} + function foo(flag: boolean): undefined { + if (flag) return bar(); + return; + } + `, + errors: [ + { + messageId: 'missingReturnValue', + data: { name: "Function 'foo'" }, + type: AST_NODE_TYPES.ReturnStatement, + line: 5, + column: 11, + endLine: 5, + endColumn: 18, + }, + ], + }, + { + code: ` + declare function foo(): void; + function bar(flag: boolean): undefined { + function baz(): undefined { + if (flag) return; + return undefined; + } + if (flag) return baz(); + return; + } + `, + errors: [ + { + messageId: 'unexpectedReturnValue', + data: { name: "Function 'baz'" }, + type: AST_NODE_TYPES.ReturnStatement, + line: 6, + column: 13, + endLine: 6, + endColumn: 30, + }, + { + messageId: 'missingReturnValue', + data: { name: "Function 'bar'" }, + type: AST_NODE_TYPES.ReturnStatement, + line: 9, + column: 11, + endLine: 9, + endColumn: 18, + }, + ], + }, + { + code: ` + function foo(flag: boolean): Promise { + if (flag) return Promise.resolve(void 0); + else return; + } + `, + errors: [ + { + messageId: 'missingReturnValue', + data: { name: "Function 'foo'" }, + type: AST_NODE_TYPES.ReturnStatement, + line: 4, + column: 16, + endLine: 4, + endColumn: 23, + }, + ], + }, + { + code: ` + async function foo(flag: boolean): Promise { + if (flag) return; + else return 'value'; + } + `, + errors: [ + { + messageId: 'unexpectedReturnValue', + data: { name: "Async function 'foo'" }, + type: AST_NODE_TYPES.ReturnStatement, + line: 4, + column: 16, + endLine: 4, + endColumn: 31, + }, + ], + }, + { + code: ` + async function foo(flag: boolean): Promise { + if (flag) return 'value'; + else return; + } + `, + errors: [ + { + messageId: 'missingReturnValue', + data: { name: "Async function 'foo'" }, + type: AST_NODE_TYPES.ReturnStatement, + line: 4, + column: 16, + endLine: 4, + endColumn: 23, + }, + ], + }, + { + code: ` + async function foo(flag: boolean) { + if (flag) return; + return 1; + } + `, + errors: [ + { + messageId: 'unexpectedReturnValue', + data: { name: "Async function 'foo'" }, + type: AST_NODE_TYPES.ReturnStatement, + line: 4, + column: 11, + endLine: 4, + endColumn: 20, + }, + ], + }, + { + code: ` + function foo(flag: boolean): Promise { + if (flag) return; + else return 'value'; + } + `, + errors: [ + { + messageId: 'unexpectedReturnValue', + data: { name: "Function 'foo'" }, + type: AST_NODE_TYPES.ReturnStatement, + line: 4, + column: 16, + endLine: 4, + endColumn: 31, + }, + ], + }, + { + code: ` + declare async function bar(): Promise; + function foo(flag?: boolean): Promise { + if (flag) { + return bar(); + } + return; + } + `, + errors: [ + { + messageId: 'missingReturnValue', + data: { name: "Function 'foo'" }, + type: AST_NODE_TYPES.ReturnStatement, + line: 7, + column: 11, + endLine: 7, + endColumn: 18, + }, + ], + }, + { + code: ` + function foo(flag: boolean): undefined | boolean { + if (flag) { + return undefined; + } + return true; + } + `, + options: [ + { + treatUndefinedAsUnspecified: true, + }, + ], + errors: [ + { + messageId: 'unexpectedReturnValue', + data: { name: "Function 'foo'" }, + type: AST_NODE_TYPES.ReturnStatement, + line: 6, + column: 11, + endLine: 6, + endColumn: 23, + }, + ], + }, + { + code: ` + declare const undefOrNum: undefined | number; + function foo(flag: boolean) { + if (flag) { + return; + } + return undefOrNum; + } + `, + options: [ + { + treatUndefinedAsUnspecified: true, + }, + ], + errors: [ + { + messageId: 'unexpectedReturnValue', + data: { name: "Function 'foo'" }, + type: AST_NODE_TYPES.ReturnStatement, + line: 7, + column: 11, + endLine: 7, + endColumn: 29, + }, + ], + }, + ], +}); diff --git a/packages/eslint-plugin/tests/schema-snapshots/consistent-return.shot b/packages/eslint-plugin/tests/schema-snapshots/consistent-return.shot new file mode 100644 index 00000000000..ec95109ea46 --- /dev/null +++ b/packages/eslint-plugin/tests/schema-snapshots/consistent-return.shot @@ -0,0 +1,29 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Rule schemas should be convertible to TS types for documentation purposes consistent-return 1`] = ` +" +# SCHEMA: + +[ + { + "additionalProperties": false, + "properties": { + "treatUndefinedAsUnspecified": { + "default": false, + "type": "boolean" + } + }, + "type": "object" + } +] + + +# TYPES: + +type Options = [ + { + treatUndefinedAsUnspecified?: boolean; + }, +]; +" +`; diff --git a/packages/eslint-plugin/typings/eslint-rules.d.ts b/packages/eslint-plugin/typings/eslint-rules.d.ts index 68e8f106781..c72e018bc9b 100644 --- a/packages/eslint-plugin/typings/eslint-rules.d.ts +++ b/packages/eslint-plugin/typings/eslint-rules.d.ts @@ -11,6 +11,7 @@ declare module 'eslint/use-at-your-own-risk' { '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'); @@ -71,6 +72,28 @@ declare module 'eslint/lib/rules/arrow-parens' { export = rule; } +declare module 'eslint/lib/rules/consistent-return' { + import type { TSESLint, TSESTree } from '@typescript-eslint/utils'; + + const rule: TSESLint.RuleModule< + 'missingReturn' | 'missingReturnValue' | 'unexpectedReturnValue', + [ + { + treatUndefinedAsUnspecified?: boolean; + }?, + ], + { + ReturnStatement(node: TSESTree.ReturnStatement): void; + 'FunctionDeclaration:exit'(node: TSESTree.FunctionDeclaration): void; + 'FunctionExpression:exit'(node: TSESTree.FunctionExpression): void; + 'ArrowFunctionExpression:exit'( + node: TSESTree.ArrowFunctionExpression, + ): void; + } + >; + export = rule; +} + declare module 'eslint/lib/rules/camelcase' { import type { TSESLint, TSESTree } from '@typescript-eslint/utils';