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 600f24fc7ce0..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,44 +213,6 @@ export default createRule({ return Usefulness.Always; } - function getBaseTypesForType(type: ts.Type): readonly ts.Type[] { - if (!tsutils.isObjectType(type)) { - return []; - } - - const interfaceTarget = tsutils.isTypeReference(type) - ? type.target - : type; - - const interfaceType = - tsutils.isObjectFlagSet( - interfaceTarget, - ts.ObjectFlags.Interface | ts.ObjectFlags.Class, - ) && (interfaceTarget as ts.InterfaceType); - - if (!interfaceType) { - return []; - } - - return checker.getBaseTypes(interfaceType); - } - - function isIgnoredTypeOrBase( - type: ts.Type, - seen = new Set(), - ): boolean { - if (seen.has(type)) { - return false; - } - - seen.add(type); - - return ( - ignoredTypeNames.includes(getTypeName(checker, type)) || - getBaseTypesForType(type).some(base => isIgnoredTypeOrBase(base, seen)) - ); - } - function collectToStringCertainty( type: ts.Type, visited: Set, @@ -286,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 069388b7eb0d..e2b6ba88b641 100644 --- a/packages/eslint-plugin/src/rules/restrict-template-expressions.ts +++ b/packages/eslint-plugin/src/rules/restrict-template-expressions.ts @@ -18,6 +18,7 @@ import { isTypeAnyType, isTypeFlagSet, isTypeNeverType, + matchesTypeOrBaseType, } from '../util'; type OptionTester = ( @@ -165,7 +166,11 @@ export default createRule({ return ( isTypeFlagSet(innerType, TypeFlags.StringLike) || - typeMatchesSomeSpecifier(innerType, allow, program) || + 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..e06706b7fe35 --- /dev/null +++ b/packages/eslint-plugin/src/util/baseTypeUtils.ts @@ -0,0 +1,68 @@ +import type { ParserServicesWithTypeInformation } from '@typescript-eslint/utils'; +import type { InterfaceType, Type } from 'typescript'; + +import { isObjectFlagSet, isObjectType } from 'ts-api-utils'; +import * as tsutils from 'ts-api-utils'; +import * as ts from 'typescript'; + +function getBaseTypesForType( + checker: ts.TypeChecker, + type: ts.Type, +): readonly ts.Type[] { + if (!tsutils.isObjectType(type)) { + return []; + } + + const interfaceTarget = tsutils.isTypeReference(type) ? type.target : type; + + const interfaceType = + tsutils.isObjectFlagSet( + interfaceTarget, + ts.ObjectFlags.Interface | ts.ObjectFlags.Class, + ) && (interfaceTarget as ts.InterfaceType); + + if (!interfaceType) { + return []; + } + + return checker.getBaseTypes(interfaceType); +} + +export function hasBaseTypes(type: Type): type is InterfaceType { + return ( + isObjectType(type) && + isObjectFlagSet(type, ts.ObjectFlags.Interface | ts.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; + } + + const checker = services.program.getTypeChecker(); + + return getBaseTypesForType(checker, type).some(base => + matchesTypeOrBaseType(services, matcher, base, seen), + ); +} 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 aa88c8276cdf..8b5aff9e940d 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,102 @@ ruleTester.run('restrict-template-expressions', rule, { 'const msg = `arg = ${undefined}`;', 'const msg = `arg = ${123}`;', "const msg = `arg = ${'abc'}`;", + { + code: ` + class Base {} + class Derived extends Base {} + const foo = new Base(); + const bar = new Derived(); + \`\${foo}\${bar}\`; + `, + options: [{ allow: [{ from: 'file', name: 'Base' }] }], + }, + { + code: ` + class Base {} + class Derived extends Base {} + class DerivedTwice extends Derived {} + const value = new DerivedTwice(); + \`\${value}\`; + `, + options: [{ allow: [{ from: 'file', name: 'Base' }] }], + }, + { + code: ` + interface Base { + value: string; + } + interface Derived extends Base { + extra: number; + } + 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: '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: ` + type Custom = { value: string }; + declare const obj: Custom; + \`\${obj}\`; + `, + options: [{ allow: [{ from: 'file', name: 'Custom' }] }], + }, ], invalid: [ @@ -614,5 +710,61 @@ ruleTester.run('restrict-template-expressions', rule, { ], options: [{ allowAny: true }], }, + { + code: ` + class Base {} + class Derived extends Base {} + const bar = new Derived(); + \`\${bar}\`; + `, + errors: [ + { + data: { type: 'Derived' }, + messageId: 'invalidType', + }, + ], + options: [{ allow: [] }], + }, + { + code: ` + interface Base { + value: string; + } + interface Derived extends Base { + extra: number; + } + declare const obj: Derived; + \`\${obj}\`; + `, + errors: [ + { + data: { type: 'Derived' }, + messageId: 'invalidType', + }, + ], + 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: [] }], + }, ], });