diff --git a/packages/eslint-plugin/docs/rules/no-useless-template-literals.md b/packages/eslint-plugin/docs/rules/no-useless-template-literals.md new file mode 100644 index 00000000000..f641e8d1f82 --- /dev/null +++ b/packages/eslint-plugin/docs/rules/no-useless-template-literals.md @@ -0,0 +1,57 @@ +--- +description: 'Disallow unnecessary template literals.' +--- + +> 🛑 This file is source code, not the primary documentation location! 🛑 +> +> See **https://typescript-eslint.io/rules/no-useless-template-literals** for documentation. + +This rule reports template literals that can be simplified to a normal string literal. + +## Examples + + + +### ❌ Incorrect + +```ts +const ab1 = `${'a'}${'b'}`; +const ab2 = `a${'b'}`; + +const stringWithNumber = `${'1 + 1 = '}${2}`; + +const stringWithBoolean = `${'true is '}${true}`; + +const text = 'a'; +const wrappedText = `${text}`; + +declare const intersectionWithString: string & { _brand: 'test-brand' }; +const wrappedIntersection = `${intersectionWithString}`; +``` + +### ✅ Correct + +```ts +const ab1 = 'ab'; +const ab2 = 'ab'; + +const stringWithNumber = `1 + 1 = 2`; + +const stringWithBoolean = `true is true`; + +const text = 'a'; +const wrappedText = text; + +declare const intersectionWithString: string & { _brand: 'test-brand' }; +const wrappedIntersection = intersectionWithString; +``` + + + +## When Not To Use It + +When you want to allow string expressions inside template literals. + +## Related To + +- [`restrict-template-expressions`](./restrict-template-expressions.md) diff --git a/packages/eslint-plugin/src/configs/all.ts b/packages/eslint-plugin/src/configs/all.ts index 5142a24bd54..f6e14dfe12a 100644 --- a/packages/eslint-plugin/src/configs/all.ts +++ b/packages/eslint-plugin/src/configs/all.ts @@ -135,6 +135,7 @@ export = { 'no-useless-constructor': 'off', '@typescript-eslint/no-useless-constructor': 'error', '@typescript-eslint/no-useless-empty-export': 'error', + '@typescript-eslint/no-useless-template-literals': 'error', '@typescript-eslint/no-var-requires': 'error', '@typescript-eslint/non-nullable-type-assertion-style': 'error', 'object-curly-spacing': 'off', diff --git a/packages/eslint-plugin/src/configs/disable-type-checked.ts b/packages/eslint-plugin/src/configs/disable-type-checked.ts index 4bacae68156..2fe413146c7 100644 --- a/packages/eslint-plugin/src/configs/disable-type-checked.ts +++ b/packages/eslint-plugin/src/configs/disable-type-checked.ts @@ -35,6 +35,7 @@ export = { '@typescript-eslint/no-unsafe-member-access': 'off', '@typescript-eslint/no-unsafe-return': 'off', '@typescript-eslint/no-unsafe-unary-minus': 'off', + '@typescript-eslint/no-useless-template-literals': 'off', '@typescript-eslint/non-nullable-type-assertion-style': 'off', '@typescript-eslint/prefer-destructuring': 'off', '@typescript-eslint/prefer-includes': 'off', diff --git a/packages/eslint-plugin/src/configs/strict-type-checked.ts b/packages/eslint-plugin/src/configs/strict-type-checked.ts index dfba0b81c7f..471175b9bba 100644 --- a/packages/eslint-plugin/src/configs/strict-type-checked.ts +++ b/packages/eslint-plugin/src/configs/strict-type-checked.ts @@ -56,6 +56,7 @@ export = { '@typescript-eslint/no-unused-vars': 'error', 'no-useless-constructor': 'off', '@typescript-eslint/no-useless-constructor': 'error', + '@typescript-eslint/no-useless-template-literals': 'error', '@typescript-eslint/no-var-requires': 'error', '@typescript-eslint/prefer-as-const': 'error', '@typescript-eslint/prefer-includes': 'error', diff --git a/packages/eslint-plugin/src/rules/consistent-type-exports.ts b/packages/eslint-plugin/src/rules/consistent-type-exports.ts index 127dde8831d..78efc59bb85 100644 --- a/packages/eslint-plugin/src/rules/consistent-type-exports.ts +++ b/packages/eslint-plugin/src/rules/consistent-type-exports.ts @@ -189,7 +189,7 @@ export default createRule({ // We have both type and value violations. const allExportNames = report.typeBasedSpecifiers.map( - specifier => `${specifier.local.name}`, + specifier => specifier.local.name, ); if (allExportNames.length === 1) { diff --git a/packages/eslint-plugin/src/rules/index.ts b/packages/eslint-plugin/src/rules/index.ts index 5423a7b8207..14c171af990 100644 --- a/packages/eslint-plugin/src/rules/index.ts +++ b/packages/eslint-plugin/src/rules/index.ts @@ -93,6 +93,7 @@ import noUnusedVars from './no-unused-vars'; import noUseBeforeDefine from './no-use-before-define'; import noUselessConstructor from './no-useless-constructor'; import noUselessEmptyExport from './no-useless-empty-export'; +import noUselessTemplateLiterals from './no-useless-template-literals'; import noVarRequires from './no-var-requires'; import nonNullableTypeAssertionStyle from './non-nullable-type-assertion-style'; import objectCurlySpacing from './object-curly-spacing'; @@ -231,6 +232,7 @@ export default { 'no-use-before-define': noUseBeforeDefine, 'no-useless-constructor': noUselessConstructor, 'no-useless-empty-export': noUselessEmptyExport, + 'no-useless-template-literals': noUselessTemplateLiterals, 'no-var-requires': noVarRequires, 'non-nullable-type-assertion-style': nonNullableTypeAssertionStyle, 'object-curly-spacing': objectCurlySpacing, diff --git a/packages/eslint-plugin/src/rules/no-useless-template-literals.ts b/packages/eslint-plugin/src/rules/no-useless-template-literals.ts new file mode 100644 index 00000000000..48c714edb90 --- /dev/null +++ b/packages/eslint-plugin/src/rules/no-useless-template-literals.ts @@ -0,0 +1,91 @@ +import type { TSESTree } from '@typescript-eslint/utils'; +import { AST_NODE_TYPES } from '@typescript-eslint/utils'; +import * as ts from 'typescript'; + +import { + createRule, + getConstrainedTypeAtLocation, + getParserServices, + isTypeFlagSet, + isUndefinedIdentifier, +} from '../util'; + +type MessageId = 'noUselessTemplateLiteral'; + +export default createRule<[], MessageId>({ + name: 'no-useless-template-literals', + meta: { + type: 'problem', + docs: { + description: 'Disallow unnecessary template literals', + recommended: 'strict', + requiresTypeChecking: true, + }, + messages: { + noUselessTemplateLiteral: + 'Template literal expression is unnecessary and can be simplified.', + }, + schema: [], + }, + defaultOptions: [], + create(context) { + const services = getParserServices(context); + + function isUnderlyingTypeString( + expression: TSESTree.Expression, + ): expression is TSESTree.StringLiteral | TSESTree.Identifier { + const type = getConstrainedTypeAtLocation(services, expression); + + const isString = (t: ts.Type): boolean => { + return isTypeFlagSet(t, ts.TypeFlags.StringLike); + }; + + if (type.isUnion()) { + return type.types.every(isString); + } + + if (type.isIntersection()) { + return type.types.some(isString); + } + + return isString(type); + } + + return { + TemplateLiteral(node: TSESTree.TemplateLiteral): void { + if (node.parent.type === AST_NODE_TYPES.TaggedTemplateExpression) { + return; + } + + const hasSingleStringVariable = + node.quasis.length === 2 && + node.quasis[0].value.raw === '' && + node.quasis[1].value.raw === '' && + node.expressions.length === 1 && + isUnderlyingTypeString(node.expressions[0]); + + if (hasSingleStringVariable) { + context.report({ + node: node.expressions[0], + messageId: 'noUselessTemplateLiteral', + }); + + return; + } + + const literalsOrUndefinedExpressions = node.expressions.filter( + (expression): expression is TSESTree.Literal | TSESTree.Identifier => + expression.type === AST_NODE_TYPES.Literal || + isUndefinedIdentifier(expression), + ); + + literalsOrUndefinedExpressions.forEach(expression => { + context.report({ + node: expression, + messageId: 'noUselessTemplateLiteral', + }); + }); + }, + }; + }, +}); diff --git a/packages/eslint-plugin/tests/docs.test.ts b/packages/eslint-plugin/tests/docs.test.ts index cc65cb32f4a..a811396013c 100644 --- a/packages/eslint-plugin/tests/docs.test.ts +++ b/packages/eslint-plugin/tests/docs.test.ts @@ -159,7 +159,7 @@ describe('Validating rule metadata', () => { } for (const [ruleName, rule] of rulesData) { - describe(`${ruleName}`, () => { + describe(ruleName, () => { it('`name` field in rule must match the filename', () => { // validate if rule name is same as url // there is no way to access this field but its used only in generation of docs url diff --git a/packages/eslint-plugin/tests/rules/no-extra-parens.test.ts b/packages/eslint-plugin/tests/rules/no-extra-parens.test.ts index 43f6902a20e..94c796a98a8 100644 --- a/packages/eslint-plugin/tests/rules/no-extra-parens.test.ts +++ b/packages/eslint-plugin/tests/rules/no-extra-parens.test.ts @@ -3,7 +3,7 @@ /* eslint "@typescript-eslint/internal/plugin-test-formatting": ["error", { formatWithPrettier: false }] */ /* eslint-enable eslint-comments/no-use */ -import { RuleTester } from '@typescript-eslint/rule-tester'; +import { noFormat, RuleTester } from '@typescript-eslint/rule-tester'; import rule from '../../src/rules/no-extra-parens'; @@ -743,7 +743,7 @@ const Component = ( /> ) `, - output: ` + output: noFormat` const Component =${' '}

