Skip to content
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.

Commit c92d240

Browse files
authoredApr 13, 2020
feat(eslint-plugin): add rule prefer-reduce-type-parameter (#1707)
1 parent 2e9c202 commit c92d240

File tree

8 files changed

+360
-2
lines changed

8 files changed

+360
-2
lines changed
 

‎packages/eslint-plugin/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,7 @@ Pro Tip: For larger codebases you may want to consider splitting our linting int
149149
| [`@typescript-eslint/prefer-optional-chain`](./docs/rules/prefer-optional-chain.md) | Prefer using concise optional chain expressions instead of chained logical ands | | :wrench: | |
150150
| [`@typescript-eslint/prefer-readonly`](./docs/rules/prefer-readonly.md) | Requires that private members are marked as `readonly` if they're never modified outside of the constructor | | :wrench: | :thought_balloon: |
151151
| [`@typescript-eslint/prefer-readonly-parameter-types`](./docs/rules/prefer-readonly-parameter-types.md) | Requires that function parameters are typed as readonly to prevent accidental mutation of inputs | | | :thought_balloon: |
152+
| [`@typescript-eslint/prefer-reduce-type-parameter`](./docs/rules/prefer-reduce-type-parameter.md) | Prefer using type parameter when calling `Array#reduce` instead of casting | | :wrench: | :thought_balloon: |
152153
| [`@typescript-eslint/prefer-regexp-exec`](./docs/rules/prefer-regexp-exec.md) | Enforce that `RegExp#exec` is used instead of `String#match` if no global flag is provided | :heavy_check_mark: | | :thought_balloon: |
153154
| [`@typescript-eslint/prefer-string-starts-ends-with`](./docs/rules/prefer-string-starts-ends-with.md) | Enforce the use of `String#startsWith` and `String#endsWith` instead of other equivalent methods of checking substrings | :heavy_check_mark: | :wrench: | :thought_balloon: |
154155
| [`@typescript-eslint/promise-function-async`](./docs/rules/promise-function-async.md) | Requires any function or method that returns a Promise to be marked async | | | :thought_balloon: |
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
# Prefer using type parameter when calling `Array#reduce` instead of casting (`prefer-reduce-type-parameter`)
2+
3+
It's common to call `Array#reduce` with a generic type, such as an array or object, as the initial value.
4+
Since these values are empty, their types are not usable:
5+
6+
- `[]` has type `never[]`, which can't have items pushed into it as nothing is type `never`
7+
- `{}` has type `{}`, which doesn't have an index signature and so can't have properties added to it
8+
9+
A common solution to this problem is to cast the initial value. While this will work, it's not the most optimal
10+
solution as casting has subtle effects on the underlying types that can allow bugs to slip in.
11+
12+
A better (and lesser known) solution is to pass the type in as a generic parameter to `Array#reduce` explicitly.
13+
This means that TypeScript doesn't have to try to infer the type, and avoids the common pitfalls that come with casting.
14+
15+
## Rule Details
16+
17+
This rule looks for calls to `Array#reduce`, and warns if an initial value is being passed & casted,
18+
suggesting instead to pass the cast type to `Array#reduce` as its generic parameter.
19+
20+
Examples of **incorrect** code for this rule:
21+
22+
```ts
23+
[1, 2, 3].reduce((arr, num) => arr.concat(num * 2), [] as number[]);
24+
25+
['a', 'b'].reduce(
26+
(accum, name) => ({
27+
...accum,
28+
[name]: true,
29+
}),
30+
{} as Record<string, boolean>,
31+
);
32+
```
33+
34+
Examples of **correct** code for this rule:
35+
36+
```ts
37+
[1, 2, 3].reduce<number[]>((arr, num) => arr.concat(num * 2), []);
38+
39+
['a', 'b'].reduce<Record<string, boolean>>(
40+
(accum, name) => ({
41+
...accum,
42+
[name]: true,
43+
}),
44+
{},
45+
);
46+
```
47+
48+
## Options
49+
50+
There are no options.
51+
52+
## When Not To Use It
53+
54+
If you don't want to use typechecking in your linting, you can't use this rule.

‎packages/eslint-plugin/src/configs/all.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@
8686
"@typescript-eslint/prefer-optional-chain": "error",
8787
"@typescript-eslint/prefer-readonly": "error",
8888
"@typescript-eslint/prefer-readonly-parameter-types": "error",
89+
"@typescript-eslint/prefer-reduce-type-parameter": "error",
8990
"@typescript-eslint/prefer-regexp-exec": "error",
9091
"@typescript-eslint/prefer-string-starts-ends-with": "error",
9192
"@typescript-eslint/promise-function-async": "error",

‎packages/eslint-plugin/src/rules/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ import preferNullishCoalescing from './prefer-nullish-coalescing';
7575
import preferOptionalChain from './prefer-optional-chain';
7676
import preferReadonly from './prefer-readonly';
7777
import preferReadonlyParameterTypes from './prefer-readonly-parameter-types';
78+
import preferReduceTypeParameter from './prefer-reduce-type-parameter';
7879
import preferRegexpExec from './prefer-regexp-exec';
7980
import preferStringStartsEndsWith from './prefer-string-starts-ends-with';
8081
import promiseFunctionAsync from './promise-function-async';
@@ -172,6 +173,7 @@ export default {
172173
'prefer-optional-chain': preferOptionalChain,
173174
'prefer-readonly-parameter-types': preferReadonlyParameterTypes,
174175
'prefer-readonly': preferReadonly,
176+
'prefer-reduce-type-parameter': preferReduceTypeParameter,
175177
'prefer-regexp-exec': preferRegexpExec,
176178
'prefer-string-starts-ends-with': preferStringStartsEndsWith,
177179
'promise-function-async': promiseFunctionAsync,
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import {
2+
AST_NODE_TYPES,
3+
TSESTree,
4+
} from '@typescript-eslint/experimental-utils';
5+
import * as util from '../util';
6+
7+
type MemberExpressionWithCallExpressionParent = (
8+
| TSESTree.MemberExpression
9+
| TSESTree.OptionalMemberExpression
10+
) & { parent: TSESTree.CallExpression | TSESTree.OptionalCallExpression };
11+
12+
const getMemberExpressionName = (
13+
member: TSESTree.MemberExpression | TSESTree.OptionalMemberExpression,
14+
): string | null => {
15+
if (!member.computed) {
16+
return member.property.name;
17+
}
18+
19+
if (
20+
member.property.type === AST_NODE_TYPES.Literal &&
21+
typeof member.property.value === 'string'
22+
) {
23+
return member.property.value;
24+
}
25+
26+
return null;
27+
};
28+
29+
export default util.createRule({
30+
name: 'prefer-reduce-type-parameter',
31+
meta: {
32+
type: 'problem',
33+
docs: {
34+
category: 'Best Practices',
35+
recommended: false,
36+
description:
37+
'Prefer using type parameter when calling `Array#reduce` instead of casting',
38+
requiresTypeChecking: true,
39+
},
40+
messages: {
41+
preferTypeParameter:
42+
'Unnecessary cast: Array#reduce accepts a type parameter for the default value.',
43+
},
44+
fixable: 'code',
45+
schema: [],
46+
},
47+
defaultOptions: [],
48+
create(context) {
49+
const service = util.getParserServices(context);
50+
const checker = service.program.getTypeChecker();
51+
52+
return {
53+
':matches(CallExpression, OptionalCallExpression) > :matches(MemberExpression, OptionalMemberExpression).callee'(
54+
callee: MemberExpressionWithCallExpressionParent,
55+
): void {
56+
if (getMemberExpressionName(callee) !== 'reduce') {
57+
return;
58+
}
59+
60+
const [, secondArg] = callee.parent.arguments;
61+
62+
if (
63+
callee.parent.arguments.length < 2 ||
64+
!util.isTypeAssertion(secondArg)
65+
) {
66+
return;
67+
}
68+
69+
// Get the symbol of the `reduce` method.
70+
const tsNode = service.esTreeNodeToTSNodeMap.get(callee.object);
71+
const calleeObjType = util.getConstrainedTypeAtLocation(
72+
checker,
73+
tsNode,
74+
);
75+
76+
// Check the owner type of the `reduce` method.
77+
if (checker.isArrayType(calleeObjType)) {
78+
context.report({
79+
messageId: 'preferTypeParameter',
80+
node: secondArg,
81+
fix: fixer => [
82+
fixer.removeRange([
83+
secondArg.range[0],
84+
secondArg.expression.range[0],
85+
]),
86+
fixer.removeRange([
87+
secondArg.expression.range[1],
88+
secondArg.range[1],
89+
]),
90+
fixer.insertTextAfter(
91+
callee,
92+
`<${context
93+
.getSourceCode()
94+
.getText(secondArg.typeAnnotation)}>`,
95+
),
96+
],
97+
});
98+
99+
return;
100+
}
101+
},
102+
};
103+
},
104+
});

‎packages/eslint-plugin/tests/fixtures/class.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,8 @@ export class Error {}
33

44
// used by unbound-method test case to test imports
55
export const console = { log() {} };
6+
7+
// used by prefer-reduce-type-parameter to test native vs userland check
8+
export class Reducable {
9+
reduce() {}
10+
}
Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
import rule from '../../src/rules/prefer-reduce-type-parameter';
2+
import { RuleTester, getFixturesRootDir } from '../RuleTester';
3+
4+
const rootPath = getFixturesRootDir();
5+
6+
const ruleTester = new RuleTester({
7+
parser: '@typescript-eslint/parser',
8+
parserOptions: {
9+
sourceType: 'module',
10+
tsconfigRootDir: rootPath,
11+
project: './tsconfig.json',
12+
},
13+
});
14+
15+
ruleTester.run('prefer-reduce-type-parameter', rule, {
16+
valid: [
17+
`
18+
new (class Mine {
19+
reduce() {}
20+
})().reduce(() => {}, 1 as any);
21+
`,
22+
`
23+
class Mine {
24+
reduce() {}
25+
}
26+
27+
new Mine().reduce(() => {}, 1 as any);
28+
`,
29+
`
30+
import { Reducable } from './class';
31+
32+
new Reducable().reduce(() => {}, 1 as any);
33+
`,
34+
"[1, 2, 3]['reduce']((sum, num) => sum + num, 0);",
35+
'[1, 2, 3][null]((sum, num) => sum + num, 0);',
36+
'[1, 2, 3]?.[null]((sum, num) => sum + num, 0);',
37+
'[1, 2, 3].reduce((sum, num) => sum + num, 0);',
38+
'[1, 2, 3].reduce<number[]>((a, s) => a.concat(s * 2), []);',
39+
'[1, 2, 3]?.reduce<number[]>((a, s) => a.concat(s * 2), []);',
40+
],
41+
invalid: [
42+
{
43+
code: '[1, 2, 3].reduce((a, s) => a.concat(s * 2), [] as number[]);',
44+
output: '[1, 2, 3].reduce<number[]>((a, s) => a.concat(s * 2), []);',
45+
errors: [
46+
{
47+
messageId: 'preferTypeParameter',
48+
column: 45,
49+
line: 1,
50+
},
51+
],
52+
},
53+
{
54+
code: '[1, 2, 3].reduce((a, s) => a.concat(s * 2), <number[]>[]);',
55+
output: '[1, 2, 3].reduce<number[]>((a, s) => a.concat(s * 2), []);',
56+
errors: [
57+
{
58+
messageId: 'preferTypeParameter',
59+
column: 45,
60+
line: 1,
61+
},
62+
],
63+
},
64+
{
65+
code: '[1, 2, 3]?.reduce((a, s) => a.concat(s * 2), [] as number[]);',
66+
output: '[1, 2, 3]?.reduce<number[]>((a, s) => a.concat(s * 2), []);',
67+
errors: [
68+
{
69+
messageId: 'preferTypeParameter',
70+
column: 46,
71+
line: 1,
72+
},
73+
],
74+
},
75+
{
76+
code: '[1, 2, 3]?.reduce((a, s) => a.concat(s * 2), <number[]>[]);',
77+
output: '[1, 2, 3]?.reduce<number[]>((a, s) => a.concat(s * 2), []);',
78+
errors: [
79+
{
80+
messageId: 'preferTypeParameter',
81+
column: 46,
82+
line: 1,
83+
},
84+
],
85+
},
86+
{
87+
code: `
88+
const names = ['a', 'b', 'c'];
89+
90+
names.reduce(
91+
(accum, name) => ({
92+
...accum,
93+
[name]: true,
94+
}),
95+
{} as Record<string, boolean>,
96+
);
97+
`,
98+
output: `
99+
const names = ['a', 'b', 'c'];
100+
101+
names.reduce<Record<string, boolean>>(
102+
(accum, name) => ({
103+
...accum,
104+
[name]: true,
105+
}),
106+
{},
107+
);
108+
`,
109+
errors: [
110+
{
111+
messageId: 'preferTypeParameter',
112+
column: 3,
113+
line: 9,
114+
},
115+
],
116+
},
117+
{
118+
code: `
119+
['a', 'b'].reduce(
120+
(accum, name) => ({
121+
...accum,
122+
[name]: true,
123+
}),
124+
<Record<string, boolean>>{},
125+
);
126+
`,
127+
output: `
128+
['a', 'b'].reduce<Record<string, boolean>>(
129+
(accum, name) => ({
130+
...accum,
131+
[name]: true,
132+
}),
133+
{},
134+
);
135+
`,
136+
errors: [
137+
{
138+
messageId: 'preferTypeParameter',
139+
column: 3,
140+
line: 7,
141+
},
142+
],
143+
},
144+
{
145+
code: `
146+
['a', 'b']['reduce'](
147+
(accum, name) => ({
148+
...accum,
149+
[name]: true,
150+
}),
151+
{} as Record<string, boolean>,
152+
);
153+
`,
154+
output: `
155+
['a', 'b']['reduce']<Record<string, boolean>>(
156+
(accum, name) => ({
157+
...accum,
158+
[name]: true,
159+
}),
160+
{},
161+
);
162+
`,
163+
errors: [
164+
{
165+
messageId: 'preferTypeParameter',
166+
column: 3,
167+
line: 7,
168+
},
169+
],
170+
},
171+
{
172+
code: `
173+
function f<T, U extends T[]>(a: U) {
174+
return a.reduce(() => {}, {} as Record<string, boolean>);
175+
}
176+
`,
177+
output: `
178+
function f<T, U extends T[]>(a: U) {
179+
return a.reduce<Record<string, boolean>>(() => {}, {});
180+
}
181+
`,
182+
errors: [
183+
{
184+
messageId: 'preferTypeParameter',
185+
column: 29,
186+
line: 3,
187+
},
188+
],
189+
},
190+
],
191+
});

0 commit comments

Comments
 (0)
Please sign in to comment.