Skip to content

Commit e7d36f3

Browse files
jtbandespezy
authored andcommitted
Add lint rule to prefer map((): T => {}) over map<T>(() => {}) (#6995)
**User-Facing Changes** None **Description** TypeScript currently widens inferred types of function expressions (microsoft/TypeScript#241). This results in the following [sad situation](https://www.typescriptlang.org/play?#code/MYewdgzgLgBAhjAvDA2gRgDQwExYMwC6A3AFAlQCeADgKYwAqSMA3iTOzAB4BcMYArgFsARjQBOpAL5k4AOkFwqACiUBLAJS9GiAHwsSAegMwAegH42HMTSj8xYfRyddeqjJecwKvAEQgQVBCqND5YMEYw4mIgYjCAvBuAsjseMNKS6qQkcgpUADz0OioaSHqsEebJ1rb2jp4uMG7JTt4wfgFBIWERYCCRYtGxgHwbgCi7yanpZEA): ```ts const a = [1, 2, 3]; type T = { x: number; } a.map((i): T => { return { x: i, y: "oopsie", // error 👍 } }); a.map<T>((i) => { return { x: i, y: "oopsie", // no error 🤔 } }); ``` This PR adds a lint rule to automatically replace `map<T>((i) => ...` with `map((i): T => ...`. Depends on https://github.com/foxglove/studio/pull/6989 to merge first. Resolves FG-5336
1 parent 431bae4 commit e7d36f3

File tree

5 files changed

+120
-3
lines changed

5 files changed

+120
-3
lines changed

packages/eslint-plugin-studio/index.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ module.exports = {
77
"link-target": require("./link-target"),
88
"lodash-ramda-imports": require("./lodash-ramda-imports"),
99
"ramda-usage": require("./ramda-usage"),
10+
"no-map-type-argument": require("./no-map-type-argument"),
1011
},
1112

1213
configs: {
@@ -16,6 +17,7 @@ module.exports = {
1617
"@foxglove/studio/link-target": "error",
1718
"@foxglove/studio/lodash-ramda-imports": "error",
1819
"@foxglove/studio/ramda-usage": "error",
20+
"@foxglove/studio/no-map-type-argument": "error",
1921
},
2022
},
2123
},
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
// This Source Code Form is subject to the terms of the Mozilla Public
2+
// License, v2.0. If a copy of the MPL was not distributed with this
3+
// file, You can obtain one at http://mozilla.org/MPL/2.0/
4+
5+
const { ESLintUtils } = require("@typescript-eslint/utils");
6+
7+
/**
8+
* @type {import("eslint").Rule.RuleModule}
9+
*/
10+
module.exports = {
11+
meta: {
12+
type: "problem",
13+
fixable: "code",
14+
messages: {
15+
preferReturnTypeAnnotation: `Annotate the function return type explicitly instead of passing generic arguments to Array#map, to avoid return type widening (https://github.com/microsoft/TypeScript/issues/241)`,
16+
},
17+
},
18+
create: (context) => {
19+
return {
20+
[`CallExpression[arguments.length>=1][typeArguments.params.length=1][arguments.0.type=ArrowFunctionExpression]:not([arguments.0.returnType]) > MemberExpression.callee[property.name="map"]`]:
21+
(/** @type {import("estree").MemberExpression} */ node) => {
22+
/** @type {import("estree").CallExpression} */
23+
const callExpr = node.parent;
24+
25+
const { esTreeNodeToTSNodeMap, program } = ESLintUtils.getParserServices(context);
26+
const sourceCode = context.getSourceCode();
27+
const checker = program.getTypeChecker();
28+
const objectTsNode = esTreeNodeToTSNodeMap.get(node.object);
29+
const objectType = checker.getTypeAtLocation(objectTsNode);
30+
if (!checker.isArrayType(objectType) && !checker.isTupleType(objectType)) {
31+
return;
32+
}
33+
34+
const arrowToken = sourceCode.getTokenBefore(
35+
callExpr.arguments[0].body,
36+
(token) => token.type === "Punctuator" && token.value === "=>",
37+
);
38+
if (!arrowToken) {
39+
return;
40+
}
41+
const maybeCloseParenToken = sourceCode.getTokenBefore(arrowToken);
42+
const closeParenToken =
43+
maybeCloseParenToken.type === "Punctuator" && maybeCloseParenToken.value === ")"
44+
? maybeCloseParenToken
45+
: undefined;
46+
47+
context.report({
48+
node: callExpr.typeArguments,
49+
messageId: "preferReturnTypeAnnotation",
50+
*fix(fixer) {
51+
const returnType = sourceCode.getText(callExpr.typeArguments.params[0]);
52+
yield fixer.remove(callExpr.typeArguments);
53+
if (closeParenToken) {
54+
yield fixer.insertTextAfter(closeParenToken, `: ${returnType}`);
55+
} else {
56+
yield fixer.insertTextBefore(callExpr.arguments[0], "(");
57+
yield fixer.insertTextAfter(
58+
callExpr.arguments[0].params[callExpr.arguments[0].params.length - 1],
59+
`): ${returnType}`,
60+
);
61+
}
62+
},
63+
});
64+
},
65+
};
66+
},
67+
};
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
// This Source Code Form is subject to the terms of the Mozilla Public
2+
// License, v2.0. If a copy of the MPL was not distributed with this
3+
// file, You can obtain one at http://mozilla.org/MPL/2.0/
4+
5+
import { RuleTester } from "@typescript-eslint/rule-tester";
6+
import { TSESLint } from "@typescript-eslint/utils";
7+
import path from "path";
8+
9+
// eslint-disable-next-line @typescript-eslint/no-var-requires
10+
const rule = require("./no-map-type-argument") as TSESLint.RuleModule<"preferReturnTypeAnnotation">;
11+
12+
const ruleTester = new RuleTester({
13+
parser: "@typescript-eslint/parser",
14+
parserOptions: {
15+
ecmaVersion: 2020,
16+
tsconfigRootDir: path.join(__dirname, "fixture"),
17+
project: "tsconfig.json",
18+
},
19+
});
20+
21+
ruleTester.run("no-map-type-argument", rule, {
22+
valid: [
23+
/* ts */ `
24+
[1, 2].map((x) => x + 1);
25+
[1, 2].map((x): number => x + 1);
26+
[1, 2].map<number>((x): number => x + 1);
27+
[1, 2].map<number, string>((x) => x + 1);
28+
({ x: 1 }).map<number>((x) => x + 1);
29+
`,
30+
],
31+
32+
invalid: [
33+
{
34+
code: /* ts */ `
35+
[1, 2].map<number>(x => x + 1);
36+
[1, 2].map<number>((x) => x + 1);
37+
`,
38+
errors: [
39+
{ messageId: "preferReturnTypeAnnotation", line: 2 },
40+
{ messageId: "preferReturnTypeAnnotation", line: 3 },
41+
],
42+
output: /* ts */ `
43+
[1, 2].map((x): number => x + 1);
44+
[1, 2].map((x): number => x + 1);
45+
`,
46+
},
47+
],
48+
});

packages/studio-base/src/components/PanelExtensionAdapter/PanelExtensionAdapter.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -393,7 +393,7 @@ function PanelExtensionAdapter(
393393
if (!isMounted()) {
394394
return;
395395
}
396-
const subscribePayloads = topics.map<SubscribePayload>((item) => {
396+
const subscribePayloads = topics.map((item): SubscribePayload => {
397397
if (typeof item === "string") {
398398
// For backwards compatability with the topic-string-array api `subscribe(["/topic"])`
399399
// results in a topic subscription with full preloading
@@ -407,7 +407,7 @@ function PanelExtensionAdapter(
407407
});
408408

409409
// ExtensionPanel-Facing subscription type
410-
const localSubs = topics.map<Subscription>((item) => {
410+
const localSubs = topics.map((item): Subscription => {
411411
if (typeof item === "string") {
412412
return { topic: item, preload: true };
413413
}

packages/studio-base/src/components/PanelExtensionAdapter/renderState.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -159,7 +159,7 @@ function initRenderStateBuilder(): BuildRenderStateFn {
159159
if (sortedTopics !== prevSortedTopics || prevMessageConverters !== messageConverters) {
160160
shouldRender = true;
161161

162-
const topics = sortedTopics.map<Topic>((topic) => {
162+
const topics = sortedTopics.map((topic): Topic => {
163163
const newTopic: Topic = {
164164
name: topic.name,
165165
datatype: topic.schemaName ?? "",

0 commit comments

Comments
 (0)