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)