diff --git a/packages/eslint-plugin/docs/rules/no-unnecessary-template-expression.mdx b/packages/eslint-plugin/docs/rules/no-unnecessary-template-expression.mdx index 1fe39d412dd1..c720652ebf7a 100644 --- a/packages/eslint-plugin/docs/rules/no-unnecessary-template-expression.mdx +++ b/packages/eslint-plugin/docs/rules/no-unnecessary-template-expression.mdx @@ -28,6 +28,8 @@ The new name is a drop-in replacement with identical functionality. const ab1 = `${'a'}${'b'}`; const ab2 = `a${'b'}`; +type AB1 = `${'A'}${'B'}`; +type AB2 = `A${'B'}`; const stringWithNumber = `${'1 + 1 = '}${2}`; @@ -38,9 +40,13 @@ const stringWithBoolean = `${'true is '}${true}`; const text = 'a'; const wrappedText = `${text}`; +type Text = 'A'; +type WrappedText = `${Text}`; declare const intersectionWithString: string & { _brand: 'test-brand' }; const wrappedIntersection = `${intersectionWithString}`; +type IntersectionWithString = string & { _brand: 'test-brand' }; +type WrappedIntersection = `${IntersectionWithString}`; ``` @@ -51,6 +57,15 @@ const wrappedIntersection = `${intersectionWithString}`; const ab1 = `ab`; const ab2 = `ab`; +type AB = `AB`; + +// Transforming enum members into string unions using template literals is allowed. +enum ABC { + A = 'A', + B = 'B', + C = 'C', +} +type ABCUnion = `${ABC}`; const stringWithNumber = `1 + 1 = 2`; @@ -61,9 +76,13 @@ const stringWithBoolean = `true is true`; const text = 'a'; const wrappedText = text; +type Text = 'A'; +type WrappedText = Text; declare const intersectionWithString: string & { _brand: 'test-brand' }; const wrappedIntersection = intersectionWithString; +type IntersectionWithString = string & { _brand: 'test-brand' }; +type WrappedIntersection = IntersectionWithString; ``` diff --git a/packages/eslint-plugin/src/rules/no-unnecessary-template-expression.ts b/packages/eslint-plugin/src/rules/no-unnecessary-template-expression.ts index fde1ea99e780..308855686d79 100644 --- a/packages/eslint-plugin/src/rules/no-unnecessary-template-expression.ts +++ b/packages/eslint-plugin/src/rules/no-unnecessary-template-expression.ts @@ -1,6 +1,6 @@ -import type { TSESLint, TSESTree } from '@typescript-eslint/utils'; +import type { TSESLint } from '@typescript-eslint/utils'; -import { AST_NODE_TYPES } from '@typescript-eslint/utils'; +import { TSESTree, AST_NODE_TYPES } from '@typescript-eslint/utils'; import * as ts from 'typescript'; import { @@ -8,6 +8,7 @@ import { getConstraintInfo, getMovedNodeCode, getParserServices, + isNodeOfType, isTypeFlagSet, isUndefinedIdentifier, nullThrows, @@ -17,6 +18,16 @@ import { rangeToLoc } from '../util/rangeToLoc'; export type MessageId = 'noUnnecessaryTemplateExpression'; +type TemplateLiteralTypeOrValue = + | TSESTree.TemplateLiteral + | TSESTree.TSTemplateLiteralType; + +interface InterpolationInfo { + interpolation: TSESTree.Expression | TSESTree.TypeNode; + prevQuasi: TSESTree.TemplateElement; + nextQuasi: TSESTree.TemplateElement; +} + const evenNumOfBackslashesRegExp = /(?({ defaultOptions: [], create(context) { const services = getParserServices(context); + const checker = services.program.getTypeChecker(); - function isUnderlyingTypeString( - expression: TSESTree.Expression, - ): expression is TSESTree.Identifier | TSESTree.StringLiteral { - const checker = services.program.getTypeChecker(); - const { constraintType } = getConstraintInfo( - checker, - services.getTypeAtLocation(expression), - ); - - if (constraintType == null) { - return false; - } - - const isString = (t: ts.Type): boolean => { - return isTypeFlagSet(t, ts.TypeFlags.StringLike); - }; + function isStringLike(type: ts.Type): boolean { + return isTypeFlagSet(type, ts.TypeFlags.StringLike); + } - if (constraintType.isUnion()) { - return constraintType.types.every(isString); + function isUnderlyingTypeString(type: ts.Type): boolean { + if (type.isUnion()) { + return type.types.every(isStringLike); } - if (constraintType.isIntersection()) { - return constraintType.types.some(isString); + if (type.isIntersection()) { + return type.types.some(isStringLike); } - return isString(constraintType); + return isStringLike(type); } - function isLiteral( - expression: TSESTree.Expression, - ): expression is TSESTree.Literal { - return expression.type === AST_NODE_TYPES.Literal; + /** + * Checks for whole enum types, i.e. `MyEnum`, and not their values, i.e. `MyEnum.A` + */ + function isEnumType(type: ts.Type): boolean { + const symbol = type.getSymbol(); + + return !!( + symbol?.valueDeclaration && + ts.isEnumDeclaration(symbol.valueDeclaration) + ); } + const isLiteral = isNodeOfType(TSESTree.AST_NODE_TYPES.Literal); + function isTemplateLiteral( - expression: TSESTree.Expression, - ): expression is TSESTree.TemplateLiteral { - return expression.type === AST_NODE_TYPES.TemplateLiteral; + node: TSESTree.Node, + ): node is TSESTree.TemplateLiteral { + return node.type === AST_NODE_TYPES.TemplateLiteral; } - function isInfinityIdentifier(expression: TSESTree.Expression): boolean { + function isInfinityIdentifier(node: TSESTree.Node): boolean { return ( - expression.type === AST_NODE_TYPES.Identifier && - expression.name === 'Infinity' + node.type === AST_NODE_TYPES.Identifier && node.name === 'Infinity' ); } - function isNaNIdentifier(expression: TSESTree.Expression): boolean { + function isNaNIdentifier(node: TSESTree.Node): boolean { + return node.type === AST_NODE_TYPES.Identifier && node.name === 'NaN'; + } + + function isFixableIdentifier(node: TSESTree.Node): boolean { return ( - expression.type === AST_NODE_TYPES.Identifier && - expression.name === 'NaN' + isUndefinedIdentifier(node) || + isInfinityIdentifier(node) || + isNaNIdentifier(node) ); } @@ -118,220 +130,348 @@ export default createRule<[], MessageId>({ return context.sourceCode.commentsExistBetween(startToken, endToken); } - return { - TemplateLiteral(node: TSESTree.TemplateLiteral): void { - if (node.parent.type === AST_NODE_TYPES.TaggedTemplateExpression) { - return; - } + function isTrivialInterpolation( + node: TSESTree.TemplateLiteral | TSESTree.TSTemplateLiteralType, + ) { + return ( + node.quasis.length === 2 && + node.quasis[0].value.raw === '' && + node.quasis[1].value.raw === '' + ); + } - const hasSingleStringVariable = - node.quasis.length === 2 && - node.quasis[0].value.raw === '' && - node.quasis[1].value.raw === '' && - node.expressions.length === 1 && - isUnderlyingTypeString(node.expressions[0]); + function getInterpolations( + node: TemplateLiteralTypeOrValue, + ): TSESTree.Expression[] | TSESTree.TypeNode[] { + if (node.type === AST_NODE_TYPES.TemplateLiteral) { + return node.expressions; + } + return node.types; + } - if (hasSingleStringVariable) { - if (hasCommentsBetweenQuasi(node.quasis[0], node.quasis[1])) { - return; - } + function getInterpolationInfos( + node: TemplateLiteralTypeOrValue, + ): InterpolationInfo[] { + return getInterpolations(node).map((interpolation, index) => ({ + interpolation, + nextQuasi: node.quasis[index + 1], + prevQuasi: node.quasis[index], + })); + } - context.report({ - loc: rangeToLoc(context.sourceCode, [ - node.expressions[0].range[0] - 2, - node.expressions[0].range[1] + 1, - ]), - messageId: 'noUnnecessaryTemplateExpression', - fix(fixer): TSESLint.RuleFix | null { - const wrappingCode = getMovedNodeCode({ - destinationNode: node, - nodeToMove: node.expressions[0], - sourceCode: context.sourceCode, - }); - - return fixer.replaceText(node, wrappingCode); - }, + function getLiteral( + node: TSESTree.Expression | TSESTree.TypeNode, + ): TSESTree.Literal | null { + const maybeLiteral = + node.type === AST_NODE_TYPES.TSLiteralType ? node.literal : node; + return isLiteral(maybeLiteral) ? maybeLiteral : null; + } + + function getTemplateLiteral( + node: TSESTree.Expression | TSESTree.TypeNode, + ): TSESTree.TemplateLiteral | null { + const maybeTemplateLiteral = + node.type === AST_NODE_TYPES.TSLiteralType ? node.literal : node; + return isTemplateLiteral(maybeTemplateLiteral) + ? maybeTemplateLiteral + : null; + } + + function reportSingleInterpolation(node: TemplateLiteralTypeOrValue): void { + const interpolations = getInterpolations(node); + context.report({ + loc: rangeToLoc(context.sourceCode, [ + interpolations[0].range[0] - 2, + interpolations[0].range[1] + 1, + ]), + messageId: 'noUnnecessaryTemplateExpression', + fix(fixer): TSESLint.RuleFix | null { + const wrappingCode = getMovedNodeCode({ + destinationNode: node, + nodeToMove: interpolations[0], + sourceCode: context.sourceCode, }); - return; + return fixer.replaceText(node, wrappingCode); + }, + }); + } + + function isUnncessaryValueInterpolation({ + interpolation, + nextQuasi, + prevQuasi, + }: InterpolationInfo): boolean { + if (hasCommentsBetweenQuasi(prevQuasi, nextQuasi)) { + return false; + } + + if (isFixableIdentifier(interpolation)) { + return true; + } + + if (isLiteral(interpolation)) { + // allow trailing whitespace literal + if (startsWithNewLine(nextQuasi.value.raw)) { + return !( + typeof interpolation.value === 'string' && + isWhitespace(interpolation.value) + ); } + return true; + } - const fixableExpressionsReversed = node.expressions - .map((expression, index) => ({ - expression, - nextQuasi: node.quasis[index + 1], - prevQuasi: node.quasis[index], - })) - .filter(({ expression, nextQuasi, prevQuasi }) => { - if ( - isUndefinedIdentifier(expression) || - isInfinityIdentifier(expression) || - isNaNIdentifier(expression) - ) { - return true; - } - - // allow expressions that include comments - if (hasCommentsBetweenQuasi(prevQuasi, nextQuasi)) { - return false; - } - - if (isLiteral(expression)) { - // allow trailing whitespace literal - if (startsWithNewLine(nextQuasi.value.raw)) { - return !( - typeof expression.value === 'string' && - isWhitespace(expression.value) - ); - } - return true; - } - - if (isTemplateLiteral(expression)) { - // allow trailing whitespace literal - if (startsWithNewLine(nextQuasi.value.raw)) { - return !( - expression.quasis.length === 1 && - isWhitespace(expression.quasis[0].value.raw) - ); - } - return true; - } - - return false; - }) - .reverse(); - - let nextCharacterIsOpeningCurlyBrace = false; - - for (const { - expression, - nextQuasi, - prevQuasi, - } of fixableExpressionsReversed) { - const fixers: ((fixer: TSESLint.RuleFixer) => TSESLint.RuleFix[])[] = - []; - - if (nextQuasi.value.raw !== '') { - nextCharacterIsOpeningCurlyBrace = - nextQuasi.value.raw.startsWith('{'); - } + if (isTemplateLiteral(interpolation)) { + // allow trailing whitespace literal + if (startsWithNewLine(nextQuasi.value.raw)) { + return !( + interpolation.quasis.length === 1 && + isWhitespace(interpolation.quasis[0].value.raw) + ); + } + return true; + } - if (isLiteral(expression)) { - let escapedValue = ( - typeof expression.value === 'string' - ? // The value is already a string, so we're removing quotes: - // "'va`lue'" -> "va`lue" - expression.raw.slice(1, -1) - : // The value may be one of number | bigint | boolean | RegExp | null. - // In regular expressions, we escape every backslash - String(expression.value).replaceAll('\\', '\\\\') - ) - // The string or RegExp may contain ` or ${. - // We want both of these to be escaped in the final template expression. - // - // A pair of backslashes means "escaped backslash", so backslashes - // from this pair won't escape ` or ${. Therefore, to escape these - // sequences in the resulting template expression, we need to escape - // all sequences that are preceded by an even number of backslashes. - // - // This RegExp does the following transformations: - // \` -> \` - // \\` -> \\\` - // \${ -> \${ - // \\${ -> \\\${ - .replaceAll( - new RegExp( - `${String(evenNumOfBackslashesRegExp.source)}(\`|\\\${)`, - 'g', - ), - '\\$1', - ); - - // `...${'...$'}{...` - // ^^^^ - if ( - nextCharacterIsOpeningCurlyBrace && - endsWithUnescapedDollarSign(escapedValue) - ) { - escapedValue = escapedValue.replaceAll(/\$$/g, '\\$'); - } - - if (escapedValue.length !== 0) { - nextCharacterIsOpeningCurlyBrace = escapedValue.startsWith('{'); - } - - fixers.push(fixer => [fixer.replaceText(expression, escapedValue)]); - } else if (isTemplateLiteral(expression)) { - // Since we iterate from the last expression to the first, - // a subsequent expression can tell the current expression - // that it starts with {. + return false; + } + + function isUnncessaryTypeInterpolation({ + interpolation, + nextQuasi, + prevQuasi, + }: InterpolationInfo): boolean { + if (hasCommentsBetweenQuasi(prevQuasi, nextQuasi)) { + return false; + } + + const literal = getLiteral(interpolation); + if (literal) { + // allow trailing whitespace literal + if (startsWithNewLine(nextQuasi.value.raw)) { + return !( + typeof literal.value === 'string' && isWhitespace(literal.value) + ); + } + return true; + } + + if ( + interpolation.type === AST_NODE_TYPES.TSNullKeyword || + interpolation.type === AST_NODE_TYPES.TSUndefinedKeyword + ) { + return true; + } + + const templateLiteral = getTemplateLiteral(interpolation); + if (templateLiteral) { + // allow trailing whitespace literal + if (startsWithNewLine(nextQuasi.value.raw)) { + return !( + templateLiteral.quasis.length === 1 && + isWhitespace(templateLiteral.quasis[0].value.raw) + ); + } + return true; + } + + return false; + } + + function getReportDescriptors( + infos: InterpolationInfo[], + ): TSESLint.ReportDescriptor[] { + let nextCharacterIsOpeningCurlyBrace = false; + const reportDescriptors: TSESLint.ReportDescriptor[] = []; + const reversedInfos = [...infos].reverse(); + for (const { interpolation, nextQuasi, prevQuasi } of reversedInfos) { + const fixers: ((fixer: TSESLint.RuleFixer) => TSESLint.RuleFix[])[] = + []; + + if (nextQuasi.value.raw !== '') { + nextCharacterIsOpeningCurlyBrace = + nextQuasi.value.raw.startsWith('{'); + } + + const literal = getLiteral(interpolation); + const templateLiteral = getTemplateLiteral(interpolation); + if (literal) { + let escapedValue = ( + typeof literal.value === 'string' + ? // The value is already a string, so we're removing quotes: + // "'va`lue'" -> "va`lue" + literal.raw.slice(1, -1) + : // The value may be one of number | bigint | boolean | RegExp | null. + // In regular expressions, we escape every backslash + String(literal.value).replaceAll('\\', '\\\\') + ) + // The string or RegExp may contain ` or ${. + // We want both of these to be escaped in the final template expression. // - // `... ${`... $`}${'{...'} ...` - // ^ ^ subsequent expression starts with { - // current expression ends with a dollar sign, - // so '$' + '{' === '${' (bad news for us). - // Let's escape the dollar sign at the end. - if ( - nextCharacterIsOpeningCurlyBrace && - endsWithUnescapedDollarSign( - expression.quasis[expression.quasis.length - 1].value.raw, - ) - ) { - fixers.push(fixer => [ - fixer.replaceTextRange( - [expression.range[1] - 2, expression.range[1] - 2], - '\\', - ), - ]); - } - if ( - expression.quasis.length === 1 && - expression.quasis[0].value.raw.length !== 0 - ) { - nextCharacterIsOpeningCurlyBrace = - expression.quasis[0].value.raw.startsWith('{'); - } - - // Remove the beginning and trailing backtick characters. - fixers.push(fixer => [ - fixer.removeRange([expression.range[0], expression.range[0] + 1]), - fixer.removeRange([expression.range[1] - 1, expression.range[1]]), - ]); - } else { - nextCharacterIsOpeningCurlyBrace = false; + // A pair of backslashes means "escaped backslash", so backslashes + // from this pair won't escape ` or ${. Therefore, to escape these + // sequences in the resulting template expression, we need to escape + // all sequences that are preceded by an even number of backslashes. + // + // This RegExp does the following transformations: + // \` -> \` + // \\` -> \\\` + // \${ -> \${ + // \\${ -> \\\${ + .replaceAll( + new RegExp( + `${String(evenNumOfBackslashesRegExp.source)}(\`|\\\${)`, + 'g', + ), + '\\$1', + ); + + // `...${'...$'}{...` + // ^^^^ + if ( + nextCharacterIsOpeningCurlyBrace && + endsWithUnescapedDollarSign(escapedValue) + ) { + escapedValue = escapedValue.replaceAll(/\$$/g, '\\$'); } - // `... $${'{...'} ...` - // ^^^^^ + if (escapedValue.length !== 0) { + nextCharacterIsOpeningCurlyBrace = escapedValue.startsWith('{'); + } + + fixers.push(fixer => [fixer.replaceText(literal, escapedValue)]); + } else if (templateLiteral) { + // Since we iterate from the last expression to the first, + // a subsequent expression can tell the current expression + // that it starts with {. + // + // `... ${`... $`}${'{...'} ...` + // ^ ^ subsequent expression starts with { + // current expression ends with a dollar sign, + // so '$' + '{' === '${' (bad news for us). + // Let's escape the dollar sign at the end. if ( nextCharacterIsOpeningCurlyBrace && - endsWithUnescapedDollarSign(prevQuasi.value.raw) + endsWithUnescapedDollarSign( + templateLiteral.quasis[templateLiteral.quasis.length - 1].value + .raw, + ) ) { fixers.push(fixer => [ fixer.replaceTextRange( - [prevQuasi.range[1] - 3, prevQuasi.range[1] - 2], - '\\$', + [templateLiteral.range[1] - 2, templateLiteral.range[1] - 2], + '\\', ), ]); } + if ( + templateLiteral.quasis.length === 1 && + templateLiteral.quasis[0].value.raw.length !== 0 + ) { + nextCharacterIsOpeningCurlyBrace = + templateLiteral.quasis[0].value.raw.startsWith('{'); + } - const warnLocStart = prevQuasi.range[1] - 2; - const warnLocEnd = nextQuasi.range[0] + 1; - - context.report({ - loc: rangeToLoc(context.sourceCode, [warnLocStart, warnLocEnd]), - messageId: 'noUnnecessaryTemplateExpression', - fix(fixer): TSESLint.RuleFix[] { - return [ - // Remove the quasis' parts that are related to the current expression. - fixer.removeRange([warnLocStart, expression.range[0]]), - fixer.removeRange([expression.range[1], warnLocEnd]), - - ...fixers.flatMap(cb => cb(fixer)), - ]; - }, - }); + // Remove the beginning and trailing backtick characters. + fixers.push(fixer => [ + fixer.removeRange([ + templateLiteral.range[0], + templateLiteral.range[0] + 1, + ]), + fixer.removeRange([ + templateLiteral.range[1] - 1, + templateLiteral.range[1], + ]), + ]); + } else { + nextCharacterIsOpeningCurlyBrace = false; + } + + // `... $${'{...'} ...` + // ^^^^^ + if ( + nextCharacterIsOpeningCurlyBrace && + endsWithUnescapedDollarSign(prevQuasi.value.raw) + ) { + fixers.push(fixer => [ + fixer.replaceTextRange( + [prevQuasi.range[1] - 3, prevQuasi.range[1] - 2], + '\\$', + ), + ]); + } + + const warnLocStart = prevQuasi.range[1] - 2; + const warnLocEnd = nextQuasi.range[0] + 1; + reportDescriptors.push({ + loc: rangeToLoc(context.sourceCode, [warnLocStart, warnLocEnd]), + messageId: 'noUnnecessaryTemplateExpression', + fix(fixer): TSESLint.RuleFix[] { + return [ + // Remove the quasis' parts that are related to the current expression. + fixer.removeRange([warnLocStart, interpolation.range[0]]), + fixer.removeRange([interpolation.range[1], warnLocEnd]), + + ...fixers.flatMap(cb => cb(fixer)), + ]; + }, + }); + } + return reportDescriptors; + } + + return { + TemplateLiteral(node: TSESTree.TemplateLiteral): void { + if (node.parent.type === AST_NODE_TYPES.TaggedTemplateExpression) { + return; + } + if ( + isTrivialInterpolation(node) && + !hasCommentsBetweenQuasi(node.quasis[0], node.quasis[1]) + ) { + const { constraintType } = getConstraintInfo( + checker, + services.getTypeAtLocation(node.expressions[0]), + ); + if (constraintType && isUnderlyingTypeString(constraintType)) { + reportSingleInterpolation(node); + return; + } + } + + const infos = getInterpolationInfos(node).filter( + isUnncessaryValueInterpolation, + ); + + for (const reportDescriptor of getReportDescriptors(infos)) { + context.report(reportDescriptor); + } + }, + TSTemplateLiteralType(node: TSESTree.TSTemplateLiteralType): void { + if ( + isTrivialInterpolation(node) && + !hasCommentsBetweenQuasi(node.quasis[0], node.quasis[1]) + ) { + const { constraintType } = getConstraintInfo( + checker, + services.getTypeAtLocation(node.types[0]), + ); + + if ( + constraintType && + isUnderlyingTypeString(constraintType) && + !isEnumType(constraintType) + ) { + reportSingleInterpolation(node); + return; + } + } + + const infos = getInterpolationInfos(node).filter( + isUnncessaryTypeInterpolation, + ); + + for (const reportDescriptor of getReportDescriptors(infos)) { + context.report(reportDescriptor); } }, }; diff --git a/packages/eslint-plugin/tests/docs-eslint-output-snapshots/no-unnecessary-template-expression.shot b/packages/eslint-plugin/tests/docs-eslint-output-snapshots/no-unnecessary-template-expression.shot index 47aa863d7203..0065786d6ef9 100644 --- a/packages/eslint-plugin/tests/docs-eslint-output-snapshots/no-unnecessary-template-expression.shot +++ b/packages/eslint-plugin/tests/docs-eslint-output-snapshots/no-unnecessary-template-expression.shot @@ -10,6 +10,11 @@ const ab1 = \`\${'a'}\${'b'}\`; ~~~~~~ Template literal expression is unnecessary and can be simplified. const ab2 = \`a\${'b'}\`; ~~~~~~ Template literal expression is unnecessary and can be simplified. +type AB1 = \`\${'A'}\${'B'}\`; + ~~~~~~ Template literal expression is unnecessary and can be simplified. + ~~~~~~ Template literal expression is unnecessary and can be simplified. +type AB2 = \`A\${'B'}\`; + ~~~~~~ Template literal expression is unnecessary and can be simplified. const stringWithNumber = \`\${'1 + 1 = '}\${2}\`; ~~~~~~~~~~~~~ Template literal expression is unnecessary and can be simplified. @@ -25,10 +30,16 @@ const stringWithBoolean = \`\${'true is '}\${true}\`; const text = 'a'; const wrappedText = \`\${text}\`; ~~~~~~~ Template literal expression is unnecessary and can be simplified. +type Text = 'A'; +type WrappedText = \`\${Text}\`; + ~~~~~~~ Template literal expression is unnecessary and can be simplified. declare const intersectionWithString: string & { _brand: 'test-brand' }; const wrappedIntersection = \`\${intersectionWithString}\`; ~~~~~~~~~~~~~~~~~~~~~~~~~ Template literal expression is unnecessary and can be simplified. +type IntersectionWithString = string & { _brand: 'test-brand' }; +type WrappedIntersection = \`\${IntersectionWithString}\`; + ~~~~~~~~~~~~~~~~~~~~~~~~~ Template literal expression is unnecessary and can be simplified. " `; @@ -39,6 +50,15 @@ exports[`Validating rule docs no-unnecessary-template-expression.mdx code exampl const ab1 = \`ab\`; const ab2 = \`ab\`; +type AB = \`AB\`; + +// Transforming enum members into string unions using template literals is allowed. +enum ABC { + A = 'A', + B = 'B', + C = 'C', +} +type ABCUnion = \`\${ABC}\`; const stringWithNumber = \`1 + 1 = 2\`; @@ -49,8 +69,12 @@ const stringWithBoolean = \`true is true\`; const text = 'a'; const wrappedText = text; +type Text = 'A'; +type WrappedText = Text; declare const intersectionWithString: string & { _brand: 'test-brand' }; const wrappedIntersection = intersectionWithString; +type IntersectionWithString = string & { _brand: 'test-brand' }; +type WrappedIntersection = IntersectionWithString; " `; diff --git a/packages/eslint-plugin/tests/rules/no-unnecessary-template-expression.test.ts b/packages/eslint-plugin/tests/rules/no-unnecessary-template-expression.test.ts index eb87d7f3755d..160c9114f2aa 100644 --- a/packages/eslint-plugin/tests/rules/no-unnecessary-template-expression.test.ts +++ b/packages/eslint-plugin/tests/rules/no-unnecessary-template-expression.test.ts @@ -976,6 +976,9 @@ ruleTester.run('no-unnecessary-template-expression', rule, { valid: [ "const string = 'a';", 'const string = `a`;', + 'const string = `NaN: ${/* comment */ NaN}`;', + 'const string = `undefined: ${/* comment */ undefined}`;', + 'const string = `Infinity: ${Infinity /* comment */}`;', ` declare const string: 'a'; \`\${string}b\`; @@ -1097,6 +1100,11 @@ ruleTester.run('no-unnecessary-template-expression', rule, { ` \` this code has trailing whitespace: \${' '} + \`; + `, + ` +\` +this code has trailing whitespace: \${\` \`} \`; `, noFormat` @@ -1147,6 +1155,41 @@ this code has trailing whitespace: \${' '} return \`\${input}\`; } `, + ` +type FooBarBaz = \`foo\${/* comment */ 'bar'}"baz"\`; + `, + ` +enum Foo { + A = 'A', + B = 'B', +} +type Foos = \`\${Foo}\`; + `, + ` +type Foo = 'A' | 'B'; +type Bar = \`foo\${Foo}foo\`; + `, + ` +type Foo = + \`trailing position interpolated empty string also makes whitespace clear \${''} +\`; + `, + noFormat` +type Foo = \`this code has trailing whitespace with a windows \\\r new line: \${\` \`}\r\n\`; + `, + "type Foo = `${'foo' | 'bar' | null}`;", + + ` +type StringOrNumber = string | number; +type Foo = \`\${StringOrNumber}\`; + `, + ` +enum Foo { + A = 1, + B = 2, +} +type Bar = \`\${Foo.A}\`; + `, ], invalid: [ @@ -1264,5 +1307,150 @@ declare const nested: string, interpolation: string; ], output: "true ? ('test' || '').trim() : undefined;", }, + { + code: 'type Foo = `${1}`;', + errors: [{ messageId: 'noUnnecessaryTemplateExpression' }], + output: 'type Foo = `1`;', + }, + { + code: 'type Foo = `${null}`;', + errors: [{ messageId: 'noUnnecessaryTemplateExpression' }], + output: 'type Foo = `null`;', + }, + { + code: 'type Foo = `${undefined}`;', + errors: [{ messageId: 'noUnnecessaryTemplateExpression' }], + output: 'type Foo = `undefined`;', + }, + { + code: "type Foo = `${'foo'}`;", + errors: [{ messageId: 'noUnnecessaryTemplateExpression' }], + output: "type Foo = 'foo';", + }, + { + code: ` +type Foo = 'A' | 'B'; +type Bar = \`\${Foo}\`; + `, + errors: [ + { + column: 13, + endColumn: 19, + endLine: 3, + line: 3, + messageId: 'noUnnecessaryTemplateExpression', + }, + ], + output: ` +type Foo = 'A' | 'B'; +type Bar = Foo; + `, + }, + { + code: ` +type Foo = 'A' | 'B'; +type Bar = \`\${\`\${Foo}\`}\`; + `, + errors: [ + { + column: 13, + endColumn: 24, + endLine: 3, + line: 3, + messageId: 'noUnnecessaryTemplateExpression', + }, + { + column: 16, + endColumn: 22, + endLine: 3, + line: 3, + messageId: 'noUnnecessaryTemplateExpression', + }, + ], + output: [ + ` +type Foo = 'A' | 'B'; +type Bar = \`\${Foo}\`; + `, + + ` +type Foo = 'A' | 'B'; +type Bar = Foo; + `, + ], + }, + { + code: "type FooBarBaz = `foo${'bar'}baz`;", + errors: [ + { + messageId: 'noUnnecessaryTemplateExpression', + }, + ], + output: 'type FooBarBaz = `foobarbaz`;', + }, + { + code: 'type FooBar = `foo${`bar`}`;', + errors: [ + { + messageId: 'noUnnecessaryTemplateExpression', + }, + ], + output: 'type FooBar = `foobar`;', + }, + { + code: "type FooBar = `${'foo' | 'bar'}`;", + errors: [ + { + messageId: 'noUnnecessaryTemplateExpression', + }, + ], + output: "type FooBar = 'foo' | 'bar';", + }, + { + code: ` +enum Foo { + A = 'A', + B = 'B', +} +type Bar = \`\${Foo.A}\`; + `, + errors: [ + { + column: 13, + endColumn: 21, + endLine: 6, + line: 6, + messageId: 'noUnnecessaryTemplateExpression', + }, + ], + output: ` +enum Foo { + A = 'A', + B = 'B', +} +type Bar = Foo.A; + `, + }, + { + code: ` +function foo() { + const a: \`\${T}\` = 'a'; +} + `, + errors: [ + { + column: 13, + endColumn: 17, + endLine: 3, + line: 3, + messageId: 'noUnnecessaryTemplateExpression', + }, + ], + output: ` +function foo() { + const a: T = 'a'; +} + `, + }, ], });