Skip to content

Commit 24a1510

Browse files
fix(eslint-plugin): [no-base-to-string] handle more robustly when multiple toString() declarations are present for a type (#10432)
* remove confusing spread element test cases * fix intersection checking * fix for old versions of TS * lintfix * readability * isTypeParameter comment * derp * ignored types * expression
1 parent 47f1ab3 commit 24a1510

File tree

2 files changed

+141
-41
lines changed

2 files changed

+141
-41
lines changed

packages/eslint-plugin/src/rules/no-base-to-string.ts

+42-21
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
/* eslint-disable @typescript-eslint/internal/prefer-ast-types-enum */
21
import type { TSESTree } from '@typescript-eslint/utils';
32

43
import { AST_NODE_TYPES } from '@typescript-eslint/utils';
@@ -69,7 +68,7 @@ export default createRule<Options, MessageIds>({
6968
const checker = services.program.getTypeChecker();
7069
const ignoredTypeNames = option.ignoredTypeNames ?? [];
7170

72-
function checkExpression(node: TSESTree.Node, type?: ts.Type): void {
71+
function checkExpression(node: TSESTree.Expression, type?: ts.Type): void {
7372
if (node.type === AST_NODE_TYPES.Literal) {
7473
return;
7574
}
@@ -176,15 +175,17 @@ export default createRule<Options, MessageIds>({
176175
}
177176

178177
function collectToStringCertainty(type: ts.Type): Usefulness {
179-
const toString =
180-
checker.getPropertyOfType(type, 'toString') ??
181-
checker.getPropertyOfType(type, 'toLocaleString');
182-
const declarations = toString?.getDeclarations();
183-
if (!toString || !declarations || declarations.length === 0) {
178+
// https://github.com/JoshuaKGoldberg/ts-api-utils/issues/382
179+
if ((tsutils.isTypeParameter as (t: ts.Type) => boolean)(type)) {
180+
const constraint = type.getConstraint();
181+
if (constraint) {
182+
return collectToStringCertainty(constraint);
183+
}
184+
// unconstrained generic means `unknown`
184185
return Usefulness.Always;
185186
}
186187

187-
// Patch for old version TypeScript, the Boolean type definition missing toString()
188+
// the Boolean type definition missing toString()
188189
if (
189190
type.flags & ts.TypeFlags.Boolean ||
190191
type.flags & ts.TypeFlags.BooleanLiteral
@@ -196,32 +197,49 @@ export default createRule<Options, MessageIds>({
196197
return Usefulness.Always;
197198
}
198199

199-
if (
200-
declarations.every(
201-
({ parent }) =>
202-
!ts.isInterfaceDeclaration(parent) || parent.name.text !== 'Object',
203-
)
204-
) {
205-
return Usefulness.Always;
206-
}
207-
208200
if (type.isIntersection()) {
209201
return collectIntersectionTypeCertainty(type, collectToStringCertainty);
210202
}
211203

212-
if (!type.isUnion()) {
213-
return Usefulness.Never;
204+
if (type.isUnion()) {
205+
return collectUnionTypeCertainty(type, collectToStringCertainty);
206+
}
207+
208+
const toString =
209+
checker.getPropertyOfType(type, 'toString') ??
210+
checker.getPropertyOfType(type, 'toLocaleString');
211+
if (!toString) {
212+
// e.g. any/unknown
213+
return Usefulness.Always;
214214
}
215-
return collectUnionTypeCertainty(type, collectToStringCertainty);
215+
216+
const declarations = toString.getDeclarations();
217+
218+
if (declarations == null || declarations.length !== 1) {
219+
// If there are multiple declarations, at least one of them must not be
220+
// the default object toString.
221+
//
222+
// This may only matter for older versions of TS
223+
// see https://github.com/typescript-eslint/typescript-eslint/issues/8585
224+
return Usefulness.Always;
225+
}
226+
227+
const declaration = declarations[0];
228+
const isBaseToString =
229+
ts.isInterfaceDeclaration(declaration.parent) &&
230+
declaration.parent.name.text === 'Object';
231+
return isBaseToString ? Usefulness.Never : Usefulness.Always;
216232
}
217233

218234
function isBuiltInStringCall(node: TSESTree.CallExpression): boolean {
219235
if (
220236
node.callee.type === AST_NODE_TYPES.Identifier &&
237+
// eslint-disable-next-line @typescript-eslint/internal/prefer-ast-types-enum
221238
node.callee.name === 'String' &&
222239
node.arguments[0]
223240
) {
224241
const scope = context.sourceCode.getScope(node);
242+
// eslint-disable-next-line @typescript-eslint/internal/prefer-ast-types-enum
225243
const variable = scope.set.get('String');
226244
return !variable?.defs.length;
227245
}
@@ -245,7 +263,10 @@ export default createRule<Options, MessageIds>({
245263
}
246264
},
247265
CallExpression(node: TSESTree.CallExpression): void {
248-
if (isBuiltInStringCall(node)) {
266+
if (
267+
isBuiltInStringCall(node) &&
268+
node.arguments[0].type !== AST_NODE_TYPES.SpreadElement
269+
) {
249270
checkExpression(node.arguments[0]);
250271
}
251272
},

packages/eslint-plugin/tests/rules/no-base-to-string.test.ts

+99-20
Original file line numberDiff line numberDiff line change
@@ -135,16 +135,20 @@ tag\`\${{}}\`;
135135
"'' += new URL();",
136136
"'' += new URLSearchParams();",
137137
`
138-
let numbers = [1, 2, 3];
139-
String(...a);
140-
`,
141-
`
142138
Number(1);
143139
`,
144140
{
145141
code: 'String(/regex/);',
146142
options: [{ ignoredTypeNames: ['RegExp'] }],
147143
},
144+
{
145+
code: `
146+
type Foo = { a: string } | { b: string };
147+
declare const foo: Foo;
148+
String(foo);
149+
`,
150+
options: [{ ignoredTypeNames: ['Foo'] }],
151+
},
148152
`
149153
function String(value) {
150154
return value;
@@ -215,6 +219,46 @@ class Foo {}
215219
declare const tuple: [string] & [Foo];
216220
tuple.join('');
217221
`,
222+
// don't bother trying to interpret spread args.
223+
`
224+
let objects = [{}, {}];
225+
String(...objects);
226+
`,
227+
// https://github.com/typescript-eslint/typescript-eslint/issues/8585
228+
`
229+
type Constructable<Entity> = abstract new (...args: any[]) => Entity;
230+
231+
interface GuildChannel {
232+
toString(): \`<#\${string}>\`;
233+
}
234+
235+
declare const foo: Constructable<GuildChannel & { bar: 1 }>;
236+
class ExtendedGuildChannel extends foo {}
237+
declare const bb: ExtendedGuildChannel;
238+
bb.toString();
239+
`,
240+
// https://github.com/typescript-eslint/typescript-eslint/issues/8585 with intersection order reversed.
241+
`
242+
type Constructable<Entity> = abstract new (...args: any[]) => Entity;
243+
244+
interface GuildChannel {
245+
toString(): \`<#\${string}>\`;
246+
}
247+
248+
declare const foo: Constructable<{ bar: 1 } & GuildChannel>;
249+
class ExtendedGuildChannel extends foo {}
250+
declare const bb: ExtendedGuildChannel;
251+
bb.toString();
252+
`,
253+
`
254+
function foo<T>(x: T) {
255+
String(x);
256+
}
257+
`,
258+
`
259+
declare const u: unknown;
260+
String(u);
261+
`,
218262
],
219263
invalid: [
220264
{
@@ -277,21 +321,6 @@ tuple.join('');
277321
},
278322
],
279323
},
280-
{
281-
code: `
282-
let objects = [{}, {}];
283-
String(...objects);
284-
`,
285-
errors: [
286-
{
287-
data: {
288-
certainty: 'will',
289-
name: '...objects',
290-
},
291-
messageId: 'baseToString',
292-
},
293-
],
294-
},
295324
{
296325
code: "'' += {};",
297326
errors: [
@@ -682,13 +711,63 @@ declare const foo: Bar & Foo;
682711
errors: [
683712
{
684713
data: {
685-
certainty: 'will',
714+
certainty: 'may',
686715
name: 'array',
687716
},
688717
messageId: 'baseArrayJoin',
689718
},
690719
],
691720
},
721+
{
722+
code: `
723+
type Bar = Record<string, string>;
724+
function foo<T extends string | Bar>(array: T[]) {
725+
array[0].toString();
726+
}
727+
`,
728+
errors: [
729+
{
730+
data: {
731+
certainty: 'may',
732+
name: 'array[0]',
733+
},
734+
messageId: 'baseToString',
735+
},
736+
],
737+
},
738+
{
739+
code: `
740+
type Bar = Record<string, string>;
741+
function foo<T extends string | Bar>(value: T) {
742+
value.toString();
743+
}
744+
`,
745+
errors: [
746+
{
747+
data: {
748+
certainty: 'may',
749+
name: 'value',
750+
},
751+
messageId: 'baseToString',
752+
},
753+
],
754+
},
755+
{
756+
code: `
757+
type Bar = Record<string, string>;
758+
declare const foo: Bar | string;
759+
foo.toString();
760+
`,
761+
errors: [
762+
{
763+
data: {
764+
certainty: 'may',
765+
name: 'foo',
766+
},
767+
messageId: 'baseToString',
768+
},
769+
],
770+
},
692771
{
693772
code: `
694773
type Bar = Record<string, string>;

0 commit comments

Comments
 (0)