Skip to content

Commit 054e62e

Browse files
committed
feat(eslint-plugin): [restrict-template-expressions] add support for intersection types (fixes #1797)
1 parent b1b8284 commit 054e62e

File tree

2 files changed

+82
-77
lines changed

2 files changed

+82
-77
lines changed

Diff for: packages/eslint-plugin/src/rules/restrict-template-expressions.ts

+61-77
Original file line numberDiff line numberDiff line change
@@ -44,29 +44,36 @@ export default util.createRule<Options, MessageId>({
4444
const service = util.getParserServices(context);
4545
const typeChecker = service.program.getTypeChecker();
4646

47-
type BaseType =
48-
| 'string'
49-
| 'number'
50-
| 'bigint'
51-
| 'boolean'
52-
| 'null'
53-
| 'undefined'
54-
| 'other';
55-
56-
const allowedTypes: BaseType[] = [
57-
'string',
58-
...(options.allowNumber ? (['number', 'bigint'] as const) : []),
59-
...(options.allowBoolean ? (['boolean'] as const) : []),
60-
...(options.allowNullable ? (['null', 'undefined'] as const) : []),
61-
];
62-
63-
function isAllowedType(types: BaseType[]): boolean {
64-
for (const type of types) {
65-
if (!allowedTypes.includes(type)) {
66-
return false;
67-
}
47+
function isUnderlyingTypePrimitive(type: ts.Type): boolean {
48+
if (util.isTypeFlagSet(type, ts.TypeFlags.StringLike)) {
49+
return true;
50+
}
51+
52+
if (
53+
util.isTypeFlagSet(
54+
type,
55+
ts.TypeFlags.NumberLike | ts.TypeFlags.BigIntLike,
56+
) &&
57+
options.allowNumber
58+
) {
59+
return true;
6860
}
69-
return true;
61+
62+
if (
63+
util.isTypeFlagSet(type, ts.TypeFlags.BooleanLike) &&
64+
options.allowBoolean
65+
) {
66+
return true;
67+
}
68+
69+
if (
70+
util.isTypeFlagSet(type, ts.TypeFlags.Null | ts.TypeFlags.Undefined) &&
71+
options.allowNullable
72+
) {
73+
return true;
74+
}
75+
76+
return false;
7077
}
7178

7279
return {
@@ -76,75 +83,52 @@ export default util.createRule<Options, MessageId>({
7683
return;
7784
}
7885

79-
for (const expr of node.expressions) {
80-
const type = getNodeType(expr);
81-
if (!isAllowedType(type)) {
86+
for (const expression of node.expressions) {
87+
if (
88+
!isUnderlyingExpressionTypeConfirmingTo(
89+
expression,
90+
isUnderlyingTypePrimitive,
91+
)
92+
) {
8293
context.report({
83-
node: expr,
94+
node: expression,
8495
messageId: 'invalidType',
8596
});
8697
}
8798
}
8899
},
89100
};
90101

91-
/**
92-
* Helper function to get base type of node
93-
* @param node the node to be evaluated.
94-
*/
95-
function getNodeType(node: TSESTree.Expression): BaseType[] {
96-
const tsNode = service.esTreeNodeToTSNodeMap.get(node);
97-
const type = typeChecker.getTypeAtLocation(tsNode);
102+
function isUnderlyingExpressionTypeConfirmingTo(
103+
expression: TSESTree.Expression,
104+
predicate: (underlyingType: ts.Type) => boolean,
105+
): boolean {
106+
const expressionType = getExpressionNodeType(expression);
98107

99-
return getBaseType(type);
100-
}
108+
return rec(
109+
// "Extracts" generic constraint, indexed access and conditional types:
110+
typeChecker.getBaseConstraintOfType(expressionType) ?? expressionType,
111+
);
101112

102-
function getBaseType(type: ts.Type): BaseType[] {
103-
const constraint = type.getConstraint();
104-
if (
105-
constraint &&
106-
// for generic types with union constraints, it will return itself
107-
constraint !== type
108-
) {
109-
return getBaseType(constraint);
110-
}
111-
112-
if (type.isStringLiteral()) {
113-
return ['string'];
114-
}
115-
if (type.isNumberLiteral()) {
116-
return ['number'];
117-
}
118-
if (type.flags & ts.TypeFlags.BigIntLiteral) {
119-
return ['bigint'];
120-
}
121-
if (type.flags & ts.TypeFlags.BooleanLiteral) {
122-
return ['boolean'];
123-
}
124-
if (type.flags & ts.TypeFlags.Null) {
125-
return ['null'];
126-
}
127-
if (type.flags & ts.TypeFlags.Undefined) {
128-
return ['undefined'];
129-
}
113+
function rec(type: ts.Type): boolean {
114+
if (type.isUnion()) {
115+
return type.types.every(rec);
116+
}
130117

131-
if (type.isUnion()) {
132-
return type.types
133-
.map(getBaseType)
134-
.reduce((all, array) => [...all, ...array], []);
135-
}
118+
if (type.isIntersection()) {
119+
return type.types.some(rec);
120+
}
136121

137-
const stringType = typeChecker.typeToString(type);
138-
if (
139-
stringType === 'string' ||
140-
stringType === 'number' ||
141-
stringType === 'bigint' ||
142-
stringType === 'boolean'
143-
) {
144-
return [stringType];
122+
return predicate(type);
145123
}
124+
}
146125

147-
return ['other'];
126+
/**
127+
* Helper function to extract the TS type of an TSESTree expression.
128+
*/
129+
function getExpressionNodeType(node: TSESTree.Expression): ts.Type {
130+
const tsNode = service.esTreeNodeToTSNodeMap.get(node);
131+
return typeChecker.getTypeAtLocation(tsNode);
148132
}
149133
},
150134
});

Diff for: packages/eslint-plugin/tests/rules/restrict-template-expressions.test.ts

+21
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,12 @@ ruleTester.run('restrict-template-expressions', rule, {
3030
return \`arg = \${arg}\`;
3131
}
3232
`,
33+
// Base case - intersection type
34+
`
35+
function test<T extends string & { _kind: "MyBrandedString" }>(arg: T) {
36+
return \`arg = \${arg}\`;
37+
}
38+
`,
3339
// Base case - don't check tagged templates
3440
`
3541
tag\`arg = \${null}\`;
@@ -68,6 +74,14 @@ ruleTester.run('restrict-template-expressions', rule, {
6874
}
6975
`,
7076
},
77+
{
78+
options: [{ allowNumber: true }],
79+
code: `
80+
function test<T extends number & { _kind: "MyBrandedNumber" }>(arg: T) {
81+
return \`arg = \${arg}\`;
82+
}
83+
`,
84+
},
7185
{
7286
options: [{ allowNumber: true }],
7387
code: `
@@ -199,6 +213,13 @@ ruleTester.run('restrict-template-expressions', rule, {
199213
`,
200214
errors: [{ messageId: 'invalidType', line: 3, column: 30 }],
201215
},
216+
{
217+
code: `
218+
declare const arg: { a: string } & { b: string };
219+
const msg = \`arg = \${arg}\`;
220+
`,
221+
errors: [{ messageId: 'invalidType', line: 3, column: 30 }],
222+
},
202223
{
203224
options: [{ allowNumber: true, allowBoolean: true, allowNullable: true }],
204225
code: `

0 commit comments

Comments
 (0)