Skip to content

Commit 04990d5

Browse files
kirkwaiblingerarkapratimcJoshuaKGoldberg
authoredJun 3, 2024
feat(eslint-plugin): [no-floating-promises] add option 'allowForKnownSafePromises' (#9186)
* feat: [no-floating-promises] add an 'allowForKnownSafePromises' option fixes: #7008 * chore: snapshot errors * chore: add a test * chore: add another test * chore: rewrite * chore: docs & test * chore: add jsdoc comment * chore: cspell * chore: lint * chore: address reviews * chore: lint errors * chore: remove invalid js/ts code * chore: add thenables * chore: fix failures * chore: re-write tests * chore: more tests * chore: fix schemas * chore: typo * fix: docs * chore: nits * snapshots * chore: address reviews * chore: reduce tests * chore: line numbers * chore: docs * chore: typos * chore: address reviews * fix: condition issues * chore: update docs Co-authored-by: Josh Goldberg ✨ <[email protected]> * chore: tag function * chore: address reviews * chore: revert var name * chore: address reviews * chore: lint & tests * chore: comments * new logic * make use of closure * rename variable * wording * whoops, copypasta mistake * remove test comment * simplify comments * remove bare string specifier * docs --------- Co-authored-by: arka1002 <[email protected]> Co-authored-by: Arka Pratim Chaudhuri <[email protected]> Co-authored-by: Josh Goldberg ✨ <[email protected]>
1 parent d4504c8 commit 04990d5

File tree

8 files changed

+612
-62
lines changed

8 files changed

+612
-62
lines changed
 

‎packages/eslint-plugin/docs/rules/no-floating-promises.mdx

+57
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,63 @@ await (async function () {
119119
})();
120120
```
121121

122+
### `allowForKnownSafePromises`
123+
124+
This option allows marking specific types as "safe" to be floating. For example, you may need to do this in the case of libraries whose APIs return Promises whose rejections are safely handled by the library.
125+
126+
This option takes an array of type specifiers to consider safe.
127+
Each item in the array must have one of the following forms:
128+
129+
- A type defined in a file (`{ from: "file", name: "Foo", path: "src/foo-file.ts" }` with `path` being an optional path relative to the project root directory)
130+
- A type from the default library (`{ from: "lib", name: "PromiseLike" }`)
131+
- A type from a package (`{ from: "package", name: "Foo", package: "foo-lib" }`, this also works for types defined in a typings package).
132+
133+
Examples of code for this rule with:
134+
135+
```json
136+
{
137+
"allowForKnownSafePromises": [
138+
{ "from": "file", "name": "SafePromise" },
139+
{ "from": "lib", "name": "PromiseLike" },
140+
{ "from": "package", "name": "Bar", "package": "bar-lib" }
141+
]
142+
}
143+
```
144+
145+
<Tabs>
146+
<TabItem value="❌ Incorrect">
147+
148+
```ts option='{"allowForKnownSafePromises":[{"from":"file","name":"SafePromise"},{"from":"lib","name":"PromiseLike"},{"from":"package","name":"Bar","package":"bar-lib"}]}'
149+
let promise: Promise<number> = Promise.resolve(2);
150+
promise;
151+
152+
function returnsPromise(): Promise<number> {
153+
return Promise.resolve(42);
154+
}
155+
156+
returnsPromise();
157+
```
158+
159+
</TabItem>
160+
<TabItem value="✅ Correct">
161+
162+
```ts option='{"allowForKnownSafePromises":[{"from":"file","name":"SafePromise"},{"from":"lib","name":"PromiseLike"},{"from":"package","name":"Bar","package":"bar-lib"}]}'
163+
// promises can be marked as safe by using branded types
164+
type SafePromise = Promise<number> & { __linterBrands?: string };
165+
166+
let promise: SafePromise = Promise.resolve(2);
167+
promise;
168+
169+
function returnsSafePromise(): SafePromise {
170+
return Promise.resolve(42);
171+
}
172+
173+
returnsSafePromise();
174+
```
175+
176+
</TabItem>
177+
</Tabs>
178+
122179
## When Not To Use It
123180

124181
This rule can be difficult to enable on large existing projects that set up many floating Promises.

‎packages/eslint-plugin/docs/rules/prefer-readonly-parameter-types.mdx

+5-4
Original file line numberDiff line numberDiff line change
@@ -142,11 +142,12 @@ interface Foo {
142142

143143
Some complex types cannot easily be made readonly, for example the `HTMLElement` type or the `JQueryStatic` type from `@types/jquery`. This option allows you to globally disable reporting of such types.
144144

145-
Each item must be one of:
145+
This option takes an array of type specifiers to ignore.
146+
Each item in the array must have one of the following forms:
146147

147-
- A type defined in a file (`{from: "file", name: "Foo", path: "src/foo-file.ts"}` with `path` being an optional path relative to the project root directory)
148-
- A type from the default library (`{from: "lib", name: "Foo"}`)
149-
- A type from a package (`{from: "package", name: "Foo", package: "foo-lib"}`, this also works for types defined in a typings package).
148+
- A type defined in a file (`{ from: "file", name: "Foo", path: "src/foo-file.ts" }` with `path` being an optional path relative to the project root directory)
149+
- A type from the default library (`{ from: "lib", name: "Foo" }`)
150+
- A type from a package (`{ from: "package", name: "Foo", package: "foo-lib" }`, this also works for types defined in a typings package).
150151

151152
Additionally, a type may be defined just as a simple string, which then matches the type independently of its origin.
152153

‎packages/eslint-plugin/src/rules/no-floating-promises.ts

+67-51
Original file line numberDiff line numberDiff line change
@@ -3,17 +3,22 @@ import { AST_NODE_TYPES } from '@typescript-eslint/utils';
33
import * as tsutils from 'ts-api-utils';
44
import * as ts from 'typescript';
55

6+
import type { TypeOrValueSpecifier } from '../util';
67
import {
78
createRule,
89
getOperatorPrecedence,
910
getParserServices,
1011
OperatorPrecedence,
12+
readonlynessOptionsDefaults,
13+
readonlynessOptionsSchema,
14+
typeMatchesSpecifier,
1115
} from '../util';
1216

1317
type Options = [
1418
{
1519
ignoreVoid?: boolean;
1620
ignoreIIFE?: boolean;
21+
allowForKnownSafePromises?: TypeOrValueSpecifier[];
1722
},
1823
];
1924

@@ -79,6 +84,7 @@ export default createRule<Options, MessageId>({
7984
'Whether to ignore async IIFEs (Immediately Invoked Function Expressions).',
8085
type: 'boolean',
8186
},
87+
allowForKnownSafePromises: readonlynessOptionsSchema.properties.allow,
8288
},
8389
additionalProperties: false,
8490
},
@@ -89,12 +95,16 @@ export default createRule<Options, MessageId>({
8995
{
9096
ignoreVoid: true,
9197
ignoreIIFE: false,
98+
allowForKnownSafePromises: readonlynessOptionsDefaults.allow,
9299
},
93100
],
94101

95102
create(context, [options]) {
96103
const services = getParserServices(context);
97104
const checker = services.program.getTypeChecker();
105+
// TODO: #5439
106+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
107+
const allowForKnownSafePromises = options.allowForKnownSafePromises!;
98108

99109
return {
100110
ExpressionStatement(node): void {
@@ -253,11 +263,11 @@ export default createRule<Options, MessageId>({
253263
// Check the type. At this point it can't be unhandled if it isn't a promise
254264
// or array thereof.
255265

256-
if (isPromiseArray(checker, tsNode)) {
266+
if (isPromiseArray(tsNode)) {
257267
return { isUnhandled: true, promiseArray: true };
258268
}
259269

260-
if (!isPromiseLike(checker, tsNode)) {
270+
if (!isPromiseLike(tsNode)) {
261271
return { isUnhandled: false };
262272
}
263273

@@ -322,63 +332,69 @@ export default createRule<Options, MessageId>({
322332
// we just can't tell.
323333
return { isUnhandled: false };
324334
}
325-
},
326-
});
327335

328-
function isPromiseArray(checker: ts.TypeChecker, node: ts.Node): boolean {
329-
const type = checker.getTypeAtLocation(node);
330-
for (const ty of tsutils
331-
.unionTypeParts(type)
332-
.map(t => checker.getApparentType(t))) {
333-
if (checker.isArrayType(ty)) {
334-
const arrayType = checker.getTypeArguments(ty)[0];
335-
if (isPromiseLike(checker, node, arrayType)) {
336-
return true;
337-
}
338-
}
336+
function isPromiseArray(node: ts.Node): boolean {
337+
const type = checker.getTypeAtLocation(node);
338+
for (const ty of tsutils
339+
.unionTypeParts(type)
340+
.map(t => checker.getApparentType(t))) {
341+
if (checker.isArrayType(ty)) {
342+
const arrayType = checker.getTypeArguments(ty)[0];
343+
if (isPromiseLike(node, arrayType)) {
344+
return true;
345+
}
346+
}
339347

340-
if (checker.isTupleType(ty)) {
341-
for (const tupleElementType of checker.getTypeArguments(ty)) {
342-
if (isPromiseLike(checker, node, tupleElementType)) {
343-
return true;
348+
if (checker.isTupleType(ty)) {
349+
for (const tupleElementType of checker.getTypeArguments(ty)) {
350+
if (isPromiseLike(node, tupleElementType)) {
351+
return true;
352+
}
353+
}
344354
}
345355
}
356+
return false;
346357
}
347-
}
348-
return false;
349-
}
350358

351-
// Modified from tsutils.isThenable() to only consider thenables which can be
352-
// rejected/caught via a second parameter. Original source (MIT licensed):
353-
//
354-
// https://github.com/ajafff/tsutils/blob/49d0d31050b44b81e918eae4fbaf1dfe7b7286af/util/type.ts#L95-L125
355-
function isPromiseLike(
356-
checker: ts.TypeChecker,
357-
node: ts.Node,
358-
type?: ts.Type,
359-
): boolean {
360-
type ??= checker.getTypeAtLocation(node);
361-
for (const ty of tsutils.unionTypeParts(checker.getApparentType(type))) {
362-
const then = ty.getProperty('then');
363-
if (then === undefined) {
364-
continue;
365-
}
359+
// Modified from tsutils.isThenable() to only consider thenables which can be
360+
// rejected/caught via a second parameter. Original source (MIT licensed):
361+
//
362+
// https://github.com/ajafff/tsutils/blob/49d0d31050b44b81e918eae4fbaf1dfe7b7286af/util/type.ts#L95-L125
363+
function isPromiseLike(node: ts.Node, type?: ts.Type): boolean {
364+
type ??= checker.getTypeAtLocation(node);
366365

367-
const thenType = checker.getTypeOfSymbolAtLocation(then, node);
368-
if (
369-
hasMatchingSignature(
370-
thenType,
371-
signature =>
372-
signature.parameters.length >= 2 &&
373-
isFunctionParam(checker, signature.parameters[0], node) &&
374-
isFunctionParam(checker, signature.parameters[1], node),
375-
)
376-
) {
377-
return true;
366+
// Ignore anything specified by `allowForKnownSafePromises` option.
367+
if (
368+
allowForKnownSafePromises.some(allowedType =>
369+
typeMatchesSpecifier(type, allowedType, services.program),
370+
)
371+
) {
372+
return false;
373+
}
374+
375+
for (const ty of tsutils.unionTypeParts(checker.getApparentType(type))) {
376+
const then = ty.getProperty('then');
377+
if (then === undefined) {
378+
continue;
379+
}
380+
381+
const thenType = checker.getTypeOfSymbolAtLocation(then, node);
382+
if (
383+
hasMatchingSignature(
384+
thenType,
385+
signature =>
386+
signature.parameters.length >= 2 &&
387+
isFunctionParam(checker, signature.parameters[0], node) &&
388+
isFunctionParam(checker, signature.parameters[1], node),
389+
)
390+
) {
391+
return true;
392+
}
393+
}
394+
return false;
378395
}
379-
}
380-
return false;
381-
}
396+
},
397+
});
382398

383399
function hasMatchingSignature(
384400
type: ts.Type,

‎packages/eslint-plugin/tests/docs-eslint-output-snapshots/no-floating-promises.shot

+35
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)
Please sign in to comment.