diff --git a/packages/eslint-plugin/docs/rules/no-floating-promises.mdx b/packages/eslint-plugin/docs/rules/no-floating-promises.mdx index e3f047e6e4f0..f2cb066ceb2d 100644 --- a/packages/eslint-plugin/docs/rules/no-floating-promises.mdx +++ b/packages/eslint-plugin/docs/rules/no-floating-promises.mdx @@ -84,6 +84,55 @@ await Promise.all([1, 2, 3].map(async x => x + 1)); ## Options +### `checkThenables` + +A ["Thenable"](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise#thenables) value is an object which has a `then` method, such as a `Promise`. +Other Thenables include TypeScript's built-in `PromiseLike` interface and any custom object that happens to have a `.then()`. + +The `checkThenables` option triggers `no-floating-promises` to also consider all values that satisfy the Thenable shape (a `.then()` method that takes two callback parameters), not just Promises. +This can be useful if your code works with older `Promise` polyfills instead of the native `Promise` class. + + + + +```ts option='{"checkThenables": true}' +declare function createPromiseLike(): PromiseLike; + +createPromiseLike(); + +interface MyThenable { + then(onFulfilled: () => void, onRejected: () => void): MyThenable; +} + +declare function createMyThenable(): MyThenable; + +createMyThenable(); +``` + + + + +```ts option='{"checkThenables": true}' +declare function createPromiseLike(): PromiseLike; + +await createPromiseLike(); + +interface MyThenable { + then(onFulfilled: () => void, onRejected: () => void): MyThenable; +} + +declare function createMyThenable(): MyThenable; + +await createMyThenable(); +``` + + + + +:::info +This option is enabled by default in v7 but will be turned off by default in v8. +::: + ### `ignoreVoid` This option, which is `true` by default, allows you to stop the rule reporting promises consumed with void operator. diff --git a/packages/eslint-plugin/src/rules/no-floating-promises.ts b/packages/eslint-plugin/src/rules/no-floating-promises.ts index 1ae5e602ae0e..b49516184317 100644 --- a/packages/eslint-plugin/src/rules/no-floating-promises.ts +++ b/packages/eslint-plugin/src/rules/no-floating-promises.ts @@ -8,6 +8,7 @@ import { createRule, getOperatorPrecedence, getParserServices, + isBuiltinSymbolLike, OperatorPrecedence, readonlynessOptionsDefaults, readonlynessOptionsSchema, @@ -16,9 +17,10 @@ import { type Options = [ { - ignoreVoid?: boolean; - ignoreIIFE?: boolean; allowForKnownSafePromises?: TypeOrValueSpecifier[]; + checkThenables?: boolean; + ignoreIIFE?: boolean; + ignoreVoid?: boolean; }, ]; @@ -75,6 +77,12 @@ export default createRule({ { type: 'object', properties: { + allowForKnownSafePromises: readonlynessOptionsSchema.properties.allow, + checkThenables: { + description: + 'Whether to check all "Thenable"s, not just the built-in Promise type.', + type: 'boolean', + }, ignoreVoid: { description: 'Whether to ignore `void` expressions.', type: 'boolean', @@ -84,7 +92,6 @@ export default createRule({ 'Whether to ignore async IIFEs (Immediately Invoked Function Expressions).', type: 'boolean', }, - allowForKnownSafePromises: readonlynessOptionsSchema.properties.allow, }, additionalProperties: false, }, @@ -93,15 +100,18 @@ export default createRule({ }, defaultOptions: [ { + allowForKnownSafePromises: readonlynessOptionsDefaults.allow, + checkThenables: true, ignoreVoid: true, ignoreIIFE: false, - allowForKnownSafePromises: readonlynessOptionsDefaults.allow, }, ], create(context, [options]) { const services = getParserServices(context); const checker = services.program.getTypeChecker(); + const { checkThenables } = options; + // TODO: #5439 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const allowForKnownSafePromises = options.allowForKnownSafePromises!; @@ -356,14 +366,10 @@ export default createRule({ 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(node: ts.Node, type?: ts.Type): boolean { type ??= checker.getTypeAtLocation(node); - // Ignore anything specified by `allowForKnownSafePromises` option. + // The highest priority is to allow anything allowlisted if ( allowForKnownSafePromises.some(allowedType => typeMatchesSpecifier(type, allowedType, services.program), @@ -372,7 +378,26 @@ export default createRule({ return false; } - for (const ty of tsutils.unionTypeParts(checker.getApparentType(type))) { + // Otherwise, we always consider the built-in Promise to be Promise-like... + const typeParts = tsutils.unionTypeParts(checker.getApparentType(type)); + if ( + typeParts.some(typePart => + isBuiltinSymbolLike(services.program, typePart, 'Promise'), + ) + ) { + return true; + } + + // ...and only check all Thenables if explicitly told to + if (!checkThenables) { + 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 + for (const ty of typeParts) { const then = ty.getProperty('then'); if (then === undefined) { continue; diff --git a/packages/eslint-plugin/tests/docs-eslint-output-snapshots/no-floating-promises.shot b/packages/eslint-plugin/tests/docs-eslint-output-snapshots/no-floating-promises.shot index 44e303fc3131..5dafc5366a02 100644 --- a/packages/eslint-plugin/tests/docs-eslint-output-snapshots/no-floating-promises.shot +++ b/packages/eslint-plugin/tests/docs-eslint-output-snapshots/no-floating-promises.shot @@ -50,6 +50,44 @@ await Promise.all([1, 2, 3].map(async x => x + 1)); `; exports[`Validating rule docs no-floating-promises.mdx code examples ESLint output 3`] = ` +"Incorrect +Options: {"checkThenables": true} + +declare function createPromiseLike(): PromiseLike; + +createPromiseLike(); +~~~~~~~~~~~~~~~~~~~~ Promises must be awaited, end with a call to .catch, end with a call to .then with a rejection handler or be explicitly marked as ignored with the \`void\` operator. + +interface MyThenable { + then(onFulfilled: () => void, onRejected: () => void): MyThenable; +} + +declare function createMyThenable(): MyThenable; + +createMyThenable(); +~~~~~~~~~~~~~~~~~~~ Promises must be awaited, end with a call to .catch, end with a call to .then with a rejection handler or be explicitly marked as ignored with the \`void\` operator. +" +`; + +exports[`Validating rule docs no-floating-promises.mdx code examples ESLint output 4`] = ` +"Correct +Options: {"checkThenables": true} + +declare function createPromiseLike(): PromiseLike; + +await createPromiseLike(); + +interface MyThenable { + then(onFulfilled: () => void, onRejected: () => void): MyThenable; +} + +declare function createMyThenable(): MyThenable; + +await createMyThenable(); +" +`; + +exports[`Validating rule docs no-floating-promises.mdx code examples ESLint output 5`] = ` "Options: { "ignoreVoid": true } async function returnsPromise() { @@ -61,7 +99,7 @@ void Promise.reject('value'); " `; -exports[`Validating rule docs no-floating-promises.mdx code examples ESLint output 4`] = ` +exports[`Validating rule docs no-floating-promises.mdx code examples ESLint output 6`] = ` "Options: { "ignoreIIFE": true } await (async function () { @@ -74,7 +112,7 @@ await (async function () { " `; -exports[`Validating rule docs no-floating-promises.mdx code examples ESLint output 5`] = ` +exports[`Validating rule docs no-floating-promises.mdx code examples ESLint output 7`] = ` "Incorrect Options: {"allowForKnownSafePromises":[{"from":"file","name":"SafePromise"},{"from":"lib","name":"PromiseLike"},{"from":"package","name":"Bar","package":"bar-lib"}]} @@ -91,7 +129,7 @@ returnsPromise(); " `; -exports[`Validating rule docs no-floating-promises.mdx code examples ESLint output 6`] = ` +exports[`Validating rule docs no-floating-promises.mdx code examples ESLint output 8`] = ` "Correct Options: {"allowForKnownSafePromises":[{"from":"file","name":"SafePromise"},{"from":"lib","name":"PromiseLike"},{"from":"package","name":"Bar","package":"bar-lib"}]} diff --git a/packages/eslint-plugin/tests/rules/no-floating-promises.test.ts b/packages/eslint-plugin/tests/rules/no-floating-promises.test.ts index e6087e512265..7077b27bc39a 100644 --- a/packages/eslint-plugin/tests/rules/no-floating-promises.test.ts +++ b/packages/eslint-plugin/tests/rules/no-floating-promises.test.ts @@ -733,6 +733,41 @@ promise().then(() => {}); }, ], }, + { + code: ` +interface SafePromise extends Promise { + brand: 'safe'; +} + +declare const createSafePromise: () => SafePromise; +createSafePromise(); + `, + options: [ + { + allowForKnownSafePromises: [{ from: 'file', name: 'SafePromise' }], + checkThenables: true, + }, + ], + }, + { + code: ` +declare const createPromise: () => PromiseLike; +createPromise(); + `, + options: [{ checkThenables: false }], + }, + { + code: ` +interface MyThenable { + then(onFulfilled: () => void, onRejected: () => void): MyThenable; +} + +declare function createMyThenable(): MyThenable; + +createMyThenable(); + `, + options: [{ checkThenables: false }], + }, ], invalid: [ @@ -2181,5 +2216,54 @@ myTag\`abc\`; options: [{ allowForKnownSafePromises: [{ from: 'file', name: 'Foo' }] }], errors: [{ line: 4, messageId: 'floatingVoid' }], }, + { + code: ` +declare const createPromise: () => PromiseLike; +createPromise(); + `, + errors: [{ line: 3, messageId: 'floatingVoid' }], + options: [{ checkThenables: true }], + }, + { + code: ` +interface MyThenable { + then(onFulfilled: () => void, onRejected: () => void): MyThenable; +} + +declare function createMyThenable(): MyThenable; + +createMyThenable(); + `, + errors: [{ line: 8, messageId: 'floatingVoid' }], + options: [{ checkThenables: true }], + }, + { + code: ` +declare const createPromise: () => Promise; +createPromise(); + `, + errors: [{ line: 3, messageId: 'floatingVoid' }], + options: [{ checkThenables: false }], + }, + { + code: ` +class MyPromise extends Promise {} +declare const createMyPromise: () => MyPromise; +createMyPromise(); + `, + errors: [{ line: 4, messageId: 'floatingVoid' }], + options: [{ checkThenables: false }], + }, + { + code: ` +class MyPromise extends Promise { + additional: string; +} +declare const createMyPromise: () => MyPromise; +createMyPromise(); + `, + errors: [{ line: 6, messageId: 'floatingVoid' }], + options: [{ checkThenables: false }], + }, ], }); diff --git a/packages/eslint-plugin/tests/schema-snapshots/no-floating-promises.shot b/packages/eslint-plugin/tests/schema-snapshots/no-floating-promises.shot index a708c7001d5b..fc91d05a7b53 100644 --- a/packages/eslint-plugin/tests/schema-snapshots/no-floating-promises.shot +++ b/packages/eslint-plugin/tests/schema-snapshots/no-floating-promises.shot @@ -102,6 +102,10 @@ exports[`Rule schemas should be convertible to TS types for documentation purpos }, "type": "array" }, + "checkThenables": { + "description": "Whether to check all \\"Thenable\\"s, not just the built-in Promise type.", + "type": "boolean" + }, "ignoreIIFE": { "description": "Whether to ignore async IIFEs (Immediately Invoked Function Expressions).", "type": "boolean" @@ -137,6 +141,8 @@ type Options = [ } | string )[]; + /** Whether to check all "Thenable"s, not just the built-in Promise type. */ + checkThenables?: boolean; /** Whether to ignore async IIFEs (Immediately Invoked Function Expressions). */ ignoreIIFE?: boolean; /** Whether to ignore \`void\` expressions. */