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 f7afc31d1ecd..6617c8d1dea2 100644 --- a/packages/eslint-plugin/src/rules/no-base-to-string.ts +++ b/packages/eslint-plugin/src/rules/no-base-to-string.ts @@ -74,6 +74,7 @@ export default createRule({ } const certainty = collectToStringCertainty( type ?? services.getTypeAtLocation(node), + new Set(), ); if (certainty === Usefulness.Always) { return; @@ -93,7 +94,7 @@ export default createRule({ node: TSESTree.Node, type: ts.Type, ): void { - const certainty = collectJoinCertainty(type); + const certainty = collectJoinCertainty(type, new Set()); if (certainty === Usefulness.Always) { return; @@ -140,9 +141,14 @@ export default createRule({ return Usefulness.Never; } - function collectTupleCertainty(type: ts.TypeReference): Usefulness { + function collectTupleCertainty( + type: ts.TypeReference, + visited: Set, + ): Usefulness { const typeArgs = checker.getTypeArguments(type); - const certainties = typeArgs.map(t => collectToStringCertainty(t)); + const certainties = typeArgs.map(t => + collectToStringCertainty(t, visited), + ); if (certainties.some(certainty => certainty === Usefulness.Never)) { return Usefulness.Never; } @@ -154,39 +160,57 @@ export default createRule({ return Usefulness.Always; } - function collectArrayCertainty(type: ts.Type): Usefulness { + function collectArrayCertainty( + type: ts.Type, + visited: Set, + ): Usefulness { const elemType = nullThrows( type.getNumberIndexType(), 'array should have number index type', ); - return collectToStringCertainty(elemType); + return collectToStringCertainty(elemType, visited); } - function collectJoinCertainty(type: ts.Type): Usefulness { + function collectJoinCertainty( + type: ts.Type, + visited: Set, + ): Usefulness { if (tsutils.isUnionType(type)) { - return collectUnionTypeCertainty(type, collectJoinCertainty); + return collectUnionTypeCertainty(type, t => + collectJoinCertainty(t, visited), + ); } if (tsutils.isIntersectionType(type)) { - return collectIntersectionTypeCertainty(type, collectJoinCertainty); + return collectIntersectionTypeCertainty(type, t => + collectJoinCertainty(t, visited), + ); } if (checker.isTupleType(type)) { - return collectTupleCertainty(type); + return collectTupleCertainty(type, visited); } if (checker.isArrayType(type)) { - return collectArrayCertainty(type); + return collectArrayCertainty(type, visited); } return Usefulness.Always; } - function collectToStringCertainty(type: ts.Type): Usefulness { + function collectToStringCertainty( + type: ts.Type, + visited: Set, + ): Usefulness { + if (visited.has(type)) { + // don't report if this is a self referencing array or tuple type + return Usefulness.Always; + } + if (tsutils.isTypeParameter(type)) { const constraint = type.getConstraint(); if (constraint) { - return collectToStringCertainty(constraint); + return collectToStringCertainty(constraint, visited); } // unconstrained generic means `unknown` return Usefulness.Always; @@ -205,19 +229,23 @@ export default createRule({ } if (type.isIntersection()) { - return collectIntersectionTypeCertainty(type, collectToStringCertainty); + return collectIntersectionTypeCertainty(type, t => + collectToStringCertainty(t, visited), + ); } if (type.isUnion()) { - return collectUnionTypeCertainty(type, collectToStringCertainty); + return collectUnionTypeCertainty(type, t => + collectToStringCertainty(t, visited), + ); } if (checker.isTupleType(type)) { - return collectTupleCertainty(type); + return collectTupleCertainty(type, new Set([...visited, type])); } if (checker.isArrayType(type)) { - return collectArrayCertainty(type); + return collectArrayCertainty(type, new Set([...visited, type])); } const toString = 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 6ac4b14bd345..f0fe39979c4d 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 @@ -483,6 +483,34 @@ function foo(x: T) { declare const u: unknown; String(u); `, + ` +type Value = string | Value[]; +declare const v: Value; + +String(v); + `, + ` +type Value = (string | Value)[]; +declare const v: Value; + +String(v); + `, + ` +type Value = Value[]; +declare const v: Value; + +String(v); + `, + ` +type Value = [Value]; +declare const v: Value; + +String(v); + `, + ` +declare const v: ('foo' | 'bar')[][]; +String(v); + `, ], invalid: [ { @@ -1811,5 +1839,71 @@ foo.toString(); }, ], }, + { + code: ` +type Value = { foo: string } | Value[]; +declare const v: Value; + +String(v); + `, + errors: [ + { + data: { + certainty: 'may', + name: 'v', + }, + messageId: 'baseToString', + }, + ], + }, + { + code: ` +type Value = ({ foo: string } | Value)[]; +declare const v: Value; + +String(v); + `, + errors: [ + { + data: { + certainty: 'may', + name: 'v', + }, + messageId: 'baseToString', + }, + ], + }, + { + code: ` +type Value = [{ foo: string }, Value]; +declare const v: Value; + +String(v); + `, + errors: [ + { + data: { + certainty: 'will', + name: 'v', + }, + messageId: 'baseToString', + }, + ], + }, + { + code: ` +declare const v: { foo: string }[][]; +v.join(); + `, + errors: [ + { + data: { + certainty: 'will', + name: 'v', + }, + messageId: 'baseArrayJoin', + }, + ], + }, ], });