diff --git a/packages/eslint-plugin/docs/rules/no-base-to-string.mdx b/packages/eslint-plugin/docs/rules/no-base-to-string.mdx index 1da882c1ebe4..8097ca8b7f41 100644 --- a/packages/eslint-plugin/docs/rules/no-base-to-string.mdx +++ b/packages/eslint-plugin/docs/rules/no-base-to-string.mdx @@ -34,6 +34,9 @@ value + ''; String({}); ({}).toString(); ({}).toLocaleString(); + +// Stringifying objects or instances in an array with the `Array.prototype.join`. +[{}, new MyClass()].join(''); ``` 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 7aad839a584a..f7c012eeb21f 100644 --- a/packages/eslint-plugin/src/rules/no-base-to-string.ts +++ b/packages/eslint-plugin/src/rules/no-base-to-string.ts @@ -2,9 +2,16 @@ import type { TSESTree } from '@typescript-eslint/utils'; import { AST_NODE_TYPES } from '@typescript-eslint/utils'; +import * as tsutils from 'ts-api-utils'; import * as ts from 'typescript'; -import { createRule, getParserServices, getTypeName } from '../util'; +import { + createRule, + getConstrainedTypeAtLocation, + getParserServices, + getTypeName, + nullThrows, +} from '../util'; enum Usefulness { Always = 'always', @@ -17,7 +24,7 @@ type Options = [ ignoredTypeNames?: string[]; }, ]; -type MessageIds = 'baseToString'; +type MessageIds = 'baseArrayJoin' | 'baseToString'; export default createRule({ name: 'no-base-to-string', @@ -30,6 +37,8 @@ export default createRule({ requiresTypeChecking: true, }, messages: { + baseArrayJoin: + "Using `join()` for {{name}} {{certainty}} use Object's default stringification format ('[object Object]') when stringified.", baseToString: "'{{name}}' {{certainty}} use Object's default stringification format ('[object Object]') when stringified.", }, @@ -64,7 +73,6 @@ export default createRule({ if (node.type === AST_NODE_TYPES.Literal) { return; } - const certainty = collectToStringCertainty( type ?? services.getTypeAtLocation(node), ); @@ -82,6 +90,91 @@ export default createRule({ }); } + function checkExpressionForArrayJoin( + node: TSESTree.Node, + type: ts.Type, + ): void { + const certainty = collectJoinCertainty(type); + + if (certainty === Usefulness.Always) { + return; + } + + context.report({ + node, + messageId: 'baseArrayJoin', + data: { + name: context.sourceCode.getText(node), + certainty, + }, + }); + } + + function collectUnionTypeCertainty( + type: ts.UnionType, + collectSubTypeCertainty: (type: ts.Type) => Usefulness, + ): Usefulness { + const certainties = type.types.map(t => collectSubTypeCertainty(t)); + if (certainties.every(certainty => certainty === Usefulness.Never)) { + return Usefulness.Never; + } + + if (certainties.every(certainty => certainty === Usefulness.Always)) { + return Usefulness.Always; + } + + return Usefulness.Sometimes; + } + + function collectIntersectionTypeCertainty( + type: ts.IntersectionType, + collectSubTypeCertainty: (type: ts.Type) => Usefulness, + ): Usefulness { + for (const subType of type.types) { + const subtypeUsefulness = collectSubTypeCertainty(subType); + + if (subtypeUsefulness === Usefulness.Always) { + return Usefulness.Always; + } + } + + return Usefulness.Never; + } + + function collectJoinCertainty(type: ts.Type): Usefulness { + if (tsutils.isUnionType(type)) { + return collectUnionTypeCertainty(type, collectJoinCertainty); + } + + if (tsutils.isIntersectionType(type)) { + return collectIntersectionTypeCertainty(type, collectJoinCertainty); + } + + if (checker.isTupleType(type)) { + const typeArgs = checker.getTypeArguments(type); + const certainties = typeArgs.map(t => collectToStringCertainty(t)); + if (certainties.some(certainty => certainty === Usefulness.Never)) { + return Usefulness.Never; + } + + if (certainties.some(certainty => certainty === Usefulness.Sometimes)) { + return Usefulness.Sometimes; + } + + return Usefulness.Always; + } + + if (checker.isArrayType(type)) { + const elemType = nullThrows( + type.getNumberIndexType(), + 'array should have number index type', + ); + return collectToStringCertainty(elemType); + } + + return Usefulness.Always; + } + function collectToStringCertainty(type: ts.Type): Usefulness { const toString = checker.getPropertyOfType(type, 'toString') ?? @@ -113,45 +206,13 @@ export default createRule({ } if (type.isIntersection()) { - for (const subType of type.types) { - const subtypeUsefulness = collectToStringCertainty(subType); - - if (subtypeUsefulness === Usefulness.Always) { - return Usefulness.Always; - } - } - - return Usefulness.Never; + return collectIntersectionTypeCertainty(type, collectToStringCertainty); } if (!type.isUnion()) { return Usefulness.Never; } - - let allSubtypesUseful = true; - let someSubtypeUseful = false; - - for (const subType of type.types) { - const subtypeUsefulness = collectToStringCertainty(subType); - - if (subtypeUsefulness !== Usefulness.Always && allSubtypesUseful) { - allSubtypesUseful = false; - } - - if (subtypeUsefulness !== Usefulness.Never && !someSubtypeUseful) { - someSubtypeUseful = true; - } - } - - if (allSubtypesUseful && someSubtypeUseful) { - return Usefulness.Always; - } - - if (someSubtypeUseful) { - return Usefulness.Sometimes; - } - - return Usefulness.Never; + return collectUnionTypeCertainty(type, collectToStringCertainty); } function isBuiltInStringCall(node: TSESTree.CallExpression): boolean { @@ -188,12 +249,20 @@ export default createRule({ checkExpression(node.arguments[0]); } }, + 'CallExpression > MemberExpression.callee > Identifier[name = "join"].property'( + node: TSESTree.Expression, + ): void { + const memberExpr = node.parent as TSESTree.MemberExpression; + const type = getConstrainedTypeAtLocation(services, memberExpr.object); + checkExpressionForArrayJoin(memberExpr.object, type); + }, 'CallExpression > MemberExpression.callee > Identifier[name = /^(toLocaleString|toString)$/].property'( node: TSESTree.Expression, ): void { const memberExpr = node.parent as TSESTree.MemberExpression; checkExpression(memberExpr.object); }, + TemplateLiteral(node: TSESTree.TemplateLiteral): void { if (node.parent.type === AST_NODE_TYPES.TaggedTemplateExpression) { return; diff --git a/packages/eslint-plugin/tests/docs-eslint-output-snapshots/no-base-to-string.shot b/packages/eslint-plugin/tests/docs-eslint-output-snapshots/no-base-to-string.shot index 1226f0a5f7ff..3e5e4b614498 100644 --- a/packages/eslint-plugin/tests/docs-eslint-output-snapshots/no-base-to-string.shot +++ b/packages/eslint-plugin/tests/docs-eslint-output-snapshots/no-base-to-string.shot @@ -21,6 +21,10 @@ String({}); ~~ '{}' will use Object's default stringification format ('[object Object]') when stringified. ({}).toLocaleString(); ~~ '{}' will use Object's default stringification format ('[object Object]') when stringified. + +// Stringifying objects or instances in an array with the \`Array.prototype.join\`. +[{}, new MyClass()].join(''); +~~~~~~~~~~~~~~~~~~~ Using \`join()\` for [{}, new MyClass()] will use Object's default stringification format ('[object Object]') when stringified. " `; 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 5aebfa112f9a..df1f3d4a6979 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 @@ -156,6 +156,65 @@ String(myValue); import { String } from 'foo'; String({}); `, + ` +['foo', 'bar'].join(''); + `, + + ` +([{}, 'bar'] as string[]).join(''); + `, + ` +function foo(array: T[]) { + return array.join(); +} + `, + ` +class Foo { + toString() { + return ''; + } +} +[new Foo()].join(); + `, + ` +class Foo { + join() {} +} +const foo = new Foo(); +foo.join(); + `, + ` +declare const array: string[]; +array.join(''); + `, + ` +class Foo {} +declare const array: (string & Foo)[]; +array.join(''); + `, + ` +class Foo {} +class Bar {} +declare const array: (string & Foo)[] | (string & Bar)[]; +array.join(''); + `, + ` +class Foo {} +class Bar {} +declare const array: (string & Foo)[] & (string & Bar)[]; +array.join(''); + `, + ` +class Foo {} +class Bar {} +declare const tuple: [string & Foo, string & Bar]; +tuple.join(''); + `, + ` +class Foo {} +declare const tuple: [string] & [Foo]; +tuple.join(''); + `, ], invalid: [ { @@ -353,5 +412,318 @@ String(...objects); }, ], }, + { + code: ` +class Foo {} +declare const foo: string | Foo; +\`\${foo}\`; + `, + errors: [ + { + data: { + certainty: 'may', + name: 'foo', + }, + messageId: 'baseToString', + }, + ], + }, + { + code: ` +class Foo {} +class Bar {} +declare const foo: Bar | Foo; +\`\${foo}\`; + `, + errors: [ + { + data: { + certainty: 'will', + name: 'foo', + }, + messageId: 'baseToString', + }, + ], + }, + { + code: ` +class Foo {} +class Bar {} +declare const foo: Bar & Foo; +\`\${foo}\`; + `, + errors: [ + { + data: { + certainty: 'will', + name: 'foo', + }, + messageId: 'baseToString', + }, + ], + }, + { + code: ` + [{}, {}].join(''); + `, + errors: [ + { + data: { + certainty: 'will', + name: '[{}, {}]', + }, + messageId: 'baseArrayJoin', + }, + ], + }, + { + code: ` + const array = [{}, {}]; + array.join(''); + `, + errors: [ + { + data: { + certainty: 'will', + name: 'array', + }, + messageId: 'baseArrayJoin', + }, + ], + }, + { + code: ` + class A {} + [new A(), 'str'].join(''); + `, + errors: [ + { + data: { + certainty: 'will', + name: "[new A(), 'str']", + }, + messageId: 'baseArrayJoin', + }, + ], + }, + { + code: ` + class Foo {} + declare const array: (string | Foo)[]; + array.join(''); + `, + errors: [ + { + data: { + certainty: 'may', + name: 'array', + }, + messageId: 'baseArrayJoin', + }, + ], + }, + { + code: ` + class Foo {} + declare const array: (string & Foo) | (string | Foo)[]; + array.join(''); + `, + errors: [ + { + data: { + certainty: 'may', + name: 'array', + }, + messageId: 'baseArrayJoin', + }, + ], + }, + { + code: ` + class Foo {} + class Bar {} + declare const array: Foo[] & Bar[]; + array.join(''); + `, + errors: [ + { + data: { + certainty: 'will', + name: 'array', + }, + messageId: 'baseArrayJoin', + }, + ], + }, + { + code: ` + class Foo {} + declare const array: string[] | Foo[]; + array.join(''); + `, + errors: [ + { + data: { + certainty: 'may', + name: 'array', + }, + messageId: 'baseArrayJoin', + }, + ], + }, + { + code: ` + class Foo {} + declare const tuple: [string, Foo]; + tuple.join(''); + `, + errors: [ + { + data: { + certainty: 'will', + name: 'tuple', + }, + messageId: 'baseArrayJoin', + }, + ], + }, + { + code: ` + class Foo {} + declare const tuple: [Foo, Foo]; + tuple.join(''); + `, + errors: [ + { + data: { + certainty: 'will', + name: 'tuple', + }, + messageId: 'baseArrayJoin', + }, + ], + }, + { + code: ` + class Foo {} + declare const tuple: [Foo | string, string]; + tuple.join(''); + `, + errors: [ + { + data: { + certainty: 'may', + name: 'tuple', + }, + messageId: 'baseArrayJoin', + }, + ], + }, + { + code: ` + class Foo {} + declare const tuple: [string, string] | [Foo, Foo]; + tuple.join(''); + `, + errors: [ + { + data: { + certainty: 'may', + name: 'tuple', + }, + messageId: 'baseArrayJoin', + }, + ], + }, + { + code: ` + class Foo {} + declare const tuple: [Foo, string] & [Foo, Foo]; + tuple.join(''); + `, + errors: [ + { + data: { + certainty: 'will', + name: 'tuple', + }, + messageId: 'baseArrayJoin', + }, + ], + }, + { + code: ` + const array = ['string', { foo: 'bar' }]; + array.join(''); + `, + errors: [ + { + data: { + certainty: 'may', + name: 'array', + }, + messageId: 'baseArrayJoin', + }, + ], + languageOptions: { + parserOptions: { + project: './tsconfig.noUncheckedIndexedAccess.json', + tsconfigRootDir: rootDir, + }, + }, + }, + { + code: ` + type Bar = Record; + function foo(array: T[]) { + return array.join(); + } + `, + errors: [ + { + data: { + certainty: 'will', + name: 'array', + }, + messageId: 'baseArrayJoin', + }, + ], + }, + { + code: ` + type Bar = Record; + function foo(array: T[]) { + return array; + } + foo([{ foo: 'foo' }]).join(); + `, + errors: [ + { + data: { + certainty: 'will', + name: "foo([{ foo: 'foo' }])", + }, + messageId: 'baseArrayJoin', + }, + ], + }, + { + code: ` + type Bar = Record; + function foo(array: T[]) { + return array; + } + foo([{ foo: 'foo' }, 'bar']).join(); + `, + errors: [ + { + data: { + certainty: 'may', + name: "foo([{ foo: 'foo' }, 'bar'])", + }, + messageId: 'baseArrayJoin', + }, + ], + }, ], });