Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(eslint-plugin): add new no-base-to-string rule #1522

Merged
merged 7 commits into from Feb 29, 2020
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/eslint-plugin/README.md
Expand Up @@ -100,6 +100,7 @@ Pro Tip: For larger codebases you may want to consider splitting our linting int
| [`@typescript-eslint/await-thenable`](./docs/rules/await-thenable.md) | Disallows awaiting a value that is not a Thenable | :heavy_check_mark: | | :thought_balloon: |
| [`@typescript-eslint/ban-ts-comment`](./docs/rules/ban-ts-comment.md) | Bans `// @ts-<directive>` comments from being used | | | |
| [`@typescript-eslint/ban-types`](./docs/rules/ban-types.md) | Bans specific types from being used | :heavy_check_mark: | :wrench: | |
| [`@typescript-eslint/base-to-string`](./docs/rules/base-to-string.md) | Requires that `.toString()` is only called on objects which provide useful information when stringified | | | :thought_balloon: |
| [`@typescript-eslint/consistent-type-assertions`](./docs/rules/consistent-type-assertions.md) | Enforces consistent usage of type assertions | :heavy_check_mark: | | |
| [`@typescript-eslint/consistent-type-definitions`](./docs/rules/consistent-type-definitions.md) | Consistent with type definition either `interface` or `type` | | :wrench: | |
| [`@typescript-eslint/explicit-function-return-type`](./docs/rules/explicit-function-return-type.md) | Require explicit return types on functions and class methods | :heavy_check_mark: | | |
Expand Down
54 changes: 54 additions & 0 deletions packages/eslint-plugin/docs/rules/base-to-string.md
@@ -0,0 +1,54 @@
# Requires that `.toString()` is only called on objects which provide useful information when stringified (`base-to-string`)

JavaScript will call `toString()` on an object when it is converted to a string, such as when `+` adding to a string or in <code>`${}`</code> template literals.

The default Object `.toString()` returns `"[object Object]"` and the default Function `.toString()` defaults to `"[object Function]"`, so this rule requires stringified functions and objects define a more useful `.toString()` method.
JoshuaKGoldberg marked this conversation as resolved.
Show resolved Hide resolved

## Rule Details

???????
JoshuaKGoldberg marked this conversation as resolved.
Show resolved Hide resolved

Examples of **incorrect** code for this rule:

```ts
// Passing an object or class instance to string concatenation:
'' + {};

class MyClass {}
const value = new MyClass();
value + '';

// Interpolation and manual .toString() calls too:
`Value: ${value}`;
({}.toString());
```

Examples of **correct** code for this rule:

```ts
// These types all have useful .toString()s
'Text' + true;
`Value: ${123}`;
`Arrays too: ${[1, 2, 3]}`;
(() => {}).toString();

// Defining a custom .toString class is considered acceptable
class CustomToString {
toString() {
return 'Hello, world!';
}
}
`Value: ${new CustomToString()}`;

const literalWithToString = {
toString: () => 'Hello, world!',
};

`Value: ${literalWithToString}`;
```

## When Not To Use It

If you don't mind `"[object Object]"` in your strings, then you will not need this rule.

- [`Object.prototype.toString()` MDN documentation](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/toString)
JoshuaKGoldberg marked this conversation as resolved.
Show resolved Hide resolved
1 change: 1 addition & 0 deletions packages/eslint-plugin/src/configs/all.json
Expand Up @@ -6,6 +6,7 @@
"@typescript-eslint/await-thenable": "error",
"@typescript-eslint/ban-ts-comment": "error",
"@typescript-eslint/ban-types": "error",
"@typescript-eslint/base-to-string": "error",
"brace-style": "off",
"@typescript-eslint/brace-style": "error",
"comma-spacing": "off",
Expand Down
133 changes: 133 additions & 0 deletions packages/eslint-plugin/src/rules/base-to-string.ts
@@ -0,0 +1,133 @@
import {
TSESTree,
AST_NODE_TYPES,
} from '@typescript-eslint/experimental-utils';
import * as ts from 'typescript';

import * as util from '../util';
import { getTypeName } from '../util';
JoshuaKGoldberg marked this conversation as resolved.
Show resolved Hide resolved

type MessageIds = 'baseToString';

enum Usefulness {
Always,
Never = 'will',
Sometimes = 'may',
}

