Skip to content

Commit ff75785

Browse files
feat(eslint-plugin): [no-useless-template-literals] add new rule (#7957)
* feat(eslint-plugin): [no-useless-template-literals] add new rule Closes #2846 * fix tests * thank you josh * hopefully fix tests? * support template literals with new lines * support also quotes * fix all files (damn, we need an auto fixer...) * wip * report on specific node * fix docs * fix lint * wip * fix lint * revert unrelated changes * more reverts * wip * wip --------- Co-authored-by: Josh Goldberg ✨ <git@joshuakgoldberg.com>
1 parent c9661c8 commit ff75785

12 files changed

+505
-6
lines changed
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
---
2+
description: 'Disallow unnecessary template literals.'
3+
---
4+
5+
> 🛑 This file is source code, not the primary documentation location! 🛑
6+
>
7+
> See **https://typescript-eslint.io/rules/no-useless-template-literals** for documentation.
8+
9+
This rule reports template literals that can be simplified to a normal string literal.
10+
11+
## Examples
12+
13+
<!--tabs-->
14+
15+
### ❌ Incorrect
16+
17+
```ts
18+
const ab1 = `${'a'}${'b'}`;
19+
const ab2 = `a${'b'}`;
20+
21+
const stringWithNumber = `${'1 + 1 = '}${2}`;
22+
23+
const stringWithBoolean = `${'true is '}${true}`;
24+
25+
const text = 'a';
26+
const wrappedText = `${text}`;
27+
28+
declare const intersectionWithString: string & { _brand: 'test-brand' };
29+
const wrappedIntersection = `${intersectionWithString}`;
30+
```
31+
32+
### ✅ Correct
33+
34+
```ts
35+
const ab1 = 'ab';
36+
const ab2 = 'ab';
37+
38+
const stringWithNumber = `1 + 1 = 2`;
39+
40+
const stringWithBoolean = `true is true`;
41+
42+
const text = 'a';
43+
const wrappedText = text;
44+
45+
declare const intersectionWithString: string & { _brand: 'test-brand' };
46+
const wrappedIntersection = intersectionWithString;
47+
```
48+
49+
<!--/tabs-->
50+
51+
## When Not To Use It
52+
53+
When you want to allow string expressions inside template literals.
54+
55+
## Related To
56+
57+
- [`restrict-template-expressions`](./restrict-template-expressions.md)

packages/eslint-plugin/src/configs/all.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,7 @@ export = {
135135
'no-useless-constructor': 'off',
136136
'@typescript-eslint/no-useless-constructor': 'error',
137137
'@typescript-eslint/no-useless-empty-export': 'error',
138+
'@typescript-eslint/no-useless-template-literals': 'error',
138139
'@typescript-eslint/no-var-requires': 'error',
139140
'@typescript-eslint/non-nullable-type-assertion-style': 'error',
140141
'object-curly-spacing': 'off',

packages/eslint-plugin/src/configs/disable-type-checked.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ export = {
3535
'@typescript-eslint/no-unsafe-member-access': 'off',
3636
'@typescript-eslint/no-unsafe-return': 'off',
3737
'@typescript-eslint/no-unsafe-unary-minus': 'off',
38+
'@typescript-eslint/no-useless-template-literals': 'off',
3839
'@typescript-eslint/non-nullable-type-assertion-style': 'off',
3940
'@typescript-eslint/prefer-destructuring': 'off',
4041
'@typescript-eslint/prefer-includes': 'off',

packages/eslint-plugin/src/configs/strict-type-checked.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ export = {
5656
'@typescript-eslint/no-unused-vars': 'error',
5757
'no-useless-constructor': 'off',
5858
'@typescript-eslint/no-useless-constructor': 'error',
59+
'@typescript-eslint/no-useless-template-literals': 'error',
5960
'@typescript-eslint/no-var-requires': 'error',
6061
'@typescript-eslint/prefer-as-const': 'error',
6162
'@typescript-eslint/prefer-includes': 'error',

packages/eslint-plugin/src/rules/consistent-type-exports.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -189,7 +189,7 @@ export default createRule<Options, MessageIds>({
189189

190190
// We have both type and value violations.
191191
const allExportNames = report.typeBasedSpecifiers.map(
192-
specifier => `${specifier.local.name}`,
192+
specifier => specifier.local.name,
193193
);
194194

195195
if (allExportNames.length === 1) {

packages/eslint-plugin/src/rules/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@ import noUnusedVars from './no-unused-vars';
9393
import noUseBeforeDefine from './no-use-before-define';
9494
import noUselessConstructor from './no-useless-constructor';
9595
import noUselessEmptyExport from './no-useless-empty-export';
96+
import noUselessTemplateLiterals from './no-useless-template-literals';
9697
import noVarRequires from './no-var-requires';
9798
import nonNullableTypeAssertionStyle from './non-nullable-type-assertion-style';
9899
import objectCurlySpacing from './object-curly-spacing';
@@ -231,6 +232,7 @@ export default {
231232
'no-use-before-define': noUseBeforeDefine,
232233
'no-useless-constructor': noUselessConstructor,
233234
'no-useless-empty-export': noUselessEmptyExport,
235+
'no-useless-template-literals': noUselessTemplateLiterals,
234236
'no-var-requires': noVarRequires,
235237
'non-nullable-type-assertion-style': nonNullableTypeAssertionStyle,
236238
'object-curly-spacing': objectCurlySpacing,
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import type { TSESTree } from '@typescript-eslint/utils';
2+
import { AST_NODE_TYPES } from '@typescript-eslint/utils';
3+
import * as ts from 'typescript';
4+
5+
import {
6+
createRule,
7+
getConstrainedTypeAtLocation,
8+
getParserServices,
9+
isTypeFlagSet,
10+
isUndefinedIdentifier,
11+
} from '../util';
12+
13+
type MessageId = 'noUselessTemplateLiteral';
14+
15+
export default createRule<[], MessageId>({
16+
name: 'no-useless-template-literals',
17+
meta: {
18+
type: 'problem',
19+
docs: {
20+
description: 'Disallow unnecessary template literals',
21+
recommended: 'strict',
22+
requiresTypeChecking: true,
23+
},
24+
messages: {
25+
noUselessTemplateLiteral:
26+
'Template literal expression is unnecessary and can be simplified.',
27+
},
28+
schema: [],
29+
},
30+
defaultOptions: [],
31+
create(context) {
32+
const services = getParserServices(context);
33+
34+
function isUnderlyingTypeString(
35+
expression: TSESTree.Expression,
36+
): expression is TSESTree.StringLiteral | TSESTree.Identifier {
37+
const type = getConstrainedTypeAtLocation(services, expression);
38+
39+
const isString = (t: ts.Type): boolean => {
40+
return isTypeFlagSet(t, ts.TypeFlags.StringLike);
41+
};
42+
43+
if (type.isUnion()) {
44+
return type.types.every(isString);
45+
}
46+
47+
if (type.isIntersection()) {
48+
return type.types.some(isString);
49+
}
50+
51+
return isString(type);
52+
}
53+
54+
return {
55+
TemplateLiteral(node: TSESTree.TemplateLiteral): void {
56+
if (node.parent.type === AST_NODE_TYPES.TaggedTemplateExpression) {
57+
return;
58+
}
59+
60+
const hasSingleStringVariable =
61+
node.quasis.length === 2 &&
62+
node.quasis[0].value.raw === '' &&
63+
node.quasis[1].value.raw === '' &&
64+
node.expressions.length === 1 &&
65+
isUnderlyingTypeString(node.expressions[0]);
66+
67+
if (hasSingleStringVariable) {
68+
context.report({
69+
node: node.expressions[0],
70+
messageId: 'noUselessTemplateLiteral',
71+
});
72+
73+
return;
74+
}
75+
76+
const literalsOrUndefinedExpressions = node.expressions.filter(
77+
(expression): expression is TSESTree.Literal | TSESTree.Identifier =>
78+
expression.type === AST_NODE_TYPES.Literal ||
79+
isUndefinedIdentifier(expression),
80+
);
81+
82+
literalsOrUndefinedExpressions.forEach(expression => {
83+
context.report({
84+
node: expression,
85+
messageId: 'noUselessTemplateLiteral',
86+
});
87+
});
88+
},
89+
};
90+
},
91+
});

packages/eslint-plugin/tests/docs.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -159,7 +159,7 @@ describe('Validating rule metadata', () => {
159159
}
160160

161161
for (const [ruleName, rule] of rulesData) {
162-
describe(`${ruleName}`, () => {
162+
describe(ruleName, () => {
163163
it('`name` field in rule must match the filename', () => {
164164
// validate if rule name is same as url
165165
// there is no way to access this field but its used only in generation of docs url

packages/eslint-plugin/tests/rules/no-extra-parens.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
/* eslint "@typescript-eslint/internal/plugin-test-formatting": ["error", { formatWithPrettier: false }] */
44
/* eslint-enable eslint-comments/no-use */
55

6-
import { RuleTester } from '@typescript-eslint/rule-tester';
6+
import { noFormat, RuleTester } from '@typescript-eslint/rule-tester';
77

88
import rule from '../../src/rules/no-extra-parens';
99

@@ -743,7 +743,7 @@ const Component = (
743743
/>
744744
)
745745
`,
746-
output: `
746+
output: noFormat`
747747
const Component =${' '}
748748
<div>
749749
<p />

packages/eslint-plugin/tests/rules/no-unnecessary-condition.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1235,7 +1235,7 @@ foo ?.
12351235
foo
12361236
?. ();
12371237
`,
1238-
output: `
1238+
output: noFormat`
12391239
let foo = () => {};
12401240
foo();
12411241
foo ();
@@ -1285,7 +1285,7 @@ foo ?.
12851285
foo
12861286
?. (bar);
12871287
`,
1288-
output: `
1288+
output: noFormat`
12891289
let foo = () => {};
12901290
foo(bar);
12911291
foo (bar);

0 commit comments

Comments
 (0)