Skip to content

feat(eslint-plugin): [prefer-promise-reject-errors] add rule #8011

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
a4f7195
feat(eslint-plugin): [prefer-promise-reject-errors] new rule!
auvred Dec 1, 2023
538323f
test: ~100% coverage
auvred Dec 1, 2023
f779d93
docs: add rule docs
auvred Dec 1, 2023
a27930a
test: add some cases
auvred Dec 1, 2023
2b83ea4
chore: lint --fix
auvred Dec 1, 2023
d86f5c0
chore: reformat tests
auvred Dec 1, 2023
dab6503
feat: add support for literal computed reject name
auvred Dec 1, 2023
8d8bbc3
chore: lint --fix
auvred Dec 1, 2023
ff307be
refactor: get rid of one @ts-expect-error
auvred Dec 3, 2023
ccde4a6
docs: refer to the original rule description
auvred Dec 3, 2023
d153d13
test: add few cases
auvred Dec 3, 2023
5263fa8
docs: remove some examples
auvred Dec 6, 2023
6981eb5
refactor: move check if symbol is from default lib or not to new fn
auvred Dec 6, 2023
a680b8d
refactor: assert that rejectVariable is non-nullable
auvred Dec 6, 2023
1e2e771
chore: remove assertion in skipChainExpression
auvred Dec 6, 2023
e1db988
test: specify error ranges for invalid test cases
auvred Dec 6, 2023
1401910
chore: merge main
auvred Dec 6, 2023
42e5c1f
chore: format tests
auvred Dec 6, 2023
47eb018
chore: remove unused check if variable reference is read or not
auvred Dec 6, 2023
f2ee5d9
chore: include rule to `strict-type-checked` config
auvred Dec 6, 2023
0229ae7
refactor: simplify isSymbolFromDefaultLibrary
auvred Dec 6, 2023
9db6d84
chore: remove ts-expect-error comment
auvred Dec 6, 2023
a0699cf
feat: add checks for Promise child classes and unions/intersections
auvred Dec 20, 2023
3a79f84
Merge branch 'main' into feat/prefer-promise-reject-errors-rule
auvred Dec 20, 2023
1c97219
Update packages/eslint-plugin/docs/rules/prefer-promise-reject-errors.md
auvred Dec 29, 2023
42c5737
refactor: `program` -> `services.program`
auvred Dec 29, 2023
184de0f
refactor: split unreadable if condition
auvred Dec 29, 2023
eaab9eb
docs: simplify examples
auvred Dec 29, 2023
895f1b5
refactor: rename `isBuiltinSymbolLike.ts` -> `builtinSymbolLikes.ts`
auvred Dec 29, 2023
584c2e7
Merge branch 'main' into feat/prefer-promise-reject-errors-rule
auvred Dec 29, 2023
f186084
perf: get type of `reject` callee lazily
auvred Jan 4, 2024
e4af6ed
test: add cases with arrays,never,unknown
auvred Jan 4, 2024
2326429
feat: add support for `Readonly<Error>` and similar
auvred Jan 5, 2024
f33a84a
chore: fix lint issues
auvred Jan 6, 2024
7236787
Merge branch 'main' into feat/prefer-promise-reject-errors-rule
auvred Jan 6, 2024
bd20782
Merge branch 'main' into feat/prefer-promise-reject-errors-rule
auvred Jan 7, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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