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 000000000000..f641e8d1f823 --- /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 5142a24bd540..f6e14dfe12a5 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 4bacae681569..2fe413146c7b 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 dfba0b81c7fa..471175b9bba7 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 127dde8831df..78efc59bb853 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 5423a7b82075..14c171af990e 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 000000000000..48c714edb90a --- /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 cc65cb32f4a7..a811396013cd 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 43f6902a20e3..94c796a98a8e 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 8c891aea6248..89cdd1a73ed5 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 000000000000..54ac89c2c797 --- /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 000000000000..785d465a8408 --- /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 = [];" +`; pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy