Skip to content

Commit 61abe24

Browse files
feat(no-unnecessary-collection): new rule (#281)
New rule to detect unnecessary usage of `combineLatest`, `forkJoin`, etc. where a collection (object or array) containing only a single observable is passed in. In these cases, `combineLatest`, etc. can be replaced with the single observable itself (after accounting for slight changes in output). * This rule is added to the strict config. * This rule does not include a fixer. A fixer could be implemented in future for `merge`, `race`, `concat`, and the overload of `combineLatest` that accepts an array. * This rule does not support variable-length arrays and non-literal objects because their lengths are not guaranteed. * Like most rules in this plugin, this rule does not support import aliases. Resolves #39 and cartant#107 .
1 parent dafc378 commit 61abe24

File tree

7 files changed

+590
-0
lines changed

7 files changed

+590
-0
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,7 @@ The package includes the following rules.
147147
| [no-tap](docs/rules/no-tap.md) | Disallow the `tap` operator. | | | | ||
148148
| [no-topromise](docs/rules/no-topromise.md) | Disallow use of the `toPromise` method. | ✅ 🔒 | | 💡 | 💭 | |
149149
| [no-unbound-methods](docs/rules/no-unbound-methods.md) | Disallow passing unbound methods. | ✅ 🔒 | | | 💭 | |
150+
| [no-unnecessary-collection](docs/rules/no-unnecessary-collection.md) | Disallow unnecessary usage of collection arguments with single values. | 🔒 | | | | |
150151
| [no-unsafe-catch](docs/rules/no-unsafe-catch.md) | Disallow unsafe `catchError` usage in effects and epics. | | | | 💭 | |
151152
| [no-unsafe-first](docs/rules/no-unsafe-first.md) | Disallow unsafe `first`/`take` usage in effects and epics. | | | | 💭 | |
152153
| [no-unsafe-subject-next](docs/rules/no-unsafe-subject-next.md) | Disallow unsafe optional `next` calls. | ✅ 🔒 | | | 💭 | |
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
# Disallow unnecessary usage of collection arguments with single values (`rxjs-x/no-unnecessary-collection`)
2+
3+
💼 This rule is enabled in the 🔒 `strict` config.
4+
5+
<!-- end auto-generated rule header -->
6+
7+
This rule effects failures when passing a collection (object or array) containing a single observable
8+
to the static observable creators that accept multiple observables
9+
(`combineLatest`, `forkJoin`, `merge`, `zip`, `concat`, `race`).
10+
Use of these creator functions with only a single observable
11+
can be replaced with direct usage of the observable itself.
12+
13+
## Rule details
14+
15+
Examples of **incorrect** code for this rule:
16+
17+
```ts
18+
import { combineLatest, forkJoin, merge, zip, concat, race, of } from "rxjs";
19+
20+
// These incorrect example can be simplified:
21+
const a$ = combineLatest([of(1)]);
22+
const b$ = forkJoin([of('data')]);
23+
24+
const c$ = combineLatest({ value: of(1) });
25+
const d$ = forkJoin({ data: of('hello') });
26+
27+
const e$ = merge(of(1));
28+
const f$ = zip(of(1));
29+
const g$ = concat(of(1));
30+
const h$ = race(of(1));
31+
```
32+
33+
Examples of **correct** code for this rule:
34+
35+
```ts
36+
import { of, map } from "rxjs";
37+
38+
// These are equivalent to the previous examples:
39+
const a$ = of(1);
40+
const b$ = of('data').pipe(map(x => [x]));
41+
42+
const c$ = of(1).pipe(map(x => ({ value: x })))
43+
const d$ = of('hello').pipe(map(x => ({ data: x })));
44+
45+
const e$ = of(1);
46+
const f$ = of(1).pipe(map(x => [x]));
47+
const g$ = of(1);
48+
const h$ = of(1);
49+
```
50+
51+
## When Not To Use It
52+
53+
If you don't care about unnecessary usage of static observable creators,
54+
then you don't need this rule.
55+
56+
## Resources
57+
58+
- [Rule source](/src/rules/no-unnecessary-collection.ts)
59+
- [Test source](/tests/rules/no-unnecessary-collection.test.ts)

src/configs/strict.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ export const createStrictConfig = (
3333
'rxjs-x/no-subscribe-in-pipe': 'error',
3434
'rxjs-x/no-topromise': 'error',
3535
'rxjs-x/no-unbound-methods': 'error',
36+
'rxjs-x/no-unnecessary-collection': 'error',
3637
'rxjs-x/no-unsafe-subject-next': 'error',
3738
'rxjs-x/no-unsafe-takeuntil': 'error',
3839
'rxjs-x/prefer-observer': 'error',

src/constants.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,19 @@ export const SOURCES_OBJECT_ACCEPTING_STATIC_OBSERVABLE_CREATORS = [
99
'forkJoin',
1010
];
1111

12+
/**
13+
* The names of static observable creators
14+
* that accept a collection (array or object) of multiple observables as input.
15+
*/
16+
export const MULTIPLE_OBSERVABLE_ACCEPTING_STATIC_OBSERVABLE_CREATORS = [
17+
'combineLatest',
18+
'forkJoin',
19+
'merge',
20+
'zip',
21+
'concat',
22+
'race',
23+
];
24+
1225
/**
1326
* The names of types that are allowed to be passed unbound.
1427
*/

src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ import { noSubscribeInPipeRule } from './rules/no-subscribe-in-pipe';
4141
import { noTapRule } from './rules/no-tap';
4242
import { noTopromiseRule } from './rules/no-topromise';
4343
import { noUnboundMethodsRule } from './rules/no-unbound-methods';
44+
import { noUnnecessaryCollectionRule } from './rules/no-unnecessary-collection';
4445
import { noUnsafeCatchRule } from './rules/no-unsafe-catch';
4546
import { noUnsafeFirstRule } from './rules/no-unsafe-first';
4647
import { noUnsafeSubjectNext } from './rules/no-unsafe-subject-next';
@@ -89,6 +90,7 @@ const allRules = {
8990
'no-tap': noTapRule,
9091
'no-topromise': noTopromiseRule,
9192
'no-unbound-methods': noUnboundMethodsRule,
93+
'no-unnecessary-collection': noUnnecessaryCollectionRule,
9294
'no-unsafe-catch': noUnsafeCatchRule,
9395
'no-unsafe-first': noUnsafeFirstRule,
9496
'no-unsafe-subject-next': noUnsafeSubjectNext,
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import {
2+
TSESTree as es,
3+
} from '@typescript-eslint/utils';
4+
import { MULTIPLE_OBSERVABLE_ACCEPTING_STATIC_OBSERVABLE_CREATORS, SOURCES_OBJECT_ACCEPTING_STATIC_OBSERVABLE_CREATORS } from '../constants';
5+
import { getTypeServices, isArrayExpression, isObjectExpression, isProperty } from '../etc';
6+
import { ruleCreator } from '../utils';
7+
8+
export const noUnnecessaryCollectionRule = ruleCreator({
9+
defaultOptions: [],
10+
meta: {
11+
docs: {
12+
description: 'Disallow unnecessary usage of collection arguments with single values.',
13+
recommended: 'strict',
14+
requiresTypeChecking: false,
15+
},
16+
messages: {
17+
forbidden: 'Unnecessary {{operator}} with {{inputType}}. Use the observable directly instead.',
18+
},
19+
schema: [],
20+
type: 'suggestion',
21+
},
22+
name: 'no-unnecessary-collection',
23+
create: (context) => {
24+
const { couldBeType, couldBeObservable } = getTypeServices(context);
25+
26+
function couldBeFromRxjs(node: es.Node, operatorName: string): boolean {
27+
return couldBeType(node, operatorName, { name: /[/\\]rxjs[/\\]/ });
28+
}
29+
30+
function checkCallExpression(node: es.CallExpression, operatorName: string): void {
31+
const args = node.arguments;
32+
33+
if (args.length === 0) {
34+
return;
35+
}
36+
37+
const firstArg = args[0];
38+
39+
// Single-valued array.
40+
if (isArrayExpression(firstArg)) {
41+
const nonNullElements = firstArg.elements.filter(element => element !== null);
42+
if (nonNullElements.length === 1 && couldBeObservable(nonNullElements[0])) {
43+
context.report({
44+
messageId: 'forbidden',
45+
node: node.callee,
46+
data: {
47+
operator: operatorName,
48+
inputType: 'single-valued array',
49+
},
50+
});
51+
}
52+
return;
53+
}
54+
55+
// Single-property object.
56+
if (isObjectExpression(firstArg) && SOURCES_OBJECT_ACCEPTING_STATIC_OBSERVABLE_CREATORS.includes(operatorName)) {
57+
if (firstArg.properties.length === 1 && isProperty(firstArg.properties[0])) {
58+
const property = firstArg.properties[0];
59+
if (property.value && couldBeObservable(property.value)) {
60+
context.report({
61+
messageId: 'forbidden',
62+
node: node.callee,
63+
data: {
64+
operator: operatorName,
65+
inputType: 'single-property object',
66+
},
67+
});
68+
}
69+
}
70+
return;
71+
}
72+
73+
// Single rest parameter argument.
74+
if (args.length === 1 && couldBeObservable(firstArg)) {
75+
context.report({
76+
messageId: 'forbidden',
77+
node: node.callee,
78+
data: {
79+
operator: operatorName,
80+
inputType: 'single argument',
81+
},
82+
});
83+
}
84+
}
85+
86+
const callExpressionVisitors: Record<string, (node: es.CallExpression) => void> = {};
87+
88+
for (const operator of MULTIPLE_OBSERVABLE_ACCEPTING_STATIC_OBSERVABLE_CREATORS) {
89+
callExpressionVisitors[`CallExpression[callee.name="${operator}"]`] = (node: es.CallExpression) => {
90+
if (couldBeFromRxjs(node.callee, operator)) {
91+
checkCallExpression(node, operator);
92+
}
93+
};
94+
}
95+
96+
for (const operator of MULTIPLE_OBSERVABLE_ACCEPTING_STATIC_OBSERVABLE_CREATORS) {
97+
callExpressionVisitors[`CallExpression[callee.type="MemberExpression"][callee.property.name="${operator}"]`] = (node: es.CallExpression) => {
98+
const memberExpr = node.callee as es.MemberExpression;
99+
if (couldBeFromRxjs(memberExpr.property, operator)) {
100+
checkCallExpression(node, operator);
101+
}
102+
};
103+
}
104+
105+
return callExpressionVisitors;
106+
},
107+
});

0 commit comments

Comments
 (0)