Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Browse files
Browse the repository at this point in the history
feat(eslint-plugin): add new no-base-to-string rule (#1522)
- Loading branch information
Josh Goldberg
committed
Feb 29, 2020
1 parent
dd233b5
commit 8333d41
Showing
6 changed files
with
354 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,59 @@ | ||
# Requires that `.toString()` is only called on objects which provide useful information when stringified (`no-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]"`, so this rule requires stringified objects define a more useful `.toString()` method. | ||
|
||
Note that `Function` provides its own `.toString()` that returns the function's code. | ||
Functions are not flagged by this rule. | ||
|
||
This rule has some overlap with with [`restrict-plus-operands`](./restrict-plus-operands.md) and [`restrict-template-expressions`](./restrict-template-expressions.md). | ||
|
||
## Rule Details | ||
|
||
This rule prevents accidentally defaulting to the base Object `.toString()` method. | ||
|
||
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) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,121 @@ | ||
import { | ||
TSESTree, | ||
AST_NODE_TYPES, | ||
} from '@typescript-eslint/experimental-utils'; | ||
import * as ts from 'typescript'; | ||
|
||
import * as util from '../util'; | ||
|
||
enum Usefulness { | ||
Always, | ||
Never = 'will', | ||
Sometimes = 'may', | ||
} | ||
|
||
export default util.createRule({ | ||
name: 'no-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 { | ||
'AssignmentExpression[operator = "+="], BinaryExpression[operator = "+"]'( | ||
node: TSESTree.AssignmentExpression | TSESTree.BinaryExpression, | ||
): void { | ||
const leftType = typeChecker.getTypeAtLocation( | ||
parserServices.esTreeNodeToTSNodeMap.get(node.left), | ||
); | ||
const rightType = typeChecker.getTypeAtLocation( | ||
parserServices.esTreeNodeToTSNodeMap.get(node.right), | ||
); | ||
|
||
if (util.getTypeName(typeChecker, leftType) === 'string') { | ||
checkExpression(node.right, rightType); | ||
} else if (util.getTypeName(typeChecker, rightType) === 'string') { | ||
checkExpression(node.left, leftType); | ||
} | ||
}, | ||
'CallExpression > MemberExpression.callee > Identifier[name = "toString"].property'( | ||
node: TSESTree.Expression, | ||
): void { | ||
const memberExpr = node.parent as TSESTree.MemberExpression; | ||
checkExpression(memberExpr.object); | ||
}, | ||
|
||
TemplateLiteral(node: TSESTree.TemplateLiteral): void { | ||
for (const expression of node.expressions) { | ||
checkExpression(expression); | ||
} | ||
}, | ||
}; | ||
}, | ||
}); |
170 changes: 170 additions & 0 deletions
170
packages/eslint-plugin/tests/rules/no-base-to-string.test.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,170 @@ | ||
import rule from '../../src/rules/no-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('no-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;`, | ||
`let _ = {} * {}`, | ||
`let _ = {} / {}`, | ||
`let _ = {} *= {}`, | ||
`let _ = {} /= {}`, | ||
`let _ = {} = {}`, | ||
`let _ = {} == {}`, | ||
`let _ = {} === {}`, | ||
`let _ = {} in {}`, | ||
`let _ = {} & {}`, | ||
`let _ = {} ^ {}`, | ||
`let _ = {} << {}`, | ||
`let _ = {} >> {}`, | ||
], | ||
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: `"" += {}`, | ||
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', | ||
}, | ||
], | ||
}, | ||
], | ||
}); |