diff --git a/packages/eslint-plugin/docs/rules/prefer-promise-reject-errors.md b/packages/eslint-plugin/docs/rules/prefer-promise-reject-errors.md new file mode 100644 index 000000000000..28e465cf00f1 --- /dev/null +++ b/packages/eslint-plugin/docs/rules/prefer-promise-reject-errors.md @@ -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 + + + +### ❌ 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()); +}); +``` diff --git a/packages/eslint-plugin/src/configs/all.ts b/packages/eslint-plugin/src/configs/all.ts index 9881e3397de2..31bf124f451c 100644 --- a/packages/eslint-plugin/src/configs/all.ts +++ b/packages/eslint-plugin/src/configs/all.ts @@ -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', diff --git a/packages/eslint-plugin/src/configs/disable-type-checked.ts b/packages/eslint-plugin/src/configs/disable-type-checked.ts index 2fe413146c7b..073c8b10c8a9 100644 --- a/packages/eslint-plugin/src/configs/disable-type-checked.ts +++ b/packages/eslint-plugin/src/configs/disable-type-checked.ts @@ -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', diff --git a/packages/eslint-plugin/src/configs/strict-type-checked.ts b/packages/eslint-plugin/src/configs/strict-type-checked.ts index 471175b9bba7..8bf73cbe7ee6 100644 --- a/packages/eslint-plugin/src/configs/strict-type-checked.ts +++ b/packages/eslint-plugin/src/configs/strict-type-checked.ts @@ -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', diff --git a/packages/eslint-plugin/src/rules/index.ts b/packages/eslint-plugin/src/rules/index.ts index 14c171af990e..d162be286e8e 100644 --- a/packages/eslint-plugin/src/rules/index.ts +++ b/packages/eslint-plugin/src/rules/index.ts @@ -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'; @@ -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, diff --git a/packages/eslint-plugin/src/rules/no-throw-literal.ts b/packages/eslint-plugin/src/rules/no-throw-literal.ts index f1129c252036..f3f5937c7379 100644 --- a/packages/eslint-plugin/src/rules/no-throw-literal.ts +++ b/packages/eslint-plugin/src/rules/no-throw-literal.ts @@ -5,6 +5,7 @@ import * as ts from 'typescript'; import { createRule, getParserServices, + isErrorLike, isTypeAnyType, isTypeUnknownType, } from '../util'; @@ -55,41 +56,6 @@ export default createRule({ ], 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 ( @@ -114,7 +80,7 @@ export default createRule({ return; } - if (isErrorLike(type)) { + if (isErrorLike(services.program, type)) { return; } diff --git a/packages/eslint-plugin/src/rules/prefer-promise-reject-errors.ts b/packages/eslint-plugin/src/rules/prefer-promise-reject-errors.ts new file mode 100644 index 000000000000..69494823e023 --- /dev/null +++ b/packages/eslint-plugin/src/rules/prefer-promise-reject-errors.ts @@ -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({ + 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( + 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); + }); + }, + }; + }, +}); diff --git a/packages/eslint-plugin/tests/rules/prefer-promise-reject-errors.test.ts b/packages/eslint-plugin/tests/rules/prefer-promise-reject-errors.test.ts new file mode 100644 index 000000000000..69eed002ebf7 --- /dev/null +++ b/packages/eslint-plugin/tests/rules/prefer-promise-reject-errors.test.ts @@ -0,0 +1,1438 @@ +import { noFormat, RuleTester } from '@typescript-eslint/rule-tester'; +import { AST_NODE_TYPES } from '@typescript-eslint/utils'; + +import rule from '../../src/rules/prefer-promise-reject-errors'; +import { getFixturesRootDir } from '../RuleTester'; + +const rootDir = getFixturesRootDir(); +const ruleTester = new RuleTester({ + parserOptions: { + ecmaVersion: 2015, + tsconfigRootDir: rootDir, + project: './tsconfig.json', + }, + parser: '@typescript-eslint/parser', +}); + +ruleTester.run('prefer-promise-reject-errors', rule, { + valid: [ + 'Promise.resolve(5);', + { + code: 'Promise.reject();', + options: [ + { + allowEmptyReject: true, + }, + ], + }, + 'Promise.reject(new Error());', + 'Promise.reject(new TypeError());', + "Promise.reject(new Error('foo'));", + ` + class CustomError extends Error {} + Promise.reject(new CustomError()); + `, + ` + declare const foo: () => { err: SyntaxError }; + Promise.reject(foo().err); + `, + ` + declare const foo: () => Promise; + Promise.reject(await foo()); + `, + 'Promise.reject((foo = new Error()));', + ` + const foo = Promise; + foo.reject(new Error()); + `, + "Promise['reject'](new Error());", + 'Promise.reject(true && new Error());', + ` + const foo = false; + Promise.reject(false || new Error()); + `, + ` + declare const foo: Readonly; + Promise.reject(foo); + `, + ` + declare const foo: Readonly | Readonly; + Promise.reject(foo); + `, + ` + declare const foo: Readonly & Readonly; + Promise.reject(foo); + `, + ` + declare const foo: Readonly & { foo: 'bar' }; + Promise.reject(foo); + `, + ` + declare const foo: Readonly & { foo: 'bar' }; + Promise.reject(foo); + `, + ` + declare const foo: Readonly>; + Promise.reject(foo); + `, + ` + declare const foo: Readonly>>; + Promise.reject(foo); + `, + ` + declare const foo: Readonly< + Readonly & { foo: 'bar' }> & { + fooBar: 'barFoo'; + } + > & { barFoo: 'fooBar' }; + Promise.reject(foo); + `, + ` + declare const foo: + | Readonly | Readonly> + | Readonly; + Promise.reject(foo); + `, + ` + type Wrapper = { foo: Readonly[] }; + declare const foo: Wrapper['foo'][5]; + Promise.reject(foo); + `, + ` + declare const foo: Error[]; + Promise.reject(foo[5]); + `, + ` + declare const foo: ReadonlyArray; + Promise.reject(foo[5]); + `, + ` + declare const foo: [Error]; + Promise.reject(foo[0]); + `, + + ` + new Promise(function (resolve, reject) { + resolve(5); + }); + `, + ` + new Promise(function (resolve, reject) { + reject(new Error()); + }); + `, + ` + new Promise((resolve, reject) => { + reject(new Error()); + }); + `, + 'new Promise((resolve, reject) => reject(new Error()));', + { + code: ` + new Promise(function (resolve, reject) { + reject(); + }); + `, + options: [ + { + allowEmptyReject: true, + }, + ], + }, + 'new Promise((yes, no) => no(new Error()));', + 'new Promise();', + 'new Promise(5);', + 'new Promise((resolve, { apply }) => {});', + 'new Promise((resolve, reject) => {});', + 'new Promise((resolve, reject) => reject);', + ` + class CustomError extends Error {} + new Promise(function (resolve, reject) { + reject(new CustomError()); + }); + `, + ` + declare const foo: () => { err: SyntaxError }; + new Promise(function (resolve, reject) { + reject(foo().err); + }); + `, + 'new Promise((resolve, reject) => reject((foo = new Error())));', + ` + new Foo((resolve, reject) => reject(5)); + `, + ` + class Foo { + constructor( + executor: (resolve: () => void, reject: (reason?: any) => void) => void, + ): Promise {} + } + new Foo((resolve, reject) => reject(5)); + `, + ` + new Promise((resolve, reject) => { + return function (reject) { + reject(5); + }; + }); + `, + 'new Promise((resolve, reject) => resolve(5, reject));', + ` + class C { + #error: Error; + foo() { + Promise.reject(this.#error); + } + } + `, + ` + const foo = Promise; + new foo((resolve, reject) => reject(new Error())); + `, + ` + declare const foo: Readonly; + new Promise((resolve, reject) => reject(foo)); + `, + ` + declare const foo: Readonly | Readonly; + new Promise((resolve, reject) => reject(foo)); + `, + ` + declare const foo: Readonly & Readonly; + new Promise((resolve, reject) => reject(foo)); + `, + ` + declare const foo: Readonly & { foo: 'bar' }; + new Promise((resolve, reject) => reject(foo)); + `, + ` + declare const foo: Readonly & { foo: 'bar' }; + new Promise((resolve, reject) => reject(foo)); + `, + ` + declare const foo: Readonly>; + new Promise((resolve, reject) => reject(foo)); + `, + ` + declare const foo: Readonly>>; + new Promise((resolve, reject) => reject(foo)); + `, + ` + declare const foo: Readonly< + Readonly & { foo: 'bar' }> & { + fooBar: 'barFoo'; + } + > & { barFoo: 'fooBar' }; + new Promise((resolve, reject) => reject(foo)); + `, + ` + declare const foo: + | Readonly | Readonly> + | Readonly; + new Promise((resolve, reject) => reject(foo)); + `, + ` + type Wrapper = { foo: Readonly[] }; + declare const foo: Wrapper['foo'][5]; + new Promise((resolve, reject) => reject(foo)); + `, + ` + declare const foo: Error[]; + new Promise((resolve, reject) => reject(foo[5])); + `, + ` + declare const foo: ReadonlyArray; + new Promise((resolve, reject) => reject(foo[5])); + `, + ` + declare const foo: [Error]; + new Promise((resolve, reject) => reject(foo[0])); + `, + ` + class Foo extends Promise {} + Foo.reject(new Error()); + `, + ` + class Foo extends Promise {} + new Foo((resolve, reject) => reject(new Error())); + `, + ` + declare const someRandomCall: { + reject(arg: any): void; + }; + someRandomCall.reject(5); + `, + ` + declare const foo: PromiseConstructor; + foo.reject(new Error()); + `, + ], + invalid: [ + { + code: 'Promise.reject(5);', + errors: [ + { + messageId: 'rejectAnError', + type: AST_NODE_TYPES.CallExpression, + line: 1, + endLine: 1, + column: 1, + endColumn: 18, + }, + ], + }, + { + code: "Promise.reject('foo');", + errors: [ + { + messageId: 'rejectAnError', + type: AST_NODE_TYPES.CallExpression, + line: 1, + endLine: 1, + column: 1, + endColumn: 22, + }, + ], + }, + { + code: 'Promise.reject(`foo`);', + errors: [ + { + messageId: 'rejectAnError', + type: AST_NODE_TYPES.CallExpression, + line: 1, + endLine: 1, + column: 1, + endColumn: 22, + }, + ], + }, + { + code: "Promise.reject('foo', somethingElse);", + errors: [ + { + messageId: 'rejectAnError', + type: AST_NODE_TYPES.CallExpression, + line: 1, + endLine: 1, + column: 1, + endColumn: 37, + }, + ], + }, + { + code: 'Promise.reject(false);', + errors: [ + { + messageId: 'rejectAnError', + type: AST_NODE_TYPES.CallExpression, + line: 1, + endLine: 1, + column: 1, + endColumn: 22, + }, + ], + }, + { + code: 'Promise.reject(void `foo`);', + errors: [ + { + messageId: 'rejectAnError', + type: AST_NODE_TYPES.CallExpression, + line: 1, + endLine: 1, + column: 1, + endColumn: 27, + }, + ], + }, + { + code: 'Promise.reject();', + errors: [ + { + messageId: 'rejectAnError', + type: AST_NODE_TYPES.CallExpression, + line: 1, + endLine: 1, + column: 1, + endColumn: 17, + }, + ], + }, + { + code: 'Promise.reject(undefined);', + errors: [ + { + messageId: 'rejectAnError', + type: AST_NODE_TYPES.CallExpression, + line: 1, + endLine: 1, + column: 1, + endColumn: 26, + }, + ], + }, + { + code: 'Promise.reject(undefined);', + options: [{ allowEmptyReject: true }], + errors: [ + { + messageId: 'rejectAnError', + type: AST_NODE_TYPES.CallExpression, + line: 1, + endLine: 1, + column: 1, + endColumn: 26, + }, + ], + }, + { + code: 'Promise.reject(null);', + errors: [ + { + messageId: 'rejectAnError', + type: AST_NODE_TYPES.CallExpression, + line: 1, + endLine: 1, + column: 1, + endColumn: 21, + }, + ], + }, + { + code: 'Promise.reject({ foo: 1 });', + errors: [ + { + messageId: 'rejectAnError', + type: AST_NODE_TYPES.CallExpression, + line: 1, + endLine: 1, + column: 1, + endColumn: 27, + }, + ], + }, + { + code: 'Promise.reject([1, 2, 3]);', + errors: [ + { + messageId: 'rejectAnError', + type: AST_NODE_TYPES.CallExpression, + line: 1, + endLine: 1, + column: 1, + endColumn: 26, + }, + ], + }, + { + code: ` +declare const foo: Error | undefined; +Promise.reject(foo); + `, + errors: [ + { + messageId: 'rejectAnError', + type: AST_NODE_TYPES.CallExpression, + line: 3, + endLine: 3, + column: 1, + endColumn: 20, + }, + ], + }, + { + code: ` +declare const foo: () => Promise; +Promise.reject(await foo()); + `, + errors: [ + { + messageId: 'rejectAnError', + type: AST_NODE_TYPES.CallExpression, + line: 3, + endLine: 3, + column: 1, + endColumn: 28, + }, + ], + }, + { + code: ` +declare const foo: boolean; +Promise.reject(foo && new Error()); + `, + errors: [ + { + messageId: 'rejectAnError', + type: AST_NODE_TYPES.CallExpression, + line: 3, + endLine: 3, + column: 1, + endColumn: 35, + }, + ], + }, + { + code: ` +const foo = Promise; +foo.reject(); + `, + errors: [ + { + messageId: 'rejectAnError', + type: AST_NODE_TYPES.CallExpression, + line: 3, + endLine: 3, + column: 1, + endColumn: 13, + }, + ], + }, + + { + code: 'Promise.reject?.(5);', + errors: [ + { + messageId: 'rejectAnError', + type: AST_NODE_TYPES.CallExpression, + line: 1, + endLine: 1, + column: 1, + endColumn: 20, + }, + ], + }, + { + code: 'Promise?.reject(5);', + errors: [ + { + messageId: 'rejectAnError', + type: AST_NODE_TYPES.CallExpression, + line: 1, + endLine: 1, + column: 1, + endColumn: 19, + }, + ], + }, + { + code: 'Promise?.reject?.(5);', + errors: [ + { + messageId: 'rejectAnError', + type: AST_NODE_TYPES.CallExpression, + line: 1, + endLine: 1, + column: 1, + endColumn: 21, + }, + ], + }, + { + code: '(Promise?.reject)(5);', + errors: [ + { + messageId: 'rejectAnError', + type: AST_NODE_TYPES.CallExpression, + line: 1, + endLine: 1, + column: 1, + endColumn: 21, + }, + ], + }, + { + code: noFormat`(Promise?.reject)?.(5);`, + errors: [ + { + messageId: 'rejectAnError', + type: AST_NODE_TYPES.CallExpression, + line: 1, + endLine: 1, + column: 1, + endColumn: 23, + }, + ], + }, + { + code: "Promise['reject'](5);", + errors: [ + { + messageId: 'rejectAnError', + type: AST_NODE_TYPES.CallExpression, + line: 1, + endLine: 1, + column: 1, + endColumn: 21, + }, + ], + }, + + // Assignments with mathematical operators will either evaluate to a primitive value or throw a TypeError + { + code: 'Promise.reject((foo += new Error()));', + errors: [ + { + messageId: 'rejectAnError', + type: AST_NODE_TYPES.CallExpression, + line: 1, + endLine: 1, + column: 1, + endColumn: 37, + }, + ], + }, + { + code: 'Promise.reject((foo -= new Error()));', + errors: [ + { + messageId: 'rejectAnError', + type: AST_NODE_TYPES.CallExpression, + line: 1, + endLine: 1, + column: 1, + endColumn: 37, + }, + ], + }, + { + code: 'Promise.reject((foo **= new Error()));', + errors: [ + { + messageId: 'rejectAnError', + type: AST_NODE_TYPES.CallExpression, + line: 1, + endLine: 1, + column: 1, + endColumn: 38, + }, + ], + }, + { + code: 'Promise.reject((foo <<= new Error()));', + errors: [ + { + messageId: 'rejectAnError', + type: AST_NODE_TYPES.CallExpression, + line: 1, + endLine: 1, + column: 1, + endColumn: 38, + }, + ], + }, + { + code: 'Promise.reject((foo |= new Error()));', + errors: [ + { + messageId: 'rejectAnError', + type: AST_NODE_TYPES.CallExpression, + line: 1, + endLine: 1, + column: 1, + endColumn: 37, + }, + ], + }, + { + code: 'Promise.reject((foo &= new Error()));', + errors: [ + { + messageId: 'rejectAnError', + type: AST_NODE_TYPES.CallExpression, + line: 1, + endLine: 1, + column: 1, + endColumn: 37, + }, + ], + }, + { + code: ` +declare const foo: never; +Promise.reject(foo); + `, + errors: [ + { + messageId: 'rejectAnError', + type: AST_NODE_TYPES.CallExpression, + line: 3, + endLine: 3, + column: 1, + endColumn: 20, + }, + ], + }, + { + code: ` +declare const foo: unknown; +Promise.reject(foo); + `, + errors: [ + { + messageId: 'rejectAnError', + type: AST_NODE_TYPES.CallExpression, + line: 3, + endLine: 3, + column: 1, + endColumn: 20, + }, + ], + }, + { + code: ` +type FakeReadonly = { 'fake readonly': T }; +declare const foo: FakeReadonly; +Promise.reject(foo); + `, + errors: [ + { + messageId: 'rejectAnError', + type: AST_NODE_TYPES.CallExpression, + line: 4, + endLine: 4, + column: 1, + endColumn: 20, + }, + ], + }, + { + code: ` +declare const foo: Readonly<'error'>; +Promise.reject(foo); + `, + errors: [ + { + messageId: 'rejectAnError', + type: AST_NODE_TYPES.CallExpression, + line: 3, + endLine: 3, + column: 1, + endColumn: 20, + }, + ], + }, + { + code: ` +declare const foo: Readonly; +Promise.reject(foo); + `, + errors: [ + { + messageId: 'rejectAnError', + type: AST_NODE_TYPES.CallExpression, + line: 3, + endLine: 3, + column: 1, + endColumn: 20, + }, + ], + }, + { + code: ` +declare const foo: Readonly | 'error'; +Promise.reject(foo); + `, + errors: [ + { + messageId: 'rejectAnError', + type: AST_NODE_TYPES.CallExpression, + line: 3, + endLine: 3, + column: 1, + endColumn: 20, + }, + ], + }, + { + code: ` +declare const foo: Readonly | Readonly | Readonly<'error'>; +Promise.reject(foo); + `, + errors: [ + { + messageId: 'rejectAnError', + type: AST_NODE_TYPES.CallExpression, + line: 3, + endLine: 3, + column: 1, + endColumn: 20, + }, + ], + }, + { + code: ` +declare const foo: Readonly>; +Promise.reject(foo); + `, + errors: [ + { + messageId: 'rejectAnError', + type: AST_NODE_TYPES.CallExpression, + line: 3, + endLine: 3, + column: 1, + endColumn: 20, + }, + ], + }, + { + code: ` +declare const foo: Readonly | 'error'>>; +Promise.reject(foo); + `, + errors: [ + { + messageId: 'rejectAnError', + type: AST_NODE_TYPES.CallExpression, + line: 3, + endLine: 3, + column: 1, + endColumn: 20, + }, + ], + }, + { + code: ` +declare const foo: Readonly & TypeError>> | 'error'; +Promise.reject(foo); + `, + errors: [ + { + messageId: 'rejectAnError', + type: AST_NODE_TYPES.CallExpression, + line: 3, + endLine: 3, + column: 1, + endColumn: 20, + }, + ], + }, + { + code: ` +declare const foo: Readonly> | Readonly | 'error'; +Promise.reject(foo); + `, + errors: [ + { + messageId: 'rejectAnError', + type: AST_NODE_TYPES.CallExpression, + line: 3, + endLine: 3, + column: 1, + endColumn: 20, + }, + ], + }, + { + code: ` +type Wrapper = { foo: Readonly[] }; +declare const foo: Wrapper['foo'][5]; +Promise.reject(foo); + `, + errors: [ + { + messageId: 'rejectAnError', + type: AST_NODE_TYPES.CallExpression, + line: 4, + endLine: 4, + column: 1, + endColumn: 20, + }, + ], + }, + { + code: ` +declare const foo: Error[]; +Promise.reject(foo); + `, + errors: [ + { + messageId: 'rejectAnError', + type: AST_NODE_TYPES.CallExpression, + line: 3, + endLine: 3, + column: 1, + endColumn: 20, + }, + ], + }, + { + code: ` +declare const foo: ReadonlyArray; +Promise.reject(foo); + `, + errors: [ + { + messageId: 'rejectAnError', + type: AST_NODE_TYPES.CallExpression, + line: 3, + endLine: 3, + column: 1, + endColumn: 20, + }, + ], + }, + { + code: ` +declare const foo: [Error]; +Promise.reject(foo); + `, + errors: [ + { + messageId: 'rejectAnError', + type: AST_NODE_TYPES.CallExpression, + line: 3, + endLine: 3, + column: 1, + endColumn: 20, + }, + ], + }, + + { + code: ` +new Promise(function (resolve, reject) { + reject(); +}); + `, + errors: [ + { + messageId: 'rejectAnError', + type: AST_NODE_TYPES.CallExpression, + line: 3, + endLine: 3, + column: 3, + endColumn: 11, + }, + ], + }, + { + code: ` +new Promise(function (resolve, reject) { + reject(5); +}); + `, + errors: [ + { + messageId: 'rejectAnError', + type: AST_NODE_TYPES.CallExpression, + line: 3, + endLine: 3, + column: 3, + endColumn: 12, + }, + ], + }, + { + code: ` +new Promise((resolve, reject) => { + reject(); +}); + `, + errors: [ + { + messageId: 'rejectAnError', + type: AST_NODE_TYPES.CallExpression, + line: 3, + endLine: 3, + column: 3, + endColumn: 11, + }, + ], + }, + { + code: 'new Promise((resolve, reject) => reject(5));', + errors: [ + { + messageId: 'rejectAnError', + type: AST_NODE_TYPES.CallExpression, + line: 1, + endLine: 1, + column: 34, + endColumn: 43, + }, + ], + }, + { + code: ` +new Promise((resolve, reject) => { + fs.readFile('foo.txt', (err, file) => { + if (err) reject('File not found'); + else resolve(file); + }); +}); + `, + errors: [ + { + messageId: 'rejectAnError', + type: AST_NODE_TYPES.CallExpression, + line: 4, + endLine: 4, + column: 14, + endColumn: 38, + }, + ], + }, + { + code: 'new Promise((yes, no) => no(5));', + errors: [ + { + messageId: 'rejectAnError', + type: AST_NODE_TYPES.CallExpression, + line: 1, + endLine: 1, + column: 26, + endColumn: 31, + }, + ], + }, + { + code: 'new Promise(({ foo, bar, baz }, reject) => reject(5));', + errors: [ + { + messageId: 'rejectAnError', + type: AST_NODE_TYPES.CallExpression, + line: 1, + endLine: 1, + column: 44, + endColumn: 53, + }, + ], + }, + { + code: ` +new Promise(function (reject, reject) { + reject(5); +}); + `, + errors: [ + { + messageId: 'rejectAnError', + type: AST_NODE_TYPES.CallExpression, + line: 3, + endLine: 3, + column: 3, + endColumn: 12, + }, + ], + }, + { + code: ` +new Promise(function (foo, arguments) { + arguments(5); +}); + `, + errors: [ + { + messageId: 'rejectAnError', + type: AST_NODE_TYPES.CallExpression, + line: 3, + endLine: 3, + column: 3, + endColumn: 15, + }, + ], + }, + { + code: 'new Promise((foo, arguments) => arguments(5));', + errors: [ + { + messageId: 'rejectAnError', + type: AST_NODE_TYPES.CallExpression, + line: 1, + endLine: 1, + column: 33, + endColumn: 45, + }, + ], + }, + { + code: ` +new Promise(function ({}, reject) { + reject(5); +}); + `, + errors: [ + { + messageId: 'rejectAnError', + type: AST_NODE_TYPES.CallExpression, + line: 3, + endLine: 3, + column: 3, + endColumn: 12, + }, + ], + }, + { + code: 'new Promise(({}, reject) => reject(5));', + errors: [ + { + messageId: 'rejectAnError', + type: AST_NODE_TYPES.CallExpression, + line: 1, + endLine: 1, + column: 29, + endColumn: 38, + }, + ], + }, + { + code: 'new Promise((resolve, reject, somethingElse = reject(5)) => {});', + errors: [ + { + messageId: 'rejectAnError', + type: AST_NODE_TYPES.CallExpression, + line: 1, + endLine: 1, + column: 47, + endColumn: 56, + }, + ], + }, + { + code: ` +declare const foo: { + bar: PromiseConstructor; +}; +new foo.bar((resolve, reject) => reject(5)); + `, + errors: [ + { + messageId: 'rejectAnError', + type: AST_NODE_TYPES.CallExpression, + line: 5, + endLine: 5, + column: 34, + endColumn: 43, + }, + ], + }, + { + code: ` +declare const foo: { + bar: PromiseConstructor; +}; +new (foo?.bar)((resolve, reject) => reject(5)); + `, + errors: [ + { + messageId: 'rejectAnError', + type: AST_NODE_TYPES.CallExpression, + line: 5, + endLine: 5, + column: 37, + endColumn: 46, + }, + ], + }, + { + code: ` +const foo = Promise; +new foo((resolve, reject) => reject(5)); + `, + errors: [ + { + messageId: 'rejectAnError', + type: AST_NODE_TYPES.CallExpression, + line: 3, + endLine: 3, + column: 30, + endColumn: 39, + }, + ], + }, + { + code: ` +declare const foo: never; +new Promise((resolve, reject) => reject(foo)); + `, + errors: [ + { + messageId: 'rejectAnError', + type: AST_NODE_TYPES.CallExpression, + line: 3, + endLine: 3, + column: 34, + endColumn: 45, + }, + ], + }, + { + code: ` +declare const foo: unknown; +new Promise((resolve, reject) => reject(foo)); + `, + errors: [ + { + messageId: 'rejectAnError', + type: AST_NODE_TYPES.CallExpression, + line: 3, + endLine: 3, + column: 34, + endColumn: 45, + }, + ], + }, + { + code: ` +type FakeReadonly = { 'fake readonly': T }; +declare const foo: FakeReadonly; +new Promise((resolve, reject) => reject(foo)); + `, + errors: [ + { + messageId: 'rejectAnError', + type: AST_NODE_TYPES.CallExpression, + line: 4, + endLine: 4, + column: 34, + endColumn: 45, + }, + ], + }, + { + code: ` +declare const foo: Readonly<'error'>; +new Promise((resolve, reject) => reject(foo)); + `, + errors: [ + { + messageId: 'rejectAnError', + type: AST_NODE_TYPES.CallExpression, + line: 3, + endLine: 3, + column: 34, + endColumn: 45, + }, + ], + }, + { + code: ` +declare const foo: Readonly; +new Promise((resolve, reject) => reject(foo)); + `, + errors: [ + { + messageId: 'rejectAnError', + type: AST_NODE_TYPES.CallExpression, + line: 3, + endLine: 3, + column: 34, + endColumn: 45, + }, + ], + }, + { + code: ` +declare const foo: Readonly | 'error'; +new Promise((resolve, reject) => reject(foo)); + `, + errors: [ + { + messageId: 'rejectAnError', + type: AST_NODE_TYPES.CallExpression, + line: 3, + endLine: 3, + column: 34, + endColumn: 45, + }, + ], + }, + { + code: ` +declare const foo: Readonly | Readonly | Readonly<'error'>; +new Promise((resolve, reject) => reject(foo)); + `, + errors: [ + { + messageId: 'rejectAnError', + type: AST_NODE_TYPES.CallExpression, + line: 3, + endLine: 3, + column: 34, + endColumn: 45, + }, + ], + }, + { + code: ` +declare const foo: Readonly>; +new Promise((resolve, reject) => reject(foo)); + `, + errors: [ + { + messageId: 'rejectAnError', + type: AST_NODE_TYPES.CallExpression, + line: 3, + endLine: 3, + column: 34, + endColumn: 45, + }, + ], + }, + { + code: ` +declare const foo: Readonly | 'error'>>; +new Promise((resolve, reject) => reject(foo)); + `, + errors: [ + { + messageId: 'rejectAnError', + type: AST_NODE_TYPES.CallExpression, + line: 3, + endLine: 3, + column: 34, + endColumn: 45, + }, + ], + }, + { + code: ` +declare const foo: Readonly & TypeError>> | 'error'; +new Promise((resolve, reject) => reject(foo)); + `, + errors: [ + { + messageId: 'rejectAnError', + type: AST_NODE_TYPES.CallExpression, + line: 3, + endLine: 3, + column: 34, + endColumn: 45, + }, + ], + }, + { + code: ` +declare const foo: Readonly> | Readonly | 'error'; +new Promise((resolve, reject) => reject(foo)); + `, + errors: [ + { + messageId: 'rejectAnError', + type: AST_NODE_TYPES.CallExpression, + line: 3, + endLine: 3, + column: 34, + endColumn: 45, + }, + ], + }, + { + code: ` +type Wrapper = { foo: Readonly[] }; +declare const foo: Wrapper['foo'][5]; +new Promise((resolve, reject) => reject(foo)); + `, + errors: [ + { + messageId: 'rejectAnError', + type: AST_NODE_TYPES.CallExpression, + line: 4, + endLine: 4, + column: 34, + endColumn: 45, + }, + ], + }, + { + code: ` +declare const foo: Error[]; +new Promise((resolve, reject) => reject(foo)); + `, + errors: [ + { + messageId: 'rejectAnError', + type: AST_NODE_TYPES.CallExpression, + line: 3, + endLine: 3, + column: 34, + endColumn: 45, + }, + ], + }, + { + code: ` +declare const foo: ReadonlyArray; +new Promise((resolve, reject) => reject(foo)); + `, + errors: [ + { + messageId: 'rejectAnError', + type: AST_NODE_TYPES.CallExpression, + line: 3, + endLine: 3, + column: 34, + endColumn: 45, + }, + ], + }, + { + code: ` +declare const foo: [Error]; +new Promise((resolve, reject) => reject(foo)); + `, + errors: [ + { + messageId: 'rejectAnError', + type: AST_NODE_TYPES.CallExpression, + line: 3, + endLine: 3, + column: 34, + endColumn: 45, + }, + ], + }, + { + code: ` +class Foo extends Promise {} +Foo.reject(5); + `, + errors: [ + { + messageId: 'rejectAnError', + type: AST_NODE_TYPES.CallExpression, + line: 3, + endLine: 3, + column: 1, + endColumn: 14, + }, + ], + }, + { + code: ` +declare const foo: PromiseConstructor & string; +foo.reject(5); + `, + errors: [ + { + messageId: 'rejectAnError', + type: AST_NODE_TYPES.CallExpression, + line: 3, + endLine: 3, + column: 1, + endColumn: 14, + }, + ], + }, + { + code: ` +class Foo extends Promise {} +class Bar extends Foo {} +Bar.reject(5); + `, + errors: [ + { + messageId: 'rejectAnError', + type: AST_NODE_TYPES.CallExpression, + line: 4, + endLine: 4, + column: 1, + endColumn: 14, + }, + ], + }, + ], +}); diff --git a/packages/eslint-plugin/tests/schema-snapshots/prefer-promise-reject-errors.shot b/packages/eslint-plugin/tests/schema-snapshots/prefer-promise-reject-errors.shot new file mode 100644 index 000000000000..fc04d11fd3f1 --- /dev/null +++ b/packages/eslint-plugin/tests/schema-snapshots/prefer-promise-reject-errors.shot @@ -0,0 +1,28 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Rule schemas should be convertible to TS types for documentation purposes prefer-promise-reject-errors 1`] = ` +" +# SCHEMA: + +[ + { + "additionalProperties": false, + "properties": { + "allowEmptyReject": { + "type": "boolean" + } + }, + "type": "object" + } +] + + +# TYPES: + +type Options = [ + { + allowEmptyReject?: boolean; + }, +]; +" +`; diff --git a/packages/type-utils/src/builtinSymbolLikes.ts b/packages/type-utils/src/builtinSymbolLikes.ts new file mode 100644 index 000000000000..3443a0d0382e --- /dev/null +++ b/packages/type-utils/src/builtinSymbolLikes.ts @@ -0,0 +1,161 @@ +import * as ts from 'typescript'; + +import { isSymbolFromDefaultLibrary } from './isSymbolFromDefaultLibrary'; + +/** + * class Foo extends Promise {} + * Foo.reject + * ^ PromiseLike + */ +export function isPromiseLike(program: ts.Program, type: ts.Type): boolean { + return isBuiltinSymbolLike(program, type, 'Promise'); +} + +/** + * const foo = Promise + * foo.reject + * ^ PromiseConstructorLike + */ +export function isPromiseConstructorLike( + program: ts.Program, + type: ts.Type, +): boolean { + return isBuiltinSymbolLike(program, type, 'PromiseConstructor'); +} + +/** + * class Foo extends Error {} + * new Foo() + * ^ ErrorLike + */ +export function isErrorLike(program: ts.Program, type: ts.Type): boolean { + return isBuiltinSymbolLike(program, type, 'Error'); +} + +/** + * type T = Readonly + * ^ ReadonlyErrorLike + */ +export function isReadonlyErrorLike( + program: ts.Program, + type: ts.Type, +): boolean { + return isReadonlyTypeLike(program, type, subtype => { + const [typeArgument] = subtype.aliasTypeArguments; + return ( + isErrorLike(program, typeArgument) || + isReadonlyErrorLike(program, typeArgument) + ); + }); +} + +/** + * type T = Readonly<{ foo: 'bar' }> + * ^ ReadonlyTypeLike + */ +export function isReadonlyTypeLike( + program: ts.Program, + type: ts.Type, + predicate?: ( + subType: ts.Type & { + aliasSymbol: ts.Symbol; + aliasTypeArguments: readonly ts.Type[]; + }, + ) => boolean, +): boolean { + return isBuiltinTypeAliasLike(program, type, subtype => { + return ( + subtype.aliasSymbol.getName() === 'Readonly' && !!predicate?.(subtype) + ); + }); +} +export function isBuiltinTypeAliasLike( + program: ts.Program, + type: ts.Type, + predicate: ( + subType: ts.Type & { + aliasSymbol: ts.Symbol; + aliasTypeArguments: readonly ts.Type[]; + }, + ) => boolean, +): boolean { + return isBuiltinSymbolLikeRecurser(program, type, subtype => { + const { aliasSymbol, aliasTypeArguments } = subtype; + + if (!aliasSymbol || !aliasTypeArguments) { + return false; + } + + if ( + isSymbolFromDefaultLibrary(program, aliasSymbol) && + predicate( + subtype as ts.Type & { + aliasSymbol: ts.Symbol; + aliasTypeArguments: readonly ts.Type[]; + }, + ) + ) { + return true; + } + + return null; + }); +} + +export function isBuiltinSymbolLike( + program: ts.Program, + type: ts.Type, + symbolName: string, +): boolean { + return isBuiltinSymbolLikeRecurser(program, type, subType => { + const symbol = subType.getSymbol(); + if (!symbol) { + return false; + } + + if ( + symbol.getName() === symbolName && + isSymbolFromDefaultLibrary(program, symbol) + ) { + return true; + } + + return null; + }); +} + +export function isBuiltinSymbolLikeRecurser( + program: ts.Program, + type: ts.Type, + predicate: (subType: ts.Type) => boolean | null, +): boolean { + if (type.isIntersection()) { + return type.types.some(t => + isBuiltinSymbolLikeRecurser(program, t, predicate), + ); + } + if (type.isUnion()) { + return type.types.every(t => + isBuiltinSymbolLikeRecurser(program, t, predicate), + ); + } + + const predicateResult = predicate(type); + if (typeof predicateResult === 'boolean') { + return predicateResult; + } + + const symbol = type.getSymbol(); + if ( + symbol && + symbol.flags & (ts.SymbolFlags.Class | ts.SymbolFlags.Interface) + ) { + const checker = program.getTypeChecker(); + for (const baseType of checker.getBaseTypes(type as ts.InterfaceType)) { + if (isBuiltinSymbolLikeRecurser(program, baseType, predicate)) { + return true; + } + } + } + return false; +} diff --git a/packages/type-utils/src/index.ts b/packages/type-utils/src/index.ts index 9fc499aa8f31..14d5b652099f 100644 --- a/packages/type-utils/src/index.ts +++ b/packages/type-utils/src/index.ts @@ -1,3 +1,4 @@ +export * from './builtinSymbolLikes'; export * from './containsAllTypesByName'; export * from './getConstrainedTypeAtLocation'; export * from './getContextualType'; @@ -6,6 +7,7 @@ export * from './getSourceFileOfNode'; export * from './getTokenAtPosition'; export * from './getTypeArguments'; export * from './getTypeName'; +export * from './isSymbolFromDefaultLibrary'; export * from './isTypeReadonly'; export * from './isUnsafeAssignment'; export * from './predicates'; diff --git a/packages/type-utils/src/isSymbolFromDefaultLibrary.ts b/packages/type-utils/src/isSymbolFromDefaultLibrary.ts new file mode 100644 index 000000000000..786ef849a2c4 --- /dev/null +++ b/packages/type-utils/src/isSymbolFromDefaultLibrary.ts @@ -0,0 +1,20 @@ +import type * as ts from 'typescript'; + +export function isSymbolFromDefaultLibrary( + program: ts.Program, + symbol: ts.Symbol | undefined, +): boolean { + if (!symbol) { + return false; + } + + const declarations = symbol.getDeclarations() ?? []; + for (const declaration of declarations) { + const sourceFile = declaration.getSourceFile(); + if (program.isSourceFileDefaultLibrary(sourceFile)) { + return true; + } + } + + return false; +}