Skip to content

Commit cc70e4f

Browse files
authored
feat(eslint-plugin): [restrict-template-expressions] add support for intersection types (typescript-eslint#1803)
1 parent 73675d1 commit cc70e4f

File tree

3 files changed

+90
-78
lines changed

3 files changed

+90
-78
lines changed

packages/eslint-plugin/docs/rules/restrict-template-expressions.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@ Examples of **correct** code:
66
const arg = 'foo';
77
const msg1 = `arg = ${arg}`;
88
const msg2 = `arg = ${arg || 'default'}`;
9+
10+
const stringWithKindProp: string & { _kind?: 'MyString' } = 'foo';
11+
const msg3 = `stringWithKindProp = ${stringWithKindProp}`;
912
```
1013

1114
Examples of **incorrect** code:
@@ -28,6 +31,8 @@ type Options = {
2831
allowNumber?: boolean;
2932
// if true, also allow boolean type in template expressions
3033
allowBoolean?: boolean;
34+
// if true, also allow any in template expressions
35+
allowAny?: boolean;
3136
// if true, also allow null and undefined in template expressions
3237
allowNullable?: boolean;
3338
};

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

Lines changed: 64 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,10 @@ import * as util from '../util';
77

88
type Options = [
99
{
10-
allowNullable?: boolean;
1110
allowNumber?: boolean;
1211
allowBoolean?: boolean;
1312
allowAny?: boolean;
13+
allowNullable?: boolean;
1414
},
1515
];
1616

@@ -33,10 +33,10 @@ export default util.createRule<Options, MessageId>({
3333
{
3434
type: 'object',
3535
properties: {
36-
allowAny: { type: 'boolean' },
36+
allowNumber: { type: 'boolean' },
3737
allowBoolean: { type: 'boolean' },
38+
allowAny: { type: 'boolean' },
3839
allowNullable: { type: 'boolean' },
39-
allowNumber: { type: 'boolean' },
4040
},
4141
},
4242
],
@@ -46,31 +46,40 @@ export default util.createRule<Options, MessageId>({
4646
const service = util.getParserServices(context);
4747
const typeChecker = service.program.getTypeChecker();
4848

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

7685
return {
@@ -80,70 +89,47 @@ export default util.createRule<Options, MessageId>({
8089
return;
8190
}
8291

83-
for (const expr of node.expressions) {
84-
const type = getNodeType(expr);
85-
if (!isAllowedType(type)) {
92+
for (const expression of node.expressions) {
93+
if (
94+
!isUnderlyingExpressionTypeConfirmingTo(
95+
expression,
96+
isUnderlyingTypePrimitive,
97+
)
98+
) {
8699
context.report({
87-
node: expr,
100+
node: expression,
88101
messageId: 'invalidType',
89102
});
90103
}
91104
}
92105
},
93106
};
94107

95-
/**
96-
* Helper function to get base type of node
97-
* @param node the node to be evaluated.
98-
*/
99-
function getNodeType(node: TSESTree.Expression): BaseType[] {
100-
const tsNode = service.esTreeNodeToTSNodeMap.get(node);
101-
const type = util.getConstrainedTypeAtLocation(typeChecker, tsNode);
108+
function isUnderlyingExpressionTypeConfirmingTo(
109+
expression: TSESTree.Expression,
110+
predicate: (underlyingType: ts.Type) => boolean,
111+
): boolean {
112+
return rec(getExpressionNodeType(expression));
102113

103-
return getBaseType(type);
104-
}
105-
106-
function getBaseType(type: ts.Type): BaseType[] {
107-
if (type.isStringLiteral()) {
108-
return ['string'];
109-
}
110-
if (type.isNumberLiteral()) {
111-
return ['number'];
112-
}
113-
if (type.flags & ts.TypeFlags.BigIntLiteral) {
114-
return ['bigint'];
115-
}
116-
if (type.flags & ts.TypeFlags.BooleanLiteral) {
117-
return ['boolean'];
118-
}
119-
if (type.flags & ts.TypeFlags.Null) {
120-
return ['null'];
121-
}
122-
if (type.flags & ts.TypeFlags.Undefined) {
123-
return ['undefined'];
124-
}
125-
if (type.flags & ts.TypeFlags.Any) {
126-
return ['any'];
127-
}
114+
function rec(type: ts.Type): boolean {
115+
if (type.isUnion()) {
116+
return type.types.every(rec);
117+
}
128118

129-
if (type.isUnion()) {
130-
return type.types
131-
.map(getBaseType)
132-
.reduce((all, array) => [...all, ...array], []);
133-
}
119+
if (type.isIntersection()) {
120+
return type.types.some(rec);
121+
}
134122

135-
const stringType = typeChecker.typeToString(type);
136-
if (
137-
stringType === 'string' ||
138-
stringType === 'number' ||
139-
stringType === 'bigint' ||
140-
stringType === 'boolean' ||
141-
stringType === 'any'
142-
) {
143-
return [stringType];
123+
return predicate(type);
144124
}
125+
}
145126

146-
return ['other'];
127+
/**
128+
* Helper function to extract the TS type of an TSESTree expression.
129+
*/
130+
function getExpressionNodeType(node: TSESTree.Expression): ts.Type {
131+
const tsNode = service.esTreeNodeToTSNodeMap.get(node);
132+
return util.getConstrainedTypeAtLocation(typeChecker, tsNode);
147133
}
148134
},
149135
});

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

Lines changed: 21 additions & 0 deletions
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: `
@@ -236,6 +250,13 @@ ruleTester.run('restrict-template-expressions', rule, {
236250
`,
237251
errors: [{ messageId: 'invalidType', line: 3, column: 30 }],
238252
},
253+
{
254+
code: `
255+
declare const arg: { a: string } & { b: string };
256+
const msg = \`arg = \${arg}\`;
257+
`,
258+
errors: [{ messageId: 'invalidType', line: 3, column: 30 }],
259+
},
239260
{
240261
options: [{ allowNumber: true, allowBoolean: true, allowNullable: true }],
241262
code: `

0 commit comments

Comments
 (0)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy