Skip to content
50 changes: 10 additions & 40 deletions packages/eslint-plugin/src/rules/no-base-to-string.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
getConstrainedTypeAtLocation,
getParserServices,
getTypeName,
matchesTypeOrBaseType,
nullThrows,
} from '../util';

Expand Down Expand Up @@ -77,7 +78,8 @@ export default createRule<Options, MessageIds>({
],
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 {
Expand Down Expand Up @@ -211,44 +213,6 @@ export default createRule<Options, MessageIds>({
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<ts.Type>(),
): 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<ts.Type>,
Expand Down Expand Up @@ -286,7 +250,13 @@ export default createRule<Options, MessageIds>({
return Usefulness.Always;
}

if (isIgnoredTypeOrBase(type)) {
if (
matchesTypeOrBaseType(
services,
type => ignoredTypeNames.includes(getTypeName(checker, type)),
type,
)
) {
return Usefulness.Always;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
isTypeAnyType,
isTypeFlagSet,
isTypeNeverType,
matchesTypeOrBaseType,
} from '../util';

type OptionTester = (
Expand Down Expand Up @@ -165,7 +166,11 @@ export default createRule<Options, MessageId>({

return (
isTypeFlagSet(innerType, TypeFlags.StringLike) ||
typeMatchesSomeSpecifier(innerType, allow, program) ||
matchesTypeOrBaseType(
services,
type => typeMatchesSomeSpecifier(type, allow, program),
innerType,
) ||
enabledOptionTesters.some(({ tester }) =>
tester(innerType, checker, recursivelyCheckType),
)
Expand Down
68 changes: 68 additions & 0 deletions packages/eslint-plugin/src/util/baseTypeUtils.ts
Original file line number Diff line number Diff line change
@@ -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<Type>(),
): 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),
);
}
1 change: 1 addition & 0 deletions packages/eslint-plugin/src/util/index.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Testing] Oh, sory, I just realized - this is missing test coverage for extending multiple classes.

  • A extends B, C where B is listed
  • A extends B, C where C is listed
  • A extends B, C + B extends D, E where E is listed

etc.

Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand Down Expand Up @@ -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: [] }],
},
],
});