diff --git a/packages/eslint-plugin/docs/rules/restrict-template-expressions.md b/packages/eslint-plugin/docs/rules/restrict-template-expressions.md index 4c52e74445f..816a0b0f9d6 100644 --- a/packages/eslint-plugin/docs/rules/restrict-template-expressions.md +++ b/packages/eslint-plugin/docs/rules/restrict-template-expressions.md @@ -6,6 +6,9 @@ Examples of **correct** code: const arg = 'foo'; const msg1 = `arg = ${arg}`; const msg2 = `arg = ${arg || 'default'}`; + +const stringWithKindProp: string & { _kind?: 'MyString' } = 'foo'; +const msg3 = `stringWithKindProp = ${stringWithKindProp}`; ``` Examples of **incorrect** code: @@ -28,6 +31,8 @@ type Options = { allowNumber?: boolean; // if true, also allow boolean type in template expressions allowBoolean?: boolean; + // if true, also allow any in template expressions + allowAny?: boolean; // if true, also allow null and undefined in template expressions allowNullable?: boolean; }; diff --git a/packages/eslint-plugin/src/rules/restrict-template-expressions.ts b/packages/eslint-plugin/src/rules/restrict-template-expressions.ts index d73d8d98a25..90dd363f820 100644 --- a/packages/eslint-plugin/src/rules/restrict-template-expressions.ts +++ b/packages/eslint-plugin/src/rules/restrict-template-expressions.ts @@ -7,10 +7,10 @@ import * as util from '../util'; type Options = [ { - allowNullable?: boolean; allowNumber?: boolean; allowBoolean?: boolean; allowAny?: boolean; + allowNullable?: boolean; }, ]; @@ -33,10 +33,10 @@ export default util.createRule({ { type: 'object', properties: { - allowAny: { type: 'boolean' }, + allowNumber: { type: 'boolean' }, allowBoolean: { type: 'boolean' }, + allowAny: { type: 'boolean' }, allowNullable: { type: 'boolean' }, - allowNumber: { type: 'boolean' }, }, }, ], @@ -46,31 +46,40 @@ export default util.createRule({ const service = util.getParserServices(context); const typeChecker = service.program.getTypeChecker(); - type BaseType = - | 'string' - | 'number' - | 'bigint' - | 'boolean' - | 'null' - | 'undefined' - | 'any' - | 'other'; - - const allowedTypes: BaseType[] = [ - 'string', - ...(options.allowNumber ? (['number', 'bigint'] as const) : []), - ...(options.allowBoolean ? (['boolean'] as const) : []), - ...(options.allowNullable ? (['null', 'undefined'] as const) : []), - ...(options.allowAny ? (['any'] as const) : []), - ]; - - function isAllowedType(types: BaseType[]): boolean { - for (const type of types) { - if (!allowedTypes.includes(type)) { - return false; - } + function isUnderlyingTypePrimitive(type: ts.Type): boolean { + if (util.isTypeFlagSet(type, ts.TypeFlags.StringLike)) { + return true; + } + + if ( + options.allowNumber && + util.isTypeFlagSet( + type, + ts.TypeFlags.NumberLike | ts.TypeFlags.BigIntLike, + ) + ) { + return true; } - return true; + + if ( + options.allowBoolean && + util.isTypeFlagSet(type, ts.TypeFlags.BooleanLike) + ) { + return true; + } + + if (options.allowAny && util.isTypeFlagSet(type, ts.TypeFlags.Any)) { + return true; + } + + if ( + options.allowNullable && + util.isTypeFlagSet(type, ts.TypeFlags.Null | ts.TypeFlags.Undefined) + ) { + return true; + } + + return false; } return { @@ -80,11 +89,15 @@ export default util.createRule({ return; } - for (const expr of node.expressions) { - const type = getNodeType(expr); - if (!isAllowedType(type)) { + for (const expression of node.expressions) { + if ( + !isUnderlyingExpressionTypeConfirmingTo( + expression, + isUnderlyingTypePrimitive, + ) + ) { context.report({ - node: expr, + node: expression, messageId: 'invalidType', }); } @@ -92,58 +105,31 @@ export default util.createRule({ }, }; - /** - * Helper function to get base type of node - * @param node the node to be evaluated. - */ - function getNodeType(node: TSESTree.Expression): BaseType[] { - const tsNode = service.esTreeNodeToTSNodeMap.get(node); - const type = util.getConstrainedTypeAtLocation(typeChecker, tsNode); + function isUnderlyingExpressionTypeConfirmingTo( + expression: TSESTree.Expression, + predicate: (underlyingType: ts.Type) => boolean, + ): boolean { + return rec(getExpressionNodeType(expression)); - return getBaseType(type); - } - - function getBaseType(type: ts.Type): BaseType[] { - if (type.isStringLiteral()) { - return ['string']; - } - if (type.isNumberLiteral()) { - return ['number']; - } - if (type.flags & ts.TypeFlags.BigIntLiteral) { - return ['bigint']; - } - if (type.flags & ts.TypeFlags.BooleanLiteral) { - return ['boolean']; - } - if (type.flags & ts.TypeFlags.Null) { - return ['null']; - } - if (type.flags & ts.TypeFlags.Undefined) { - return ['undefined']; - } - if (type.flags & ts.TypeFlags.Any) { - return ['any']; - } + function rec(type: ts.Type): boolean { + if (type.isUnion()) { + return type.types.every(rec); + } - if (type.isUnion()) { - return type.types - .map(getBaseType) - .reduce((all, array) => [...all, ...array], []); - } + if (type.isIntersection()) { + return type.types.some(rec); + } - const stringType = typeChecker.typeToString(type); - if ( - stringType === 'string' || - stringType === 'number' || - stringType === 'bigint' || - stringType === 'boolean' || - stringType === 'any' - ) { - return [stringType]; + return predicate(type); } + } - return ['other']; + /** + * Helper function to extract the TS type of an TSESTree expression. + */ + function getExpressionNodeType(node: TSESTree.Expression): ts.Type { + const tsNode = service.esTreeNodeToTSNodeMap.get(node); + return util.getConstrainedTypeAtLocation(typeChecker, tsNode); } }, }); diff --git a/packages/eslint-plugin/tests/rules/restrict-template-expressions.test.ts b/packages/eslint-plugin/tests/rules/restrict-template-expressions.test.ts index 6a2861efa32..2f5900f3d2a 100644 --- a/packages/eslint-plugin/tests/rules/restrict-template-expressions.test.ts +++ b/packages/eslint-plugin/tests/rules/restrict-template-expressions.test.ts @@ -30,6 +30,12 @@ ruleTester.run('restrict-template-expressions', rule, { return \`arg = \${arg}\`; } `, + // Base case - intersection type + ` + function test(arg: T) { + return \`arg = \${arg}\`; + } + `, // Base case - don't check tagged templates ` tag\`arg = \${null}\`; @@ -68,6 +74,14 @@ ruleTester.run('restrict-template-expressions', rule, { } `, }, + { + options: [{ allowNumber: true }], + code: ` + function test(arg: T) { + return \`arg = \${arg}\`; + } + `, + }, { options: [{ allowNumber: true }], code: ` @@ -236,6 +250,13 @@ ruleTester.run('restrict-template-expressions', rule, { `, errors: [{ messageId: 'invalidType', line: 3, column: 30 }], }, + { + code: ` + declare const arg: { a: string } & { b: string }; + const msg = \`arg = \${arg}\`; + `, + errors: [{ messageId: 'invalidType', line: 3, column: 30 }], + }, { options: [{ allowNumber: true, allowBoolean: true, allowNullable: true }], code: `