diff --git a/packages/eslint-plugin/tests/rules/no-unnecessary-condition.test.ts b/packages/eslint-plugin/tests/rules/no-unnecessary-condition.test.ts index 8c891aea624..89cdd1a73ed 100644 --- a/packages/eslint-plugin/tests/rules/no-unnecessary-condition.test.ts +++ b/packages/eslint-plugin/tests/rules/no-unnecessary-condition.test.ts @@ -1235,7 +1235,7 @@ foo ?. foo ?. (); `, - output: ` + output: noFormat` let foo = () => {}; foo(); foo (); @@ -1285,7 +1285,7 @@ foo ?. foo ?. (bar); `, - output: ` + output: noFormat` let foo = () => {}; foo(bar); foo (bar); diff --git a/packages/eslint-plugin/tests/rules/no-useless-template-literals.test.ts b/packages/eslint-plugin/tests/rules/no-useless-template-literals.test.ts new file mode 100644 index 00000000000..54ac89c2c79 --- /dev/null +++ b/packages/eslint-plugin/tests/rules/no-useless-template-literals.test.ts @@ -0,0 +1,332 @@ +import { noFormat, RuleTester } from '@typescript-eslint/rule-tester'; + +import rule from '../../src/rules/no-useless-template-literals'; +import { getFixturesRootDir } from '../RuleTester'; + +const rootPath = getFixturesRootDir(); + +const ruleTester = new RuleTester({ + parser: '@typescript-eslint/parser', + parserOptions: { + tsconfigRootDir: rootPath, + project: './tsconfig.json', + }, +}); + +ruleTester.run('no-useless-template-literals', rule, { + valid: [ + "const string = 'a';", + 'const string = `a`;', + + ` + declare const string: 'a'; + \`\${string}b\`; + `, + + ` + declare const number: 1; + \`\${number}b\`; + `, + + ` + declare const boolean: true; + \`\${boolean}b\`; + `, + + ` + declare const nullish: null; + \`\${nullish}-undefined\`; + `, + + ` + declare const undefinedish: undefined; + \`\${undefinedish}\`; + `, + + ` + declare const left: 'a'; + declare const right: 'b'; + \`\${left}\${right}\`; + `, + + ` + declare const left: 'a'; + declare const right: 'c'; + \`\${left}b\${right}\`; + `, + + ` + declare const left: 'a'; + declare const center: 'b'; + declare const right: 'c'; + \`\${left}\${center}\${right}\`; + `, + + '`1 + 1 = ${1 + 1}`;', + + '`true && false = ${true && false}`;', + + "tag`${'a'}${'b'}`;", + + '`${function () {}}`;', + + '`${() => {}}`;', + + '`${(...args: any[]) => args}`;', + + ` + declare const number: 1; + \`\${number}\`; + `, + + ` + declare const boolean: true; + \`\${boolean}\`; + `, + + ` + declare const nullish: null; + \`\${nullish}\`; + `, + + ` + declare const union: string | number; + \`\${union}\`; + `, + + ` + declare const unknown: unknown; + \`\${unknown}\`; + `, + + ` + declare const never: never; + \`\${never}\`; + `, + + ` + declare const any: any; + \`\${any}\`; + `, + + ` + function func(arg: T) { + \`\${arg}\`; + } + `, + + ` + \`with + + new line\`; + `, + + ` + declare const a: 'a'; + + \`\${a} with + + new line\`; + `, + + noFormat` + \`with windows \r new line\`; + `, + ], + + invalid: [ + { + code: '`${1}`;', + errors: [ + { + messageId: 'noUselessTemplateLiteral', + line: 1, + column: 4, + endColumn: 5, + }, + ], + }, + { + code: '`${1n}`;', + errors: [ + { + messageId: 'noUselessTemplateLiteral', + line: 1, + column: 4, + endColumn: 6, + }, + ], + }, + { + code: '`${true}`;', + errors: [ + { + messageId: 'noUselessTemplateLiteral', + line: 1, + column: 4, + endColumn: 8, + }, + ], + }, + { + code: '`${null}`;', + errors: [ + { + messageId: 'noUselessTemplateLiteral', + line: 1, + column: 4, + endColumn: 8, + }, + ], + }, + { + code: '`${undefined}`;', + errors: [ + { + messageId: 'noUselessTemplateLiteral', + line: 1, + column: 4, + endColumn: 13, + }, + ], + }, + { + code: "`${'a'}${'b'}`;", + errors: [ + { + messageId: 'noUselessTemplateLiteral', + line: 1, + column: 4, + endColumn: 7, + }, + { + messageId: 'noUselessTemplateLiteral', + line: 1, + column: 10, + endColumn: 13, + }, + ], + }, + + { + code: ` + declare const b: 'b'; + \`a\${b}\${'c'}\`; + `, + errors: [ + { + messageId: 'noUselessTemplateLiteral', + line: 3, + column: 17, + endColumn: 20, + }, + ], + }, + + { + code: "`a${'b'}`;", + errors: [ + { + messageId: 'noUselessTemplateLiteral', + line: 1, + column: 5, + endColumn: 8, + }, + ], + }, + + { + code: "`${'1 + 1 = '}${2}`;", + errors: [ + { + messageId: 'noUselessTemplateLiteral', + line: 1, + column: 4, + endColumn: 14, + }, + { + messageId: 'noUselessTemplateLiteral', + line: 1, + column: 17, + endColumn: 18, + }, + ], + }, + + { + code: "`${'a'}${true}`;", + errors: [ + { + messageId: 'noUselessTemplateLiteral', + line: 1, + column: 4, + endColumn: 7, + }, + { + messageId: 'noUselessTemplateLiteral', + line: 1, + column: 10, + endColumn: 14, + }, + ], + }, + + { + code: ` + declare const string: 'a'; + \`\${string}\`; + `, + errors: [ + { + messageId: 'noUselessTemplateLiteral', + line: 3, + column: 12, + endColumn: 18, + }, + ], + }, + + { + code: "`${String(Symbol.for('test'))}`;", + errors: [ + { + messageId: 'noUselessTemplateLiteral', + line: 1, + column: 4, + endColumn: 30, + }, + ], + }, + + { + code: ` + declare const intersection: string & { _brand: 'test-brand' }; + \`\${intersection}\`; + `, + errors: [ + { + messageId: 'noUselessTemplateLiteral', + line: 3, + column: 12, + endColumn: 24, + }, + ], + }, + + { + code: ` + function func(arg: T) { + \`\${arg}\`; + } + `, + errors: [ + { + messageId: 'noUselessTemplateLiteral', + line: 3, + column: 14, + endColumn: 17, + }, + ], + }, + ], +}); diff --git a/packages/eslint-plugin/tests/schema-snapshots/no-useless-template-literals.shot b/packages/eslint-plugin/tests/schema-snapshots/no-useless-template-literals.shot new file mode 100644 index 00000000000..785d465a840 --- /dev/null +++ b/packages/eslint-plugin/tests/schema-snapshots/no-useless-template-literals.shot @@ -0,0 +1,14 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Rule schemas should be convertible to TS types for documentation purposes no-useless-template-literals 1`] = ` +" +# SCHEMA: + +[] + + +# TYPES: + +/** No options declared */ +type Options = [];" +`;