export default util.createRule<[], MessageIds>({
JoshuaKGoldberg marked this conversation as resolved.
Show resolved Hide resolved
name: 'base-to-string',
meta: {
docs: {
description:
'Requires that `.toString()` is only called on objects which provide useful information when stringified',
category: 'Best Practices',
recommended: false,
requiresTypeChecking: true,
},
messages: {
baseToString:
"'{{name}} {{certainty}} evaluate to '[Object object]' when stringified.",
},
schema: [],
type: 'suggestion',
},
defaultOptions: [],
create(context) {
const parserServices = util.getParserServices(context);
const typeChecker = parserServices.program.getTypeChecker();

function checkExpression(node: TSESTree.Expression, type?: ts.Type): void {
if (node.type === AST_NODE_TYPES.Literal) {
return;
}

const certainty = collectToStringCertainty(
type ??
typeChecker.getTypeAtLocation(
parserServices.esTreeNodeToTSNodeMap.get(node),
),
);
if (certainty === Usefulness.Always) {
return;
}

context.report({
data: {
certainty,
name: context.getSourceCode().getText(node),
},
messageId: 'baseToString',
node,
});
}

function collectToStringCertainty(type: ts.Type): Usefulness {
const toString = typeChecker.getPropertyOfType(type, 'toString');
if (toString === undefined || toString.declarations.length === 0) {
return Usefulness.Always;
}

if (
toString.declarations.every(
({ parent }) =>
!ts.isInterfaceDeclaration(parent) || parent.name.text !== 'Object',
)
) {
return Usefulness.Always;
}

if (!type.isUnion()) {
return Usefulness.Never;
}

for (const subType of type.types) {
if (collectToStringCertainty(subType) !== Usefulness.Never) {
return Usefulness.Sometimes;
}
}

return Usefulness.Never;
}

return {
BinaryExpression(node: TSESTree.BinaryExpression): void {
JoshuaKGoldberg marked this conversation as resolved.
Show resolved Hide resolved
const leftType = typeChecker.getTypeAtLocation(
parserServices.esTreeNodeToTSNodeMap.get(node.left),
);
const rightType = typeChecker.getTypeAtLocation(
parserServices.esTreeNodeToTSNodeMap.get(node.right),
);

if (getTypeName(typeChecker, leftType) === 'string') {
checkExpression(node.right, rightType);
} else if (getTypeName(typeChecker, rightType) === 'string') {
checkExpression(node.left, leftType);
}
},

CallExpression(node: TSESTree.CallExpression): void {
JoshuaKGoldberg marked this conversation as resolved.
Show resolved Hide resolved
const { callee } = node;
if (callee.type !== AST_NODE_TYPES.MemberExpression) {
return;
}

const { property } = callee;
if (
property.type !== AST_NODE_TYPES.Identifier ||
property.name !== 'toString'
) {
return;
}

checkExpression(callee.object);
},

TemplateLiteral(node: TSESTree.TemplateLiteral): void {
for (const expression of node.expressions) {
checkExpression(expression);
}
},
};
},
});
2 changes: 2 additions & 0 deletions packages/eslint-plugin/src/rules/index.ts
Expand Up @@ -4,6 +4,7 @@ import awaitThenable from './await-thenable';
import banTsIgnore from './ban-ts-ignore';
import banTsComment from './ban-ts-comment';
import banTypes from './ban-types';
import baseToString from './base-to-string';
import braceStyle from './brace-style';
import camelcase from './camelcase';
import classNameCasing from './class-name-casing';
Expand Down Expand Up @@ -90,6 +91,7 @@ export default {
'ban-ts-ignore': banTsIgnore,
'ban-ts-comment': banTsComment,
'ban-types': banTypes,
'base-to-string': baseToString,
'brace-style': braceStyle,
camelcase: camelcase,
'class-name-casing': classNameCasing,
Expand Down
146 changes: 146 additions & 0 deletions packages/eslint-plugin/tests/rules/base-to-string.test.ts
@@ -0,0 +1,146 @@
import rule from '../../src/rules/base-to-string';
import { RuleTester, getFixturesRootDir } from '../RuleTester';

const rootDir = getFixturesRootDir();
const ruleTester = new RuleTester({
parser: '@typescript-eslint/parser',
parserOptions: {
ecmaVersion: 2018,
tsconfigRootDir: rootDir,
project: './tsconfig.json',
},
});

ruleTester.run('base-to-string', rule, {
valid: [
`\`\${""}\``,
`\`\${true}\``,
`\`\${[]}\``,
`\`\${function () {}}\``,
`"" + ""`,
`"" + true`,
`"" + []`,
`true + true`,
`true + ""`,
`true + []`,
`[] + []`,
`[] + true`,
`[] + ""`,
`({}).constructor()`,
`"text".toString()`,
`false.toString()`,
`let value = 1;
value.toString()`,
`let value = 1n;
value.toString()`,
`function someFunction() { }
someFunction.toString();`,
'unknownObject.toString()',
'unknownObject.someOtherMethod()',
'(() => {}).toString();',
`class CustomToString { toString() { return "Hello, world!"; } }
"" + (new CustomToString());`,
`const literalWithToString = {
toString: () => "Hello, world!",
};
"" + literalToString;`,
],
invalid: [
{
code: `\`\${{}})\``,
errors: [
{
data: {
certainty: 'will',
name: '{}',
},
messageId: 'baseToString',
},
],
},
{
code: `({}).toString()`,
errors: [
{
data: {
certainty: 'will',
name: '{}',
},
messageId: 'baseToString',
},
],
},
{
code: `"" + {}`,
errors: [
{
data: {
certainty: 'will',
name: '{}',
},
messageId: 'baseToString',
},
],
},
{
code: `
let someObjectOrString = Math.random() ? { a: true } : "text";
someObjectOrString.toString();
`,
errors: [
{
data: {
certainty: 'may',
name: 'someObjectOrString',
},
messageId: 'baseToString',
},
],
},
{
code: `
let someObjectOrString = Math.random() ? { a: true } : "text";
someObjectOrString + "";
`,
errors: [
{
data: {
certainty: 'may',
name: 'someObjectOrString',
},
messageId: 'baseToString',
},
],
},
{
code: `
let someObjectOrObject = Math.random() ? { a: true, b: true } : { a: true };
someObjectOrObject.toString();
`,
errors: [
{
data: {
certainty: 'will',
name: 'someObjectOrObject',
},
messageId: 'baseToString',
},
],
},
{
code: `
let someObjectOrObject = Math.random() ? { a: true, b: true } : { a: true };
someObjectOrObject + "";
`,
errors: [
{
data: {
certainty: 'will',
name: 'someObjectOrObject',
},
messageId: 'baseToString',
},
],
},
],
});