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 f7c012eeb21f..b681f820a3b0 100644 --- a/packages/eslint-plugin/src/rules/no-base-to-string.ts +++ b/packages/eslint-plugin/src/rules/no-base-to-string.ts @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/internal/prefer-ast-types-enum */ import type { TSESTree } from '@typescript-eslint/utils'; import { AST_NODE_TYPES } from '@typescript-eslint/utils'; @@ -69,7 +68,7 @@ export default createRule({ const checker = services.program.getTypeChecker(); const ignoredTypeNames = option.ignoredTypeNames ?? []; - function checkExpression(node: TSESTree.Node, type?: ts.Type): void { + function checkExpression(node: TSESTree.Expression, type?: ts.Type): void { if (node.type === AST_NODE_TYPES.Literal) { return; } @@ -176,15 +175,17 @@ export default createRule({ } function collectToStringCertainty(type: ts.Type): Usefulness { - const toString = - checker.getPropertyOfType(type, 'toString') ?? - checker.getPropertyOfType(type, 'toLocaleString'); - const declarations = toString?.getDeclarations(); - if (!toString || !declarations || declarations.length === 0) { + // https://github.com/JoshuaKGoldberg/ts-api-utils/issues/382 + if ((tsutils.isTypeParameter as (t: ts.Type) => boolean)(type)) { + const constraint = type.getConstraint(); + if (constraint) { + return collectToStringCertainty(constraint); + } + // unconstrained generic means `unknown` return Usefulness.Always; } - // Patch for old version TypeScript, the Boolean type definition missing toString() + // the Boolean type definition missing toString() if ( type.flags & ts.TypeFlags.Boolean || type.flags & ts.TypeFlags.BooleanLiteral @@ -196,32 +197,49 @@ export default createRule({ return Usefulness.Always; } - if ( - declarations.every( - ({ parent }) => - !ts.isInterfaceDeclaration(parent) || parent.name.text !== 'Object', - ) - ) { - return Usefulness.Always; - } - if (type.isIntersection()) { return collectIntersectionTypeCertainty(type, collectToStringCertainty); } - if (!type.isUnion()) { - return Usefulness.Never; + if (type.isUnion()) { + return collectUnionTypeCertainty(type, collectToStringCertainty); + } + + const toString = + checker.getPropertyOfType(type, 'toString') ?? + checker.getPropertyOfType(type, 'toLocaleString'); + if (!toString) { + // e.g. any/unknown + return Usefulness.Always; } - return collectUnionTypeCertainty(type, collectToStringCertainty); + + const declarations = toString.getDeclarations(); + + if (declarations == null || declarations.length !== 1) { + // If there are multiple declarations, at least one of them must not be + // the default object toString. + // + // This may only matter for older versions of TS + // see https://github.com/typescript-eslint/typescript-eslint/issues/8585 + return Usefulness.Always; + } + + const declaration = declarations[0]; + const isBaseToString = + ts.isInterfaceDeclaration(declaration.parent) && + declaration.parent.name.text === 'Object'; + return isBaseToString ? Usefulness.Never : Usefulness.Always; } function isBuiltInStringCall(node: TSESTree.CallExpression): boolean { if ( node.callee.type === AST_NODE_TYPES.Identifier && + // eslint-disable-next-line @typescript-eslint/internal/prefer-ast-types-enum node.callee.name === 'String' && node.arguments[0] ) { const scope = context.sourceCode.getScope(node); + // eslint-disable-next-line @typescript-eslint/internal/prefer-ast-types-enum const variable = scope.set.get('String'); return !variable?.defs.length; } @@ -245,7 +263,10 @@ export default createRule({ } }, CallExpression(node: TSESTree.CallExpression): void { - if (isBuiltInStringCall(node)) { + if ( + isBuiltInStringCall(node) && + node.arguments[0].type !== AST_NODE_TYPES.SpreadElement + ) { checkExpression(node.arguments[0]); } }, diff --git a/packages/eslint-plugin/tests/rules/no-base-to-string.test.ts b/packages/eslint-plugin/tests/rules/no-base-to-string.test.ts index df1f3d4a6979..5b73b1e371e5 100644 --- a/packages/eslint-plugin/tests/rules/no-base-to-string.test.ts +++ b/packages/eslint-plugin/tests/rules/no-base-to-string.test.ts @@ -135,16 +135,20 @@ tag\`\${{}}\`; "'' += new URL();", "'' += new URLSearchParams();", ` -let numbers = [1, 2, 3]; -String(...a); - `, - ` Number(1); `, { code: 'String(/regex/);', options: [{ ignoredTypeNames: ['RegExp'] }], }, + { + code: ` +type Foo = { a: string } | { b: string }; +declare const foo: Foo; +String(foo); + `, + options: [{ ignoredTypeNames: ['Foo'] }], + }, ` function String(value) { return value; @@ -215,6 +219,46 @@ class Foo {} declare const tuple: [string] & [Foo]; tuple.join(''); `, + // don't bother trying to interpret spread args. + ` +let objects = [{}, {}]; +String(...objects); + `, + // https://github.com/typescript-eslint/typescript-eslint/issues/8585 + ` +type Constructable = abstract new (...args: any[]) => Entity; + +interface GuildChannel { + toString(): \`<#\${string}>\`; +} + +declare const foo: Constructable; +class ExtendedGuildChannel extends foo {} +declare const bb: ExtendedGuildChannel; +bb.toString(); + `, + // https://github.com/typescript-eslint/typescript-eslint/issues/8585 with intersection order reversed. + ` +type Constructable = abstract new (...args: any[]) => Entity; + +interface GuildChannel { + toString(): \`<#\${string}>\`; +} + +declare const foo: Constructable<{ bar: 1 } & GuildChannel>; +class ExtendedGuildChannel extends foo {} +declare const bb: ExtendedGuildChannel; +bb.toString(); + `, + ` +function foo(x: T) { + String(x); +} + `, + ` +declare const u: unknown; +String(u); + `, ], invalid: [ { @@ -277,21 +321,6 @@ tuple.join(''); }, ], }, - { - code: ` -let objects = [{}, {}]; -String(...objects); - `, - errors: [ - { - data: { - certainty: 'will', - name: '...objects', - }, - messageId: 'baseToString', - }, - ], - }, { code: "'' += {};", errors: [ @@ -682,13 +711,63 @@ declare const foo: Bar & Foo; errors: [ { data: { - certainty: 'will', + certainty: 'may', name: 'array', }, messageId: 'baseArrayJoin', }, ], }, + { + code: ` + type Bar = Record; + function foo(array: T[]) { + array[0].toString(); + } + `, + errors: [ + { + data: { + certainty: 'may', + name: 'array[0]', + }, + messageId: 'baseToString', + }, + ], + }, + { + code: ` + type Bar = Record; + function foo(value: T) { + value.toString(); + } + `, + errors: [ + { + data: { + certainty: 'may', + name: 'value', + }, + messageId: 'baseToString', + }, + ], + }, + { + code: ` +type Bar = Record; +declare const foo: Bar | string; +foo.toString(); + `, + errors: [ + { + data: { + certainty: 'may', + name: 'foo', + }, + messageId: 'baseToString', + }, + ], + }, { code: ` type Bar = Record;