From dfca64842c2704faacf2355ba5d324150d154fb4 Mon Sep 17 00:00:00 2001 From: Sanghee Son Date: Mon, 17 Nov 2025 00:46:21 +0900 Subject: [PATCH 1/8] fix(restrict-template-expressions): check base types in allow list Allow derived types in template expressions when base types are in allow list Fixes #11759 --- .../rules/restrict-template-expressions.ts | 37 +++++++++++++++++-- .../restrict-template-expressions.test.ts | 12 ++++++ 2 files changed, 46 insertions(+), 3 deletions(-) diff --git a/packages/eslint-plugin/src/rules/restrict-template-expressions.ts b/packages/eslint-plugin/src/rules/restrict-template-expressions.ts index 069388b7eb0d..9f24bf3b1b8a 100644 --- a/packages/eslint-plugin/src/rules/restrict-template-expressions.ts +++ b/packages/eslint-plugin/src/rules/restrict-template-expressions.ts @@ -1,12 +1,13 @@ import type { TSESTree } from '@typescript-eslint/utils'; -import type { Type, TypeChecker } from 'typescript'; +import type { Type, InterfaceType, TypeChecker } from 'typescript'; +import { ObjectFlags, TypeFlags } from 'typescript'; import { typeMatchesSomeSpecifier, typeOrValueSpecifiersSchema, } from '@typescript-eslint/type-utils'; import { AST_NODE_TYPES } from '@typescript-eslint/utils'; -import { TypeFlags } from 'typescript'; +import { isObjectFlagSet, isObjectType } from 'ts-api-utils'; import type { TypeOrValueSpecifier } from '../util'; @@ -130,6 +131,36 @@ export default createRule({ ({ option }) => options[option], ); + function hasBaseTypes(type: Type): type is InterfaceType { + return ( + isObjectType(type) && + isObjectFlagSet(type, ObjectFlags.Interface | ObjectFlags.Class) + ); + } + + function isAllowedTypeOrBase( + type: Type, + seen = new Set(), + ): boolean { + if (seen.has(type)) { + return false; + } + + seen.add(type); + + if (typeMatchesSomeSpecifier(type, allow, program)) { + return true; + } + + if (hasBaseTypes(type)) { + return checker + .getBaseTypes(type) + .some(base => isAllowedTypeOrBase(base, seen)); + } + + return false; + } + return { TemplateLiteral(node: TSESTree.TemplateLiteral): void { // don't check tagged template literals @@ -165,7 +196,7 @@ export default createRule({ return ( isTypeFlagSet(innerType, TypeFlags.StringLike) || - typeMatchesSomeSpecifier(innerType, allow, program) || + isAllowedTypeOrBase(innerType) || enabledOptionTesters.some(({ tester }) => tester(innerType, checker, recursivelyCheckType), ) 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 aa88c8276cdf..febcddf31607 100644 --- a/packages/eslint-plugin/tests/rules/restrict-template-expressions.test.ts +++ b/packages/eslint-plugin/tests/rules/restrict-template-expressions.test.ts @@ -344,6 +344,18 @@ ruleTester.run('restrict-template-expressions', rule, { 'const msg = `arg = ${undefined}`;', 'const msg = `arg = ${123}`;', "const msg = `arg = ${'abc'}`;", + // https://github.com/typescript-eslint/typescript-eslint/issues/11759 + // allow should check base types + { + code: ` + class Base { } + class Derived extends Base { } + const foo = new Base(); + const bar = new Derived(); + \`\${foo}\${bar}\`; + `, + options: [{ allow: [{ from: 'file', name: 'Base' }] }], + }, ], invalid: [ From 6ac61a0111586a44d817091dad9a8a9f04a997f9 Mon Sep 17 00:00:00 2001 From: Sanghee Son Date: Mon, 17 Nov 2025 01:30:01 +0900 Subject: [PATCH 2/8] fix: improve test coverage and fix import formatting --- .../rules/restrict-template-expressions.ts | 13 ++++----- .../restrict-template-expressions.test.ts | 28 +++++++++++++++++++ 2 files changed, 34 insertions(+), 7 deletions(-) diff --git a/packages/eslint-plugin/src/rules/restrict-template-expressions.ts b/packages/eslint-plugin/src/rules/restrict-template-expressions.ts index 9f24bf3b1b8a..3c94edd5a797 100644 --- a/packages/eslint-plugin/src/rules/restrict-template-expressions.ts +++ b/packages/eslint-plugin/src/rules/restrict-template-expressions.ts @@ -1,12 +1,14 @@ import type { TSESTree } from '@typescript-eslint/utils'; -import type { Type, InterfaceType, TypeChecker } from 'typescript'; -import { ObjectFlags, TypeFlags } from 'typescript'; - import { typeMatchesSomeSpecifier, typeOrValueSpecifiersSchema, } from '@typescript-eslint/type-utils'; import { AST_NODE_TYPES } from '@typescript-eslint/utils'; + +import type { InterfaceType, Type, TypeChecker } from 'typescript'; + +import { ObjectFlags, TypeFlags } from 'typescript'; + import { isObjectFlagSet, isObjectType } from 'ts-api-utils'; import type { TypeOrValueSpecifier } from '../util'; @@ -138,10 +140,7 @@ export default createRule({ ); } - function isAllowedTypeOrBase( - type: Type, - seen = new Set(), - ): boolean { + function isAllowedTypeOrBase(type: Type, seen = new Set()): boolean { if (seen.has(type)) { return false; } 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 febcddf31607..6de7605c9e94 100644 --- a/packages/eslint-plugin/tests/rules/restrict-template-expressions.test.ts +++ b/packages/eslint-plugin/tests/rules/restrict-template-expressions.test.ts @@ -356,6 +356,17 @@ ruleTester.run('restrict-template-expressions', rule, { `, options: [{ allow: [{ from: 'file', name: 'Base' }] }], }, + // allow should check base types - multi-level inheritance + { + code: ` + class Base { } + class Derived extends Base { } + class DerivedTwice extends Derived { } + const value = new DerivedTwice(); + \`\${value}\`; + `, + options: [{ allow: [{ from: 'file', name: 'Base' }] }], + }, ], invalid: [ @@ -626,5 +637,22 @@ ruleTester.run('restrict-template-expressions', rule, { ], options: [{ allowAny: true }], }, + // https://github.com/typescript-eslint/typescript-eslint/issues/11759 + // derived type should error when base type is not in allow list + { + code: ` + class Base { } + class Derived extends Base { } + const bar = new Derived(); + \`\${bar}\`; + `, + errors: [ + { + messageId: 'invalidType', + data: { type: 'Derived' }, + }, + ], + options: [{ allow: [] }], + }, ], }); From 95ad77617a9dfda81752293b3af39f3b0450fe4a Mon Sep 17 00:00:00 2001 From: Sanghee Son Date: Mon, 17 Nov 2025 21:14:43 +0900 Subject: [PATCH 3/8] fix: improve test coverage and fix linting issues --- .../rules/restrict-template-expressions.ts | 8 +-- .../restrict-template-expressions.test.ts | 57 ++++++++++++++++--- 2 files changed, 53 insertions(+), 12 deletions(-) diff --git a/packages/eslint-plugin/src/rules/restrict-template-expressions.ts b/packages/eslint-plugin/src/rules/restrict-template-expressions.ts index 3c94edd5a797..e9b1069d2609 100644 --- a/packages/eslint-plugin/src/rules/restrict-template-expressions.ts +++ b/packages/eslint-plugin/src/rules/restrict-template-expressions.ts @@ -1,15 +1,13 @@ import type { TSESTree } from '@typescript-eslint/utils'; +import type { InterfaceType, Type, TypeChecker } from 'typescript'; + import { typeMatchesSomeSpecifier, typeOrValueSpecifiersSchema, } from '@typescript-eslint/type-utils'; import { AST_NODE_TYPES } from '@typescript-eslint/utils'; - -import type { InterfaceType, Type, TypeChecker } from 'typescript'; - -import { ObjectFlags, TypeFlags } from 'typescript'; - import { isObjectFlagSet, isObjectType } from 'ts-api-utils'; +import { ObjectFlags, TypeFlags } from 'typescript'; import type { TypeOrValueSpecifier } from '../util'; 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 6de7605c9e94..ac4fcd99f590 100644 --- a/packages/eslint-plugin/tests/rules/restrict-template-expressions.test.ts +++ b/packages/eslint-plugin/tests/rules/restrict-template-expressions.test.ts @@ -348,8 +348,8 @@ ruleTester.run('restrict-template-expressions', rule, { // allow should check base types { code: ` - class Base { } - class Derived extends Base { } + class Base {} + class Derived extends Base {} const foo = new Base(); const bar = new Derived(); \`\${foo}\${bar}\`; @@ -359,14 +359,37 @@ ruleTester.run('restrict-template-expressions', rule, { // allow should check base types - multi-level inheritance { code: ` - class Base { } - class Derived extends Base { } - class DerivedTwice extends Derived { } + class Base {} + class Derived extends Base {} + class DerivedTwice extends Derived {} const value = new DerivedTwice(); \`\${value}\`; `, options: [{ allow: [{ from: 'file', name: 'Base' }] }], }, + // allow should check base types - interface inheritance + { + code: ` + interface Base { + value: string; + } + interface Derived extends Base { + extra: number; + } + declare const obj: Derived; + \`\${obj}\`; + `, + options: [{ allow: [{ from: 'file', name: 'Base' }] }], + }, + // allow list with type alias without base types + { + code: ` + type Custom = { value: string }; + declare const obj: Custom; + \`\${obj}\`; + `, + options: [{ allow: [{ from: 'file', name: 'Custom' }] }], + }, ], invalid: [ @@ -641,15 +664,35 @@ ruleTester.run('restrict-template-expressions', rule, { // derived type should error when base type is not in allow list { code: ` - class Base { } - class Derived extends Base { } + class Base {} + class Derived extends Base {} const bar = new Derived(); \`\${bar}\`; `, errors: [ { + data: { type: 'Derived' }, messageId: 'invalidType', + }, + ], + options: [{ allow: [] }], + }, + // derived interface should error when base type is not in allow list + { + code: ` + interface Base { + value: string; + } + interface Derived extends Base { + extra: number; + } + declare const obj: Derived; + \`\${obj}\`; + `, + errors: [ + { data: { type: 'Derived' }, + messageId: 'invalidType', }, ], options: [{ allow: [] }], From 8eaddd50a9ab014d1bfd81efd915051269d36aac Mon Sep 17 00:00:00 2001 From: Sanghee Son Date: Mon, 17 Nov 2025 21:40:59 +0900 Subject: [PATCH 4/8] chore: trigger CI rebuild From c6b7ac874b603cec710d581f650ebece294c5f0b Mon Sep 17 00:00:00 2001 From: Sanghee Son Date: Tue, 18 Nov 2025 21:57:58 +0900 Subject: [PATCH 5/8] refactor: deduplicate base type checking logic - Remove unnecessary test comments - Extract hasBaseTypes to shared util - Create matchesTypeOrBaseType with matcher pattern --- .../src/rules/no-base-to-string.ts | 42 ++++------------- .../rules/restrict-template-expressions.ts | 39 ++++----------- .../eslint-plugin/src/util/baseTypeUtils.ts | 47 +++++++++++++++++++ packages/eslint-plugin/src/util/index.ts | 1 + .../restrict-template-expressions.test.ts | 3 -- 5 files changed, 66 insertions(+), 66 deletions(-) create mode 100644 packages/eslint-plugin/src/util/baseTypeUtils.ts diff --git a/packages/eslint-plugin/src/rules/no-base-to-string.ts b/packages/eslint-plugin/src/rules/no-base-to-string.ts index 48fca65676ed..9f1953286115 100644 --- a/packages/eslint-plugin/src/rules/no-base-to-string.ts +++ b/packages/eslint-plugin/src/rules/no-base-to-string.ts @@ -9,6 +9,7 @@ import { getConstrainedTypeAtLocation, getParserServices, getTypeName, + matchesTypeOrBaseType, nullThrows, } from '../util'; @@ -77,7 +78,8 @@ export default createRule({ ], create(context, [option]) { const services = getParserServices(context); - const checker = services.program.getTypeChecker(); + const { program } = services; + const checker = program.getTypeChecker(); const ignoredTypeNames = option.ignoredTypeNames ?? []; function checkExpression(node: TSESTree.Expression, type?: ts.Type): void { @@ -211,36 +213,6 @@ export default createRule({ return Usefulness.Always; } - function hasBaseTypes(type: ts.Type): type is ts.InterfaceType { - return ( - tsutils.isObjectType(type) && - tsutils.isObjectFlagSet( - type, - ts.ObjectFlags.Interface | ts.ObjectFlags.Class, - ) - ); - } - - function isIgnoredTypeOrBase( - type: ts.Type, - seen = new Set(), - ): boolean { - if (seen.has(type)) { - return false; - } - - seen.add(type); - - const typeName = getTypeName(checker, type); - return ( - ignoredTypeNames.includes(typeName) || - (hasBaseTypes(type) && - checker - .getBaseTypes(type) - .some(base => isIgnoredTypeOrBase(base, seen))) - ); - } - function collectToStringCertainty( type: ts.Type, visited: Set, @@ -278,7 +250,13 @@ export default createRule({ return Usefulness.Always; } - if (isIgnoredTypeOrBase(type)) { + if ( + matchesTypeOrBaseType( + services, + type => ignoredTypeNames.includes(getTypeName(checker, type)), + type, + ) + ) { return Usefulness.Always; } diff --git a/packages/eslint-plugin/src/rules/restrict-template-expressions.ts b/packages/eslint-plugin/src/rules/restrict-template-expressions.ts index e9b1069d2609..e2b6ba88b641 100644 --- a/packages/eslint-plugin/src/rules/restrict-template-expressions.ts +++ b/packages/eslint-plugin/src/rules/restrict-template-expressions.ts @@ -1,13 +1,12 @@ import type { TSESTree } from '@typescript-eslint/utils'; -import type { InterfaceType, Type, TypeChecker } from 'typescript'; +import type { Type, TypeChecker } from 'typescript'; import { typeMatchesSomeSpecifier, typeOrValueSpecifiersSchema, } from '@typescript-eslint/type-utils'; import { AST_NODE_TYPES } from '@typescript-eslint/utils'; -import { isObjectFlagSet, isObjectType } from 'ts-api-utils'; -import { ObjectFlags, TypeFlags } from 'typescript'; +import { TypeFlags } from 'typescript'; import type { TypeOrValueSpecifier } from '../util'; @@ -19,6 +18,7 @@ import { isTypeAnyType, isTypeFlagSet, isTypeNeverType, + matchesTypeOrBaseType, } from '../util'; type OptionTester = ( @@ -131,33 +131,6 @@ export default createRule({ ({ option }) => options[option], ); - function hasBaseTypes(type: Type): type is InterfaceType { - return ( - isObjectType(type) && - isObjectFlagSet(type, ObjectFlags.Interface | ObjectFlags.Class) - ); - } - - function isAllowedTypeOrBase(type: Type, seen = new Set()): boolean { - if (seen.has(type)) { - return false; - } - - seen.add(type); - - if (typeMatchesSomeSpecifier(type, allow, program)) { - return true; - } - - if (hasBaseTypes(type)) { - return checker - .getBaseTypes(type) - .some(base => isAllowedTypeOrBase(base, seen)); - } - - return false; - } - return { TemplateLiteral(node: TSESTree.TemplateLiteral): void { // don't check tagged template literals @@ -193,7 +166,11 @@ export default createRule({ return ( isTypeFlagSet(innerType, TypeFlags.StringLike) || - isAllowedTypeOrBase(innerType) || + matchesTypeOrBaseType( + services, + type => typeMatchesSomeSpecifier(type, allow, program), + innerType, + ) || enabledOptionTesters.some(({ tester }) => tester(innerType, checker, recursivelyCheckType), ) diff --git a/packages/eslint-plugin/src/util/baseTypeUtils.ts b/packages/eslint-plugin/src/util/baseTypeUtils.ts new file mode 100644 index 000000000000..d3755de5aa33 --- /dev/null +++ b/packages/eslint-plugin/src/util/baseTypeUtils.ts @@ -0,0 +1,47 @@ +import type { ParserServicesWithTypeInformation } from '@typescript-eslint/utils'; +import type { InterfaceType, Type } from 'typescript'; + +import { isObjectFlagSet, isObjectType } from 'ts-api-utils'; +import { ObjectFlags } from 'typescript'; + +export function hasBaseTypes(type: Type): type is InterfaceType { + return ( + isObjectType(type) && + isObjectFlagSet(type, ObjectFlags.Interface | ObjectFlags.Class) + ); +} + +/** + * Recursively checks if a type or any of its base types matches the provided + * matcher function. + * @param services Parser services with type information + * @param matcher Function to test if a type matches the desired criteria + * @param type The type to check + * @param seen Set of already visited types to prevent infinite recursion + * @returns `true` if the type or any of its base types match the matcher + */ +export function matchesTypeOrBaseType( + services: ParserServicesWithTypeInformation, + matcher: (type: Type) => boolean, + type: Type, + seen = new Set(), +): boolean { + if (seen.has(type)) { + return false; + } + + seen.add(type); + + if (matcher(type)) { + return true; + } + + if (hasBaseTypes(type)) { + const checker = services.program.getTypeChecker(); + return checker + .getBaseTypes(type) + .some(base => matchesTypeOrBaseType(services, matcher, base, seen)); + } + + return false; +} diff --git a/packages/eslint-plugin/src/util/index.ts b/packages/eslint-plugin/src/util/index.ts index 035fca0cec4a..6507d195d5f7 100644 --- a/packages/eslint-plugin/src/util/index.ts +++ b/packages/eslint-plugin/src/util/index.ts @@ -1,6 +1,7 @@ import { ESLintUtils } from '@typescript-eslint/utils'; export * from './astUtils'; +export * from './baseTypeUtils'; export * from './collectUnusedVariables'; export * from './createRule'; export * from './getFixOrSuggest'; 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 ac4fcd99f590..a5a60bed04a9 100644 --- a/packages/eslint-plugin/tests/rules/restrict-template-expressions.test.ts +++ b/packages/eslint-plugin/tests/rules/restrict-template-expressions.test.ts @@ -344,8 +344,6 @@ ruleTester.run('restrict-template-expressions', rule, { 'const msg = `arg = ${undefined}`;', 'const msg = `arg = ${123}`;', "const msg = `arg = ${'abc'}`;", - // https://github.com/typescript-eslint/typescript-eslint/issues/11759 - // allow should check base types { code: ` class Base {} @@ -356,7 +354,6 @@ ruleTester.run('restrict-template-expressions', rule, { `, options: [{ allow: [{ from: 'file', name: 'Base' }] }], }, - // allow should check base types - multi-level inheritance { code: ` class Base {} From 29092d8ec66b57681c10888fe33635befd7b72bb Mon Sep 17 00:00:00 2001 From: Sanghee Son Date: Tue, 18 Nov 2025 22:39:08 +0900 Subject: [PATCH 6/8] remove unnecessary test comments --- .../tests/rules/restrict-template-expressions.test.ts | 4 ---- 1 file changed, 4 deletions(-) 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 a5a60bed04a9..35eda1205c40 100644 --- a/packages/eslint-plugin/tests/rules/restrict-template-expressions.test.ts +++ b/packages/eslint-plugin/tests/rules/restrict-template-expressions.test.ts @@ -364,7 +364,6 @@ ruleTester.run('restrict-template-expressions', rule, { `, options: [{ allow: [{ from: 'file', name: 'Base' }] }], }, - // allow should check base types - interface inheritance { code: ` interface Base { @@ -657,8 +656,6 @@ ruleTester.run('restrict-template-expressions', rule, { ], options: [{ allowAny: true }], }, - // https://github.com/typescript-eslint/typescript-eslint/issues/11759 - // derived type should error when base type is not in allow list { code: ` class Base {} @@ -674,7 +671,6 @@ ruleTester.run('restrict-template-expressions', rule, { ], options: [{ allow: [] }], }, - // derived interface should error when base type is not in allow list { code: ` interface Base { From 90532b0aed2d838b5815b467c1b178a5ca19874c Mon Sep 17 00:00:00 2001 From: Sanghee Son Date: Tue, 18 Nov 2025 23:12:34 +0900 Subject: [PATCH 7/8] add multiple inheritance tests --- .../restrict-template-expressions.test.ts | 76 +++++++++++++++++++ 1 file changed, 76 insertions(+) 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 35eda1205c40..8b5aff9e940d 100644 --- a/packages/eslint-plugin/tests/rules/restrict-template-expressions.test.ts +++ b/packages/eslint-plugin/tests/rules/restrict-template-expressions.test.ts @@ -377,6 +377,60 @@ ruleTester.run('restrict-template-expressions', rule, { `, options: [{ allow: [{ from: 'file', name: 'Base' }] }], }, + { + code: ` + interface Base { + value: string; + } + interface Other { + other: number; + } + interface Derived extends Base, Other { + extra: boolean; + } + declare const obj: Derived; + \`\${obj}\`; + `, + options: [{ allow: [{ from: 'file', name: 'Base' }] }], + }, + { + code: ` + interface Base { + value: string; + } + interface Other { + other: number; + } + interface Derived extends Base, Other { + extra: boolean; + } + declare const obj: Derived; + \`\${obj}\`; + `, + options: [{ allow: [{ from: 'file', name: 'Other' }] }], + }, + { + code: ` + interface Root { + root: string; + } + interface Another { + another: string; + } + interface Base extends Root, Another { + value: string; + } + interface Other { + other: number; + } + interface Derived extends Base, Other { + extra: boolean; + } + declare const obj: Derived; + \`\${obj}\`; + `, + options: [{ allow: [{ from: 'file', name: 'Another' }] }], + }, // allow list with type alias without base types { code: ` @@ -690,5 +744,27 @@ ruleTester.run('restrict-template-expressions', rule, { ], options: [{ allow: [] }], }, + { + code: ` + interface Base { + value: string; + } + interface Other { + other: number; + } + interface Derived extends Base, Other { + extra: boolean; + } + declare const obj: Derived; + \`\${obj}\`; + `, + errors: [ + { + data: { type: 'Derived' }, + messageId: 'invalidType', + }, + ], + options: [{ allow: [] }], + }, ], }); From f89753c86352ecb0b1c9b6a69953a6a33c63b36c Mon Sep 17 00:00:00 2001 From: Sanghee Son Date: Sun, 23 Nov 2025 22:40:06 +0900 Subject: [PATCH 8/8] chore: retrigger CI