-
-
Notifications
You must be signed in to change notification settings - Fork 2.7k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(eslint-plugin): [prefer-promise-reject-errors] add rule (#8011)
* feat(eslint-plugin): [prefer-promise-reject-errors] new rule! * test: ~100% coverage * docs: add rule docs * test: add some cases * chore: lint --fix * chore: reformat tests * feat: add support for literal computed reject name * chore: lint --fix * refactor: get rid of one @ts-expect-error * docs: refer to the original rule description * test: add few cases * docs: remove some examples * refactor: move check if symbol is from default lib or not to new fn * refactor: assert that rejectVariable is non-nullable * chore: remove assertion in skipChainExpression * test: specify error ranges for invalid test cases * chore: format tests * chore: remove unused check if variable reference is read or not * chore: include rule to `strict-type-checked` config * refactor: simplify isSymbolFromDefaultLibrary * chore: remove ts-expect-error comment * feat: add checks for Promise child classes and unions/intersections * Update packages/eslint-plugin/docs/rules/prefer-promise-reject-errors.md Co-authored-by: Josh Goldberg ✨ <git@joshuakgoldberg.com> * refactor: `program` -> `services.program` * refactor: split unreadable if condition * docs: simplify examples * refactor: rename `isBuiltinSymbolLike.ts` -> `builtinSymbolLikes.ts` * perf: get type of `reject` callee lazily * test: add cases with arrays,never,unknown * feat: add support for `Readonly<Error>` and similar * chore: fix lint issues --------- Co-authored-by: Josh Goldberg ✨ <git@joshuakgoldberg.com>
- Loading branch information
1 parent
aa7ab0e
commit 1aa8664
Showing
12 changed files
with
1,861 additions
and
36 deletions.
There are no files selected for viewing
50 changes: 50 additions & 0 deletions
50
packages/eslint-plugin/docs/rules/prefer-promise-reject-errors.md
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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()); | ||
}); | ||
``` |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
153 changes: 153 additions & 0 deletions
153
packages/eslint-plugin/src/rules/prefer-promise-reject-errors.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
}); | ||
}, | ||
}; | ||
}, | ||
}); |
Oops, something went wrong.