diff --git a/docs/rules/no-throw-statements.md b/docs/rules/no-throw-statements.md index 862b9c975..b4cbbdde0 100644 --- a/docs/rules/no-throw-statements.md +++ b/docs/rules/no-throw-statements.md @@ -54,7 +54,7 @@ This rule accepts an options object of the following type: ```ts type Options = { - allowInAsyncFunctions: boolean; + allowToRejectPromises: boolean; }; ``` @@ -62,7 +62,7 @@ type Options = { ```ts const defaults = { - allowInAsyncFunctions: false, + allowToRejectPromises: false, }; ``` @@ -72,19 +72,19 @@ const defaults = { ```ts const recommendedAndLiteOptions = { - allowInAsyncFunctions: true, + allowToRejectPromises: true, }; ``` -### `allowInAsyncFunctions` +### `allowToRejectPromises` -If true, throw statements will be allowed within async functions.\ +If true, throw statements will be allowed when they are used to reject a promise, such when in an async function.\ This essentially allows throw statements to be used as return statements for errors. #### ✅ Correct ```js -/* eslint functional/no-throw-statements: ["error", { "allowInAsyncFunctions": true }] */ +/* eslint functional/no-throw-statements: ["error", { "allowToRejectPromises": true }] */ async function divide(x, y) { const [xv, yv] = await Promise.all([x, y]); diff --git a/src/configs/recommended.ts b/src/configs/recommended.ts index d67d2ac7b..5a77b53ab 100644 --- a/src/configs/recommended.ts +++ b/src/configs/recommended.ts @@ -66,7 +66,7 @@ const overrides = { [noThrowStatements.fullName]: [ "error", { - allowInAsyncFunctions: true, + allowToRejectPromises: true, }, ], [noTryStatements.fullName]: "off", diff --git a/src/rules/no-throw-statements.ts b/src/rules/no-throw-statements.ts index 22c3840bd..f7797bccc 100644 --- a/src/rules/no-throw-statements.ts +++ b/src/rules/no-throw-statements.ts @@ -8,7 +8,7 @@ import { type RuleResult, createRule, } from "#/utils/rule"; -import { isInFunctionBody } from "#/utils/tree"; +import { isInFunctionBody, isInPromiseCatchFunction } from "#/utils/tree"; /** * The name of this rule. @@ -25,7 +25,7 @@ export const fullName = `${ruleNameScope}/${name}`; */ type Options = [ { - allowInAsyncFunctions: boolean; + allowToRejectPromises: boolean; }, ]; @@ -36,7 +36,7 @@ const schema: JSONSchema4[] = [ { type: "object", properties: { - allowInAsyncFunctions: { + allowToRejectPromises: { type: "boolean", }, }, @@ -49,7 +49,7 @@ const schema: JSONSchema4[] = [ */ const defaultOptions: Options = [ { - allowInAsyncFunctions: false, + allowToRejectPromises: false, }, ]; @@ -84,9 +84,12 @@ function checkThrowStatement( context: Readonly>, options: Readonly, ): RuleResult { - const [{ allowInAsyncFunctions }] = options; + const [{ allowToRejectPromises }] = options; - if (!allowInAsyncFunctions || !isInFunctionBody(node, true)) { + if ( + !allowToRejectPromises || + !(isInFunctionBody(node, true) || isInPromiseCatchFunction(node, context)) + ) { return { context, descriptors: [{ node, messageId: "generic" }] }; } diff --git a/src/utils/tree.ts b/src/utils/tree.ts index a3874d1c2..4a23a55af 100644 --- a/src/utils/tree.ts +++ b/src/utils/tree.ts @@ -4,7 +4,7 @@ import { type RuleContext } from "@typescript-eslint/utils/ts-eslint"; import typescript from "#/conditional-imports/typescript"; -import { type BaseOptions } from "./rule"; +import { type BaseOptions, getTypeOfNode } from "./rule"; import { isBlockStatement, isCallExpression, @@ -18,6 +18,7 @@ import { isMethodDefinition, isObjectExpression, isProgram, + isPromiseType, isProperty, isTSInterfaceBody, isTSInterfaceHeritage, @@ -104,6 +105,51 @@ export function isInReadonly(node: TSESTree.Node): boolean { return getReadonly(node) !== null; } +/** + * Test if the given node is in a catch function callback of a promise. + */ +export function isInPromiseCatchFunction< + Context extends RuleContext, +>(node: TSESTree.Node, context: Context): boolean { + const functionNode = getAncestorOfType( + (n, c): n is TSESTree.FunctionLike => isFunctionLike(n) && n.body === c, + node, + ); + + if ( + functionNode === null || + !isCallExpression(functionNode.parent) || + !isMemberExpression(functionNode.parent.callee) || + !isIdentifier(functionNode.parent.callee.property) + ) { + return false; + } + + const { object, property } = functionNode.parent.callee; + switch (property.name) { + case "then": { + if (functionNode.parent.arguments[1] !== functionNode) { + return false; + } + break; + } + + case "catch": { + if (functionNode.parent.arguments[0] !== functionNode) { + return false; + } + break; + } + + default: { + return false; + } + } + + const objectType = getTypeOfNode(object, context); + return isPromiseType(objectType); +} + /** * Test if the given node is shallowly inside a `Readonly<{...}>`. */ diff --git a/src/utils/type-guards.ts b/src/utils/type-guards.ts index 507dc55df..ecc28d3f0 100644 --- a/src/utils/type-guards.ts +++ b/src/utils/type-guards.ts @@ -434,3 +434,12 @@ export function isObjectConstructorType(type: Type | null): boolean { export function isFunctionLikeType(type: Type | null): boolean { return type !== null && type.getCallSignatures().length > 0; } + +export function isPromiseType(type: Type | null): boolean { + return ( + type !== null && + (((type.symbol as unknown) !== undefined && + type.symbol.name === "Promise") || + (isUnionType(type) && type.types.some(isPromiseType))) + ); +} diff --git a/tests/rules/no-throw-statement/es2016/invalid.ts b/tests/rules/no-throw-statement/es2016/invalid.ts index 99e511d7a..2af290418 100644 --- a/tests/rules/no-throw-statement/es2016/invalid.ts +++ b/tests/rules/no-throw-statement/es2016/invalid.ts @@ -20,7 +20,7 @@ const tests: Array< optionsSet: [ [ { - allowInAsyncFunctions: false, + allowToRejectPromises: false, }, ], ], diff --git a/tests/rules/no-throw-statement/es2016/valid.ts b/tests/rules/no-throw-statement/es2016/valid.ts index e3968e883..480c1fd8c 100644 --- a/tests/rules/no-throw-statement/es2016/valid.ts +++ b/tests/rules/no-throw-statement/es2016/valid.ts @@ -13,7 +13,7 @@ const tests: Array>> = [ optionsSet: [ [ { - allowInAsyncFunctions: true, + allowToRejectPromises: true, }, ], ], diff --git a/tests/rules/no-throw-statement/ts/index.test.ts b/tests/rules/no-throw-statement/ts/index.test.ts new file mode 100644 index 000000000..9ebaa593e --- /dev/null +++ b/tests/rules/no-throw-statement/ts/index.test.ts @@ -0,0 +1,19 @@ +import { name, rule } from "#/rules/no-throw-statements"; +import { testRule } from "#/tests/helpers/testers"; + +import es2016Invalid from "../es2016/invalid"; +import es2016Valid from "../es2016/valid"; +import es3Invalid from "../es3/invalid"; +import es3Valid from "../es3/valid"; + +import invalid from "./invalid"; +import valid from "./valid"; + +const tests = { + valid: [...es3Valid, ...es2016Valid, ...valid], + invalid: [...es3Invalid, ...es2016Invalid, ...invalid], +}; + +const tester = testRule(name, rule); + +tester.typescript(tests); diff --git a/tests/rules/no-throw-statement/ts/invalid.ts b/tests/rules/no-throw-statement/ts/invalid.ts new file mode 100644 index 000000000..67a326f57 --- /dev/null +++ b/tests/rules/no-throw-statement/ts/invalid.ts @@ -0,0 +1,92 @@ +import { AST_NODE_TYPES } from "@typescript-eslint/utils"; +import dedent from "dedent"; + +import { type rule } from "#/rules/no-throw-statements"; +import { + type InvalidTestCaseSet, + type MessagesOf, + type OptionsOf, +} from "#/tests/helpers/util"; + +const tests: Array< + InvalidTestCaseSet, OptionsOf> +> = [ + { + code: dedent` + const foo = Promise.reject(); + foo.then(() => { + throw new Error(); + }); + `, + optionsSet: [ + [ + { + allowToRejectPromises: true, + }, + ], + ], + errors: [ + { + messageId: "generic", + type: AST_NODE_TYPES.ThrowStatement, + line: 3, + column: 3, + }, + ], + }, + { + code: dedent` + const foo = Promise.reject(); + foo.then( + () => { + throw new Error(); + }, + () => {} + ); + `, + optionsSet: [ + [ + { + allowToRejectPromises: true, + }, + ], + ], + errors: [ + { + messageId: "generic", + type: AST_NODE_TYPES.ThrowStatement, + line: 4, + column: 7, + }, + ], + }, + { + code: dedent` + const foo = { + catch(cb) { + cb(); + }, + }; + foo.catch(() => { + throw new Error(); + }); + `, + optionsSet: [ + [ + { + allowToRejectPromises: true, + }, + ], + ], + errors: [ + { + messageId: "generic", + type: AST_NODE_TYPES.ThrowStatement, + line: 7, + column: 3, + }, + ], + }, +]; + +export default tests; diff --git a/tests/rules/no-throw-statement/ts/valid.ts b/tests/rules/no-throw-statement/ts/valid.ts new file mode 100644 index 000000000..3a70d2138 --- /dev/null +++ b/tests/rules/no-throw-statement/ts/valid.ts @@ -0,0 +1,42 @@ +import dedent from "dedent"; + +import { type rule } from "#/rules/no-throw-statements"; +import { type OptionsOf, type ValidTestCaseSet } from "#/tests/helpers/util"; + +const tests: Array>> = [ + { + code: dedent` + const foo = Promise.reject(); + foo.catch(() => { + throw new Error(); + }); + `, + optionsSet: [ + [ + { + allowToRejectPromises: true, + }, + ], + ], + }, + { + code: dedent` + const foo = Promise.reject(); + foo.then( + () => {}, + () => { + throw new Error(); + } + ); + `, + optionsSet: [ + [ + { + allowToRejectPromises: true, + }, + ], + ], + }, +]; + +export default tests;