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; +} pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy