diff --git a/packages/eslint-plugin/README.md b/packages/eslint-plugin/README.md index 87e8cfd72ee1..ff6a11b3726e 100644 --- a/packages/eslint-plugin/README.md +++ b/packages/eslint-plugin/README.md @@ -146,6 +146,7 @@ Then you should add `airbnb` (or `airbnb-base`) to your `extends` section of `.e | [`@typescript-eslint/no-explicit-any`](./docs/rules/no-explicit-any.md) | Disallow usage of the `any` type | :heavy_check_mark: | | | | [`@typescript-eslint/no-extra-parens`](./docs/rules/no-extra-parens.md) | Disallow unnecessary parentheses | | :wrench: | | | [`@typescript-eslint/no-extraneous-class`](./docs/rules/no-extraneous-class.md) | Forbids the use of classes as namespaces | | | | +| [`@typescript-eslint/no-floating-promises`](./docs/rules/no-floating-promises.md) | Requires Promise-like values to be handled appropriately. | | | :thought_balloon: | | [`@typescript-eslint/no-for-in-array`](./docs/rules/no-for-in-array.md) | Disallow iterating over an array with a for-in loop | | | :thought_balloon: | | [`@typescript-eslint/no-inferrable-types`](./docs/rules/no-inferrable-types.md) | Disallows explicit type declarations for variables or parameters initialized to a number, string, or boolean | :heavy_check_mark: | :wrench: | | | [`@typescript-eslint/no-magic-numbers`](./docs/rules/no-magic-numbers.md) | Disallows magic numbers | | | | diff --git a/packages/eslint-plugin/ROADMAP.md b/packages/eslint-plugin/ROADMAP.md index b2567afe070e..f0171f73b090 100644 --- a/packages/eslint-plugin/ROADMAP.md +++ b/packages/eslint-plugin/ROADMAP.md @@ -1,4 +1,4 @@ -# Roadmap +# Roadmap ✅ = done
🌟 = in ESLint core
@@ -60,7 +60,7 @@ | [`no-dynamic-delete`] | 🛑 | N/A | | [`no-empty`] | 🌟 | [`no-empty`][no-empty] | | [`no-eval`] | 🌟 | [`no-eval`][no-eval] | -| [`no-floating-promises`] | 🛑 | N/A ([relevant plugin][plugin:promise]) | +| [`no-floating-promises`] | ✅ | [`@typescript-eslint/no-floating-promises`] | | [`no-for-in-array`] | ✅ | [`@typescript-eslint/no-for-in-array`] | | [`no-implicit-dependencies`] | 🔌 | [`import/no-extraneous-dependencies`] | | [`no-inferred-empty-object-type`] | 🛑 | N/A | @@ -612,6 +612,7 @@ Relevant plugins: [`chai-expect-keywords`](https://github.com/gavinaiken/eslint- [`@typescript-eslint/no-for-in-array`]: https://github.com/typescript-eslint/typescript-eslint/blob/master/packages/eslint-plugin/docs/rules/no-for-in-array.md [`@typescript-eslint/no-unnecessary-qualifier`]: https://github.com/typescript-eslint/typescript-eslint/blob/master/packages/eslint-plugin/docs/rules/no-unnecessary-qualifier.md [`@typescript-eslint/semi`]: https://github.com/typescript-eslint/typescript-eslint/blob/master/packages/eslint-plugin/docs/rules/semi.md +[`@typescript-eslint/no-floating-promises`]: https://github.com/typescript-eslint/typescript-eslint/blob/master/packages/eslint-plugin/docs/rules/no-floating-promises.md diff --git a/packages/eslint-plugin/docs/rules/no-floating-promises.md b/packages/eslint-plugin/docs/rules/no-floating-promises.md new file mode 100644 index 000000000000..75bc49efa66d --- /dev/null +++ b/packages/eslint-plugin/docs/rules/no-floating-promises.md @@ -0,0 +1,46 @@ +# Requires Promise-like values to be handled appropriately (no-floating-promises) + +This rule forbids usage of Promise-like values in statements without handling +their errors appropriately. Unhandled promises can cause several issues, such +as improperly sequenced operations, ignored Promise rejections and more. Valid +ways of handling a Promise-valued statement include `await`ing, returning, and +either calling `.then()` with two arguments or `.catch()` with one argument. + +## Rule Details + +Examples of **incorrect** code for this rule: + +```ts +const promise = new Promise((resolve, reject) => resolve('value')); +promise; + +async function returnsPromise() { + return 'value'; +} +returnsPromise().then(() => {}); + +Promise.reject('value').catch(); +``` + +Examples of **correct** code for this rule: + +```ts +const promise = new Promise((resolve, reject) => resolve('value')); +await promise; + +async function returnsPromise() { + return 'value'; +} +returnsPromise().then(() => {}, () => {}); + +Promise.reject('value').catch(() => {}); +``` + +## When Not To Use It + +If you do not use Promise-like values in your codebase or want to allow them to +remain unhandled. + +## Related to + +- Tslint: ['no-floating-promises'](https://palantir.github.io/tslint/rules/no-floating-promises/) diff --git a/packages/eslint-plugin/src/rules/index.ts b/packages/eslint-plugin/src/rules/index.ts index d62f58d6af3f..c92f6a36d8b9 100644 --- a/packages/eslint-plugin/src/rules/index.ts +++ b/packages/eslint-plugin/src/rules/index.ts @@ -20,6 +20,7 @@ import noEmptyInterface from './no-empty-interface'; import noExplicitAny from './no-explicit-any'; import noExtraParens from './no-extra-parens'; import noExtraneousClass from './no-extraneous-class'; +import noFloatingPromises from './no-floating-promises'; import noForInArray from './no-for-in-array'; import noInferrableTypes from './no-inferrable-types'; import noMagicNumbers from './no-magic-numbers'; @@ -76,6 +77,7 @@ export default { 'no-explicit-any': noExplicitAny, 'no-extra-parens': noExtraParens, 'no-extraneous-class': noExtraneousClass, + 'no-floating-promises': noFloatingPromises, 'no-for-in-array': noForInArray, 'no-inferrable-types': noInferrableTypes, 'no-magic-numbers': noMagicNumbers, diff --git a/packages/eslint-plugin/src/rules/no-floating-promises.ts b/packages/eslint-plugin/src/rules/no-floating-promises.ts new file mode 100644 index 000000000000..1c8cf412c070 --- /dev/null +++ b/packages/eslint-plugin/src/rules/no-floating-promises.ts @@ -0,0 +1,171 @@ +import * as tsutils from 'tsutils'; +import * as ts from 'typescript'; + +import * as util from '../util'; + +export default util.createRule({ + name: 'no-floating-promises', + meta: { + docs: { + description: 'Requires Promise-like values to be handled appropriately.', + category: 'Best Practices', + recommended: false, + }, + messages: { + floating: 'Promises must be handled appropriately', + }, + schema: [], + type: 'problem', + }, + defaultOptions: [], + + create(context) { + const parserServices = util.getParserServices(context); + const checker = parserServices.program.getTypeChecker(); + + return { + ExpressionStatement(node) { + const { expression } = parserServices.esTreeNodeToTSNodeMap.get( + node, + ) as ts.ExpressionStatement; + + if (isUnhandledPromise(checker, expression)) { + context.report({ + messageId: 'floating', + node, + }); + } + }, + }; + }, +}); + +function isUnhandledPromise(checker: ts.TypeChecker, node: ts.Node): boolean { + // First, check expressions whose resulting types may not be promise-like + if ( + ts.isBinaryExpression(node) && + node.operatorToken.kind === ts.SyntaxKind.CommaToken + ) { + // Any child in a comma expression could return a potentially unhandled + // promise, so we check them all regardless of whether the final returned + // value is promise-like. + return ( + isUnhandledPromise(checker, node.left) || + isUnhandledPromise(checker, node.right) + ); + } else if (ts.isVoidExpression(node)) { + // Similarly, a `void` expression always returns undefined, so we need to + // see what's inside it without checking the type of the overall expression. + return isUnhandledPromise(checker, node.expression); + } + + // Check the type. At this point it can't be unhandled if it isn't a promise + if (!isPromiseLike(checker, node)) { + return false; + } + + if (ts.isCallExpression(node)) { + // If the outer expression is a call, it must be either a `.then()` or + // `.catch()` that handles the promise. + return ( + !isPromiseCatchCallWithHandler(node) && + !isPromiseThenCallWithRejectionHandler(node) + ); + } else if (ts.isConditionalExpression(node)) { + // We must be getting the promise-like value from one of the branches of the + // ternary. Check them directly. + return ( + isUnhandledPromise(checker, node.whenFalse) || + isUnhandledPromise(checker, node.whenTrue) + ); + } else if ( + ts.isPropertyAccessExpression(node) || + ts.isIdentifier(node) || + ts.isNewExpression(node) + ) { + // If it is just a property access chain or a `new` call (e.g. `foo.bar` or + // `new Promise()`), the promise is not handled because it doesn't have the + // necessary then/catch call at the end of the chain. + return true; + } + + // We conservatively return false for all other types of expressions because + // we don't want to accidentally fail if the promise is handled internally but + // we just can't tell. + return false; +} + +// Modified from tsutils.isThenable() to only consider thenables which can be +// rejected/caught via a second parameter. Original source (MIT licensed): +// +// https://github.com/ajafff/tsutils/blob/49d0d31050b44b81e918eae4fbaf1dfe7b7286af/util/type.ts#L95-L125 +function isPromiseLike(checker: ts.TypeChecker, node: ts.Node): boolean { + const type = checker.getTypeAtLocation(node); + for (const ty of tsutils.unionTypeParts(checker.getApparentType(type))) { + const then = ty.getProperty('then'); + if (then === undefined) { + continue; + } + + const thenType = checker.getTypeOfSymbolAtLocation(then, node); + if ( + hasMatchingSignature( + thenType, + signature => + signature.parameters.length >= 2 && + isFunctionParam(checker, signature.parameters[0], node) && + isFunctionParam(checker, signature.parameters[1], node), + ) + ) { + return true; + } + } + return false; +} + +function hasMatchingSignature( + type: ts.Type, + matcher: (signature: ts.Signature) => boolean, +): boolean { + for (const t of tsutils.unionTypeParts(type)) { + if (t.getCallSignatures().some(matcher)) { + return true; + } + } + + return false; +} + +function isFunctionParam( + checker: ts.TypeChecker, + param: ts.Symbol, + node: ts.Node, +): boolean { + const type: ts.Type | undefined = checker.getApparentType( + checker.getTypeOfSymbolAtLocation(param, node), + ); + for (const t of tsutils.unionTypeParts(type)) { + if (t.getCallSignatures().length !== 0) { + return true; + } + } + return false; +} + +function isPromiseCatchCallWithHandler(expression: ts.CallExpression): boolean { + return ( + tsutils.isPropertyAccessExpression(expression.expression) && + expression.expression.name.text === 'catch' && + expression.arguments.length >= 1 + ); +} + +function isPromiseThenCallWithRejectionHandler( + expression: ts.CallExpression, +): boolean { + return ( + tsutils.isPropertyAccessExpression(expression.expression) && + expression.expression.name.text === 'then' && + expression.arguments.length >= 2 + ); +} diff --git a/packages/eslint-plugin/tests/rules/no-floating-promises.test.ts b/packages/eslint-plugin/tests/rules/no-floating-promises.test.ts new file mode 100644 index 000000000000..63a360715d91 --- /dev/null +++ b/packages/eslint-plugin/tests/rules/no-floating-promises.test.ts @@ -0,0 +1,547 @@ +import rule from '../../src/rules/no-floating-promises'; +import { RuleTester, getFixturesRootDir } from '../RuleTester'; + +const rootDir = getFixturesRootDir(); +const messageId = 'floating'; + +const ruleTester = new RuleTester({ + parserOptions: { + ecmaVersion: 2018, + tsconfigRootDir: rootDir, + project: './tsconfig.json', + }, + parser: '@typescript-eslint/parser', +}); + +ruleTester.run('no-floating-promises', rule, { + valid: [ + ` +async function test() { + await Promise.resolve("value"); + Promise.resolve("value").then(() => {}, () => {}); + Promise.resolve("value").then(() => {}).catch(() => {}); + Promise.resolve("value").catch(() => {}); + return Promise.resolve("value"); +} +`, + ` +async function test() { + await Promise.reject(new Error("message")); + Promise.reject(new Error("message")).then(() => {}, () => {}); + Promise.reject(new Error("message")).then(() => {}).catch(() => {}); + Promise.reject(new Error("message")).catch(() => {}); + return Promise.reject(new Error("message")); +} +`, + ` +async function test() { + await (async () => true)(); + (async () => true)().then(() => {}, () => {}); + (async () => true)().then(() => {}).catch(() => {}); + (async () => true)().catch(() => {}); + return (async () => true)(); +} +`, + ` +async function test() { + async function returnsPromise() {} + await returnsPromise(); + returnsPromise().then(() => {}, () => {}); + returnsPromise().then(() => {}).catch(() => {}); + returnsPromise().catch(() => {}); + return returnsPromise(); +} +`, + ` +async function test() { + const x = Promise.resolve(); + const y = x.then(() => {}); + y.catch(() => {}); +} +`, + ` +async function test() { + Math.random() > 0.5 ? Promise.resolve().catch(() => {}) : null; +} +`, + ` +async function test() { + Promise.resolve().catch(() => {}), 123; + 123, Promise.resolve().then(() => {}, () => {}); + 123, Promise.resolve().then(() => {}, () => {}), 123; +} +`, + ` +async function test() { + void Promise.resolve().catch(() => {}); +} +`, + ` +async function test() { + Promise.resolve().catch(() => {}) || Promise.resolve().then(() => {}, () => {}); +} +`, + ` +async function test() { + const promiseValue: Promise; + + await promiseValue; + promiseValue.then(() => {}, () => {}); + promiseValue.then(() => {}).catch(() => {}); + promiseValue.catch(() => {}); + return promiseValue; +} +`, + ` +async function test() { + const promiseUnion: Promise | number; + + await promiseUnion; + promiseUnion.then(() => {}, () => {}); + promiseUnion.then(() => {}).catch(() => {}); + promiseUnion.catch(() => {}); + return promiseUnion; +} +`, + ` +async function test() { + const promiseIntersection: Promise & number; + + await promiseIntersection; + promiseIntersection.then(() => {}, () => {}); + promiseIntersection.then(() => {}).catch(() => {}); + promiseIntersection.catch(() => {}); + return promiseIntersection; +} +`, + ` +async function test() { + class CanThen extends Promise {} + const canThen: CanThen = Foo.resolve(2); + + await canThen; + canThen.then(() => {}, () => {}); + canThen.then(() => {}).catch(() => {}); + canThen.catch(() => {}); + return canThen; +} +`, + ` +async function test() { + await (Math.random() > 0.5 ? numberPromise : 0); + await (Math.random() > 0.5 ? foo : 0); + await (Math.random() > 0.5 ? bar : 0); + + const intersectionPromise: Promise & number; + await intersectionPromise; +} +`, + ` +async function test() { + class Thenable { + then(callback: () => {}): Thenable { return new Thenable(); } + }; + const thenable = new Thenable(); + + await thenable; + thenable; + thenable.then(() => {}); + return thenable; +} +`, + ` +async function test() { + class NonFunctionParamThenable { + then(param: string, param2: number): NonFunctionParamThenable { + return new NonFunctionParamThenable(); + } + }; + const thenable = new NonFunctionParamThenable(); + + await thenable; + thenable; + thenable.then('abc', 'def'); + return thenable; +} +`, + ` +async function test() { + class NonFunctionThenable { then: number }; + const thenable = new NonFunctionThenable(); + + thenable; + thenable.then; + return thenable; +} +`, + ` +async function test() { + class CatchableThenable { + then(callback: () => {}, callback: () => {}): CatchableThenable { + return new CatchableThenable(); + } + }; + const thenable = new CatchableThenable(); + + await thenable + return thenable; +} +`, + ` +// https://github.com/DefinitelyTyped/DefinitelyTyped/blob/master/types/promise-polyfill/index.d.ts +// Type definitions for promise-polyfill 6.0 +// Project: https://github.com/taylorhakes/promise-polyfill +// Definitions by: Steve Jenkins +// Daniel Cassidy +// Definitions: https://github.com/DefinitelyTyped/DefinitelyTyped + +interface PromisePolyfillConstructor extends PromiseConstructor { + _immediateFn?: (handler: (() => void) | string) => void; +} + +declare const PromisePolyfill: PromisePolyfillConstructor; + +async function test() { + const promise = new PromisePolyfill(() => {}); + + await promise; + promise.then(() => {}, () => {}); + promise.then(() => {}).catch(() => {}); + promise.catch(() => {}); + return promise; +} +`, + ], + + invalid: [ + { + code: ` +async function test() { + Promise.resolve("value"); + Promise.resolve("value").then(() => {}); + Promise.resolve("value").catch(); +} +`, + errors: [ + { + line: 3, + messageId, + }, + { + line: 4, + messageId, + }, + { + line: 5, + messageId, + }, + ], + }, + { + code: ` +async function test() { + Promise.reject(new Error("message")); + Promise.reject(new Error("message")).then(() => {}); + Promise.reject(new Error("message")).catch(); +} +`, + errors: [ + { + line: 3, + messageId, + }, + { + line: 4, + messageId, + }, + { + line: 5, + messageId, + }, + ], + }, + { + code: ` +async function test() { + (async () => true)(); + (async () => true)().then(() => {}); + (async () => true)().catch(); +} +`, + errors: [ + { + line: 3, + messageId, + }, + { + line: 4, + messageId, + }, + { + line: 5, + messageId, + }, + ], + }, + { + code: ` +async function test() { + async function returnsPromise() {} + + returnsPromise(); + returnsPromise().then(() => {}); + returnsPromise().catch(); +} +`, + errors: [ + { + line: 5, + messageId, + }, + { + line: 6, + messageId, + }, + { + line: 7, + messageId, + }, + ], + }, + { + code: ` +async function test() { + Math.random() > 0.5 ? Promise.resolve() : null; + Math.random() > 0.5 ? null : Promise.resolve(); +} +`, + errors: [ + { + line: 3, + messageId, + }, + { + line: 4, + messageId, + }, + ], + }, + { + code: ` +async function test() { + Promise.resolve(), 123 + 123, Promise.resolve() + 123, Promise.resolve(), 123 +} +`, + errors: [ + { + line: 3, + messageId, + }, + { + line: 4, + messageId, + }, + { + line: 5, + messageId, + }, + ], + }, + { + code: ` +async function test() { + void Promise.resolve(); +} +`, + errors: [ + { + line: 3, + messageId, + }, + ], + }, + { + code: ` +async function test() { + const obj = { foo: Promise.resolve() }; + obj.foo; +} +`, + errors: [ + { + line: 4, + messageId, + }, + ], + }, + { + code: ` +async function test() { + new Promise(resolve => resolve()); +} +`, + errors: [ + { + line: 3, + messageId, + }, + ], + }, + { + code: ` +async function test() { + const promiseValue: Promise; + + promiseValue; + promiseValue.then(() => {}); + promiseValue.catch(); +} +`, + errors: [ + { + line: 5, + messageId, + }, + { + line: 6, + messageId, + }, + { + line: 7, + messageId, + }, + ], + }, + { + code: ` +async function test() { + const promiseUnion: Promise | number; + + promiseUnion; +} +`, + errors: [ + { + line: 5, + messageId, + }, + ], + }, + { + code: ` +async function test() { + const promiseIntersection: Promise & number; + + promiseIntersection; + promiseIntersection.then(() => {}) + promiseIntersection.catch(); +} +`, + errors: [ + { + line: 5, + messageId, + }, + { + line: 6, + messageId, + }, + { + line: 7, + messageId, + }, + ], + }, + { + code: ` +async function test() { + class CanThen extends Promise {} + const canThen: CanThen = Foo.resolve(2); + + canThen; + canThen.then(() => {}); + canThen.catch(); +} +`, + errors: [ + { + line: 6, + messageId, + }, + { + line: 7, + messageId, + }, + { + line: 8, + messageId, + }, + ], + }, + { + code: ` +async function test() { + class CatchableThenable { + then(callback: () => {}, callback: () => {}): CatchableThenable { + return new CatchableThenable(); + } + }; + const thenable = new CatchableThenable(); + + thenable; + thenable.then(() => {}); +} +`, + errors: [ + { + line: 10, + messageId, + }, + { + line: 11, + messageId, + }, + ], + }, + { + code: ` +// https://github.com/DefinitelyTyped/DefinitelyTyped/blob/master/types/promise-polyfill/index.d.ts +// Type definitions for promise-polyfill 6.0 +// Project: https://github.com/taylorhakes/promise-polyfill +// Definitions by: Steve Jenkins +// Daniel Cassidy +// Definitions: https://github.com/DefinitelyTyped/DefinitelyTyped + +interface PromisePolyfillConstructor extends PromiseConstructor { + _immediateFn?: (handler: (() => void) | string) => void; +} + +declare const PromisePolyfill: PromisePolyfillConstructor; + +async function test() { + const promise = new PromisePolyfill(() => {}); + + promise; + promise.then(() => {}); + promise.catch(); +} +`, + errors: [ + { + line: 18, + messageId, + }, + { + line: 19, + messageId, + }, + { + line: 20, + messageId, + }, + ], + }, + ], +}); 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