[go: up one dir, main page]

Skip to content

Commit

Permalink
feat(eslint-plugin): [prefer-promise-reject-errors] add rule (#8011)
Browse files Browse the repository at this point in the history
* feat(eslint-plugin): [prefer-promise-reject-errors] new rule!

* test: ~100% coverage

* docs: add rule docs

* test: add some cases

* chore: lint --fix

* chore: reformat tests

* feat: add support for literal computed reject name

* chore: lint --fix

* refactor: get rid of one @ts-expect-error

* docs: refer to the original rule description

* test: add few cases

* docs: remove some examples

* refactor: move check if symbol is from default lib or not to new fn

* refactor: assert that rejectVariable is non-nullable

* chore: remove assertion in skipChainExpression

* test: specify error ranges for invalid test cases

* chore: format tests

* chore: remove unused check if variable reference is read or not

* chore: include rule to `strict-type-checked` config

* refactor: simplify isSymbolFromDefaultLibrary

* chore: remove ts-expect-error comment

* feat: add checks for Promise child classes and unions/intersections

* Update packages/eslint-plugin/docs/rules/prefer-promise-reject-errors.md

Co-authored-by: Josh Goldberg ✨ <git@joshuakgoldberg.com>

* refactor: `program` -> `services.program`

* refactor: split unreadable if condition

* docs: simplify examples

* refactor: rename `isBuiltinSymbolLike.ts` -> `builtinSymbolLikes.ts`

* perf: get type of `reject` callee lazily

* test: add cases with arrays,never,unknown

* feat: add support for `Readonly<Error>` and similar

* chore: fix lint issues

---------

Co-authored-by: Josh Goldberg ✨ <git@joshuakgoldberg.com>
  • Loading branch information
auvred and JoshuaKGoldberg authored Jan 9, 2024
1 parent aa7ab0e commit 1aa8664
Show file tree
Hide file tree
Showing 12 changed files with 1,861 additions and 36 deletions.
50 changes: 50 additions & 0 deletions packages/eslint-plugin/docs/rules/prefer-promise-reject-errors.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
---
description: 'Require using Error objects as Promise rejection reasons.'
---

> 🛑 This file is source code, not the primary documentation location! 🛑
>
> See **https://typescript-eslint.io/rules/prefer-promise-reject-errors** for documentation.
This rule extends the base [`eslint/prefer-promise-reject-errors`](https://eslint.org/docs/rules/prefer-promise-reject-errors) rule.
It uses type information to enforce that `Promise`s are only rejected with `Error` objects.

## Examples

<!--tabs-->

### ❌ Incorrect

```ts
Promise.reject('error');

const err = new Error();
Promise.reject('an ' + err);

new Promise((resolve, reject) => reject('error'));

new Promise((resolve, reject) => {
const err = new Error();
reject('an ' + err);
});
```

### ✅ Correct

```ts
Promise.reject(new Error());

class CustomError extends Error {
// ...
}
Promise.reject(new CustomError());

new Promise((resolve, reject) => reject(new Error()));

new Promise((resolve, reject) => {
class CustomError extends Error {
// ...
}
return reject(new CustomError());
});
```
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 @@ -125,6 +125,8 @@ export = {
'@typescript-eslint/prefer-namespace-keyword': 'error',
'@typescript-eslint/prefer-nullish-coalescing': 'error',
'@typescript-eslint/prefer-optional-chain': 'error',
'prefer-promise-reject-errors': 'off',
'@typescript-eslint/prefer-promise-reject-errors': 'error',
'@typescript-eslint/prefer-readonly': 'error',
'@typescript-eslint/prefer-readonly-parameter-types': 'error',
'@typescript-eslint/prefer-reduce-type-parameter': '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 @@ -41,6 +41,7 @@ export = {
'@typescript-eslint/prefer-includes': 'off',
'@typescript-eslint/prefer-nullish-coalescing': 'off',
'@typescript-eslint/prefer-optional-chain': 'off',
'@typescript-eslint/prefer-promise-reject-errors': 'off',
'@typescript-eslint/prefer-readonly': 'off',
'@typescript-eslint/prefer-readonly-parameter-types': 'off',
'@typescript-eslint/prefer-reduce-type-parameter': 'off',
Expand Down
2 changes: 2 additions & 0 deletions packages/eslint-plugin/src/configs/strict-type-checked.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,8 @@ export = {
'@typescript-eslint/prefer-as-const': 'error',
'@typescript-eslint/prefer-includes': 'error',
'@typescript-eslint/prefer-literal-enum-member': 'error',
'prefer-promise-reject-errors': 'off',
'@typescript-eslint/prefer-promise-reject-errors': 'error',
'@typescript-eslint/prefer-reduce-type-parameter': 'error',
'@typescript-eslint/prefer-return-this-type': 'error',
'@typescript-eslint/prefer-ts-expect-error': 'error',
Expand Down
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 @@ -109,6 +109,7 @@ import preferLiteralEnumMember from './prefer-literal-enum-member';
import preferNamespaceKeyword from './prefer-namespace-keyword';
import preferNullishCoalescing from './prefer-nullish-coalescing';
import preferOptionalChain from './prefer-optional-chain';
import preferPromiseRejectErrors from './prefer-promise-reject-errors';
import preferReadonly from './prefer-readonly';
import preferReadonlyParameterTypes from './prefer-readonly-parameter-types';
import preferReduceTypeParameter from './prefer-reduce-type-parameter';
Expand Down Expand Up @@ -248,6 +249,7 @@ export default {
'prefer-namespace-keyword': preferNamespaceKeyword,
'prefer-nullish-coalescing': preferNullishCoalescing,
'prefer-optional-chain': preferOptionalChain,
'prefer-promise-reject-errors': preferPromiseRejectErrors,
'prefer-readonly': preferReadonly,
'prefer-readonly-parameter-types': preferReadonlyParameterTypes,
'prefer-reduce-type-parameter': preferReduceTypeParameter,
Expand Down
38 changes: 2 additions & 36 deletions packages/eslint-plugin/src/rules/no-throw-literal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import * as ts from 'typescript';
import {
createRule,
getParserServices,
isErrorLike,
isTypeAnyType,
isTypeUnknownType,
} from '../util';
Expand Down Expand Up @@ -55,41 +56,6 @@ export default createRule<Options, MessageIds>({
],
create(context, [options]) {
const services = getParserServices(context);
const checker = services.program.getTypeChecker();

function isErrorLike(type: ts.Type): boolean {
if (type.isIntersection()) {
return type.types.some(isErrorLike);
}
if (type.isUnion()) {
return type.types.every(isErrorLike);
}

const symbol = type.getSymbol();
if (!symbol) {
return false;
}

if (symbol.getName() === 'Error') {
const declarations = symbol.getDeclarations() ?? [];
for (const declaration of declarations) {
const sourceFile = declaration.getSourceFile();
if (services.program.isSourceFileDefaultLibrary(sourceFile)) {
return true;
}
}
}

if (symbol.flags & (ts.SymbolFlags.Class | ts.SymbolFlags.Interface)) {
for (const baseType of checker.getBaseTypes(type as ts.InterfaceType)) {
if (isErrorLike(baseType)) {
return true;
}
}
}

return false;
}

function checkThrowArgument(node: TSESTree.Node): void {
if (
Expand All @@ -114,7 +80,7 @@ export default createRule<Options, MessageIds>({
return;
}

if (isErrorLike(type)) {
if (isErrorLike(services.program, type)) {
return;
}

Expand Down
153 changes: 153 additions & 0 deletions packages/eslint-plugin/src/rules/prefer-promise-reject-errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
import type { TSESTree } from '@typescript-eslint/utils';
import { AST_NODE_TYPES } from '@typescript-eslint/utils';
import { getDeclaredVariables } from '@typescript-eslint/utils/eslint-utils';

import {
createRule,
getParserServices,
isErrorLike,
isFunction,
isIdentifier,
isPromiseConstructorLike,
isPromiseLike,
isReadonlyErrorLike,
} from '../util';

export type MessageIds = 'rejectAnError';

export type Options = [
{
allowEmptyReject?: boolean;
},
];

export default createRule<Options, MessageIds>({
name: 'prefer-promise-reject-errors',
meta: {
type: 'suggestion',
docs: {
description: 'Require using Error objects as Promise rejection reasons',
recommended: 'strict',
extendsBaseRule: true,
requiresTypeChecking: true,
},
schema: [
{
type: 'object',
properties: {
allowEmptyReject: {
type: 'boolean',
},
},
additionalProperties: false,
},
],
messages: {
rejectAnError: 'Expected the Promise rejection reason to be an Error.',
},
},
defaultOptions: [
{
allowEmptyReject: false,
},
],
create(context, [options]) {
const services = getParserServices(context);

function checkRejectCall(callExpression: TSESTree.CallExpression): void {
const argument = callExpression.arguments.at(0);
if (argument) {
const type = services.getTypeAtLocation(argument);
if (
isErrorLike(services.program, type) ||
isReadonlyErrorLike(services.program, type)
) {
return;
}
} else if (options.allowEmptyReject) {
return;
}

context.report({
node: callExpression,
messageId: 'rejectAnError',
});
}

function skipChainExpression<T extends TSESTree.Node>(
node: T,
): T | TSESTree.ChainElement {
return node.type === AST_NODE_TYPES.ChainExpression
? node.expression
: node;
}

function typeAtLocationIsLikePromise(node: TSESTree.Node): boolean {
const type = services.getTypeAtLocation(node);
return (
isPromiseConstructorLike(services.program, type) ||
isPromiseLike(services.program, type)
);
}

return {
CallExpression(node): void {
const callee = skipChainExpression(node.callee);

if (callee.type !== AST_NODE_TYPES.MemberExpression) {
return;
}

const rejectMethodCalled = callee.computed
? callee.property.type === AST_NODE_TYPES.Literal &&
callee.property.value === 'reject'
: callee.property.name === 'reject';

if (
!rejectMethodCalled ||
!typeAtLocationIsLikePromise(callee.object)
) {
return;
}

checkRejectCall(node);
},
NewExpression(node): void {
const callee = skipChainExpression(node.callee);
if (
!isPromiseConstructorLike(
services.program,
services.getTypeAtLocation(callee),
)
) {
return;
}

const executor = node.arguments.at(0);
if (!executor || !isFunction(executor)) {
return;
}
const rejectParamNode = executor.params.at(1);
if (!rejectParamNode || !isIdentifier(rejectParamNode)) {
return;
}

// reject param is always present in variables declared by executor
const rejectVariable = getDeclaredVariables(context, executor).find(
variable => variable.identifiers.includes(rejectParamNode),
)!;

rejectVariable.references.forEach(ref => {
if (
ref.identifier.parent.type !== AST_NODE_TYPES.CallExpression ||
ref.identifier !== ref.identifier.parent.callee
) {
return;
}

checkRejectCall(ref.identifier.parent);
});
},
};
},
});
Loading

0 comments on commit 1aa8664

Please sign in to comment.