Skip to content

Commit

Permalink
feat(no-throw-statements)!: replace option allowInAsyncFunctions wi…
Browse files Browse the repository at this point in the history
…th `allowToRejectPromises`
  • Loading branch information
RebeccaStevens committed Jun 24, 2024
1 parent 87bfb3b commit fd3b0d0
Show file tree
Hide file tree
Showing 10 changed files with 227 additions and 16 deletions.
12 changes: 6 additions & 6 deletions docs/rules/no-throw-statements.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,15 +54,15 @@ This rule accepts an options object of the following type:

```ts
type Options = {
allowInAsyncFunctions: boolean;
allowToRejectPromises: boolean;
};
```

### Default Options

```ts
const defaults = {
allowInAsyncFunctions: false,
allowToRejectPromises: false,
};
```

Expand All @@ -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]);
Expand Down
2 changes: 1 addition & 1 deletion src/configs/recommended.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ const overrides = {
[noThrowStatements.fullName]: [
"error",
{
allowInAsyncFunctions: true,
allowToRejectPromises: true,
},
],
[noTryStatements.fullName]: "off",
Expand Down
15 changes: 9 additions & 6 deletions src/rules/no-throw-statements.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -25,7 +25,7 @@ export const fullName = `${ruleNameScope}/${name}`;
*/
type Options = [
{
allowInAsyncFunctions: boolean;
allowToRejectPromises: boolean;
},
];

Expand All @@ -36,7 +36,7 @@ const schema: JSONSchema4[] = [
{
type: "object",
properties: {
allowInAsyncFunctions: {
allowToRejectPromises: {
type: "boolean",
},
},
Expand All @@ -49,7 +49,7 @@ const schema: JSONSchema4[] = [
*/
const defaultOptions: Options = [
{
allowInAsyncFunctions: false,
allowToRejectPromises: false,
},
];

Expand Down Expand Up @@ -84,9 +84,12 @@ function checkThrowStatement(
context: Readonly<RuleContext<keyof typeof errorMessages, Options>>,
options: Readonly<Options>,
): RuleResult<keyof typeof errorMessages, Options> {
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" }] };
}

Expand Down
48 changes: 47 additions & 1 deletion src/utils/tree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -18,6 +18,7 @@ import {
isMethodDefinition,
isObjectExpression,
isProgram,
isPromiseType,
isProperty,
isTSInterfaceBody,
isTSInterfaceHeritage,
Expand Down Expand Up @@ -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<string, BaseOptions>,
>(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<{...}>`.
*/
Expand Down
9 changes: 9 additions & 0 deletions src/utils/type-guards.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)))
);
}
2 changes: 1 addition & 1 deletion tests/rules/no-throw-statement/es2016/invalid.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ const tests: Array<
optionsSet: [
[
{
allowInAsyncFunctions: false,
allowToRejectPromises: false,
},
],
],
Expand Down
2 changes: 1 addition & 1 deletion tests/rules/no-throw-statement/es2016/valid.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ const tests: Array<ValidTestCaseSet<OptionsOf<typeof rule>>> = [
optionsSet: [
[
{
allowInAsyncFunctions: true,
allowToRejectPromises: true,
},
],
],
Expand Down
19 changes: 19 additions & 0 deletions tests/rules/no-throw-statement/ts/index.test.ts
Original file line number Diff line number Diff line change
@@ -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);
92 changes: 92 additions & 0 deletions tests/rules/no-throw-statement/ts/invalid.ts
Original file line number Diff line number Diff line change
@@ -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<MessagesOf<typeof rule>, OptionsOf<typeof rule>>
> = [
{
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;
42 changes: 42 additions & 0 deletions tests/rules/no-throw-statement/ts/valid.ts
Original file line number Diff line number Diff line change
@@ -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<ValidTestCaseSet<OptionsOf<typeof rule>>> = [
{
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;

0 comments on commit fd3b0d0

Please sign in to comment.