TypeScript String Interpolation

September 28, 202610 min read
Requirements:
FunctionsObjects| Unions

TypeScript string interpolation is the ${} syntax inside backticks, and it is the one piece of modern JavaScript that almost everybody gets right on the first try. Type a template literal, drop a value in the hole, get a string out. Nothing to learn.

The types are where it gets interesting. Interpolation quietly throws away literal types, it accepts values that stringify into garbage, and the exact same syntax means something completely different in type position. Three examples cover the runtime half. The rest of this page is about what the compiler does and does not catch.

What TypeScript string interpolation produces

A template literal is a string with holes. Each ${} is evaluated, converted to a string, and spliced in.

const userName = 'Ada'
const userRole = 'admin'
 
const greeting = `Hello, ${userName}. You are signed in as ${userRole}.`
// "Hello, Ada. You are signed in as admin."

The expression in the hole is a full expression, not just a variable. Calls, arithmetic, and ternaries all work:

const itemCount: number = 3
 
const summary = `You have ${itemCount} item${itemCount === 1 ? '' : 's'} left.`
// "You have 3 items left."

Note the annotation on itemCount. Without it the type is the literal 3, and TypeScript rejects itemCount === 1 outright as a comparison that can never be true. That is the first hint that interpolation and literal types have a complicated relationship.

That is the whole runtime feature. The inferred type of every one of those is plain string.

Interpolation widens literal types

This is the first surprise. A const holding a string literal has a literal type. Push it through interpolation and you get string back.

const literalName = 'Ada' // type: "Ada"
 
const widened = `Hello, ${literalName}` // type: string ❌ not "Hello, Ada"

The compiler has enough information to compute "Hello, Ada" — it just does not, because that would be surprising in the other 95% of cases where the parts are dynamic. You opt in with a const assertion:

const constName = 'Ada'
 
const keptLiteral = `Hello, ${constName}` as const // type: "Hello, Ada" ✅

as const only helps when every hole is already a literal type. Feed it something wider and the result is wide too:

let mutableName = 'Ada' // type: string, because let widens
 
const stillWide = `Hello, ${mutableName}` as const // type: string

This matters the moment a function expects a narrow string. Say you have a route helper that only accepts real routes:

type KnownRoute = '/blog' | '/concepts' | '/challenges'
 
function navigate(route: KnownRoute): string {
  return `navigating to ${route}`
}
 
const area = 'concepts'
 
// navigate(`/${area}`) // ❌ Argument of type 'string' is not assignable to 'KnownRoute'
navigate(`/${area}` as const) // ✅ the const assertion keeps "/concepts"

The error message is the confusing part: it says string, not "/concepts", even though you can see the literal right there. That is widening, not a bug. Either add as const at the call site or widen the parameter to a template literal type such as `/${string}` and accept that you have given up the exhaustiveness check.

Anything with a toString goes in

Template holes accept almost any type. TypeScript does not require a string, and it does not warn you when the conversion is useless.

const account = { id: 7, plan: 'pro' }
 
const broken = `Account: ${account}` // "Account: [object Object]" — no error

That compiles cleanly under strict. It is the most common interpolation bug there is, and the type checker is no help at all, because Object.prototype.toString genuinely exists. Be explicit about how an object becomes text:

const fixed = `Account: ${account.id} (${account.plan})` // "Account: 7 (pro)"
const debugged = `Account: ${JSON.stringify(account)}` // "Account: {"id":7,"plan":"pro"}"

null and undefined behave the same way — they interpolate as the literal text, which is rarely what you want in something a user reads:

const maybeName: string | undefined = undefined
 
const nullish = `Hello, ${maybeName}` // "Hello, undefined"
const guarded = `Hello, ${maybeName ?? 'guest'}` // "Hello, guest" ✅

There is exactly one type TypeScript refuses: symbol. Implicit symbol-to-string conversion throws at runtime, so the compiler blocks it.

const marker = Symbol('marker')
 
// const badMarker = `Tag: ${marker}` // ❌ TS2731: Implicit conversion of a symbol to a string
const okMarker = `Tag: ${String(marker)}` // ✅ "Tag: Symbol(marker)"

If you want the same protection for objects, a tiny helper that only accepts printable types does the job. Narrowing an unknown value with typeof before it reaches the hole is the other half of that pattern.

type Printable = string | number | boolean | bigint
 
function show(value: Printable): string {
  return `${value}`
}
 
// show(account) // ❌ Argument of type '{ id: number; plan: string; }' is not assignable
show(account.id) // ✅ "7"

Where interpolation hides in other syntax

Template literals are not only for building sentences. They show up as computed property keys, which is the usual way to build a prefixed object at runtime:

const tenantId = 'acme'
 
const config = {
  [`${tenantId}_region`]: 'eu-central-1',
  [`${tenantId}_tier`]: 'standard',
}
// type: { [x: string]: string }

The inferred type is an index signature, not the two specific keys — widening again. If you need the real keys, a satisfies clause with a template literal type is the usual fix.

Arrays are the other quiet one. Interpolating an array calls join(','), which is almost never the separator you meant:

const tagList = ['ts', 'strings', 'types']
 
const commaJoined = `Tags: ${tagList}` // "Tags: ts,strings,types"
const spaced = `Tags: ${tagList.join(', ')}` // "Tags: ts, strings, types" ✅

Nesting works too, and stays readable as long as you only go one level deep:

const isAdmin = true
 
const banner = `Welcome${isAdmin ? ` back, ${'admin'}` : ''}!`
// "Welcome back, admin!"

Multi-line strings and String.raw

Template literals keep newlines and indentation exactly as written. That includes the leading whitespace of the line, which catches people out inside indented functions.

const routePath = '/users/1'
 
const rawRequest = `GET ${routePath}
Accept: application/json`
// two lines, no \n needed

Escape sequences still apply, so Windows paths and regex sources get mangled. String.raw is the tag that turns them off:

const mangled = `C:\new\table` // \n and \t become real control characters
const literal = String.raw`C:\new\table` // "C:\new\table" ✅

Regex sources are the other place this bites. Building a pattern from a normal template means escaping every backslash twice — once for the string, once for the regex — and String.raw removes one of those rounds:

[object Object]

Both tags return an ordinary string, so the type never tells you which one you used. That is worth a comment when the difference matters.

Typing a tagged template

String.raw is a tag: a function called with the literal's static chunks and its interpolated values. You can write your own, and the signature is always the same shape — a TemplateStringsArray first, then a rest parameter for the holes.

function highlight(strings: TemplateStringsArray, ...values: unknown[]): string {
  return strings.reduce((out, chunk, index) => {
    const filled = index < values.length ? `**${String(values[index])}**` : ''
    return out + chunk + filled
  }, '')
}
 
const invoiceState = 'overdue'
 
const notice = highlight`This invoice is ${invoiceState}.`
// "This invoice is **overdue**."

TemplateStringsArray is a readonly string[] with an extra raw property holding the un-escaped chunks. There is always exactly one more chunk than there are values, which is why the index < values.length guard is needed.

The useful part is that the rest parameter is yours to constrain. Type it narrowly and the tag rejects bad input at the call site:

function sqlish(strings: TemplateStringsArray, ...params: (string | number)[]): string {
  return `${strings.raw.join('?')} -- ${params.length} param(s)`
}
 
const rowId = 42
 
const statement = sqlish`select * from users where id = ${rowId}`
// "select * from users where id = ? -- 1 param(s)"
 
// sqlish`select * from users where id = ${account}` // ❌ object is not string | number

That is a real guardrail: an object can no longer slip into a query string as [object Object].

Interpolation vs concat vs join

Three ways to build the same string, and they are not interchangeable.

const city = 'Berlin'
const temperature = 21
 
const byInterpolation = `${city}: ${temperature}°C`
const byPlus = city + ': ' + temperature + '°C'
const byJoin = [city, ': ', temperature, '°C'].join('')

Interpolation wins for a fixed shape with a few holes — it reads like the output. + is fine for two pieces and turns into noise beyond that. join is the right tool when the number of pieces is not known until runtime, and concat is for arrays, not strings, despite the name suggesting otherwise.

One thing interpolation does not give you is formatting. Numbers go through the default toString, so do the rounding yourself:

const price = 19.5
 
const sloppy = `Total: ${price} EUR` // "Total: 19.5 EUR"
const formatted = `Total: ${price.toFixed(2)} EUR` // "Total: 19.50 EUR" ✅

The same syntax at the type level

Write a template literal in type position and you get a template literal type — a string type built from other string types, expanded across unions.

type Lang = 'en' | 'de'
type Area = 'blog' | 'concepts'
 
type PagePath = `/${Lang}/${Area}`
// "/en/blog" | "/en/concepts" | "/de/blog" | "/de/concepts"

It looks identical, but nothing is evaluated at runtime — this is the compiler multiplying unions together. That feature has its own page: Template Literal Types covers the patterns worth knowing, and BEM style string makes you write one.

When not to interpolate

Interpolation is a text-splicing tool, and it has no idea what the text means. Two cases where reaching for it is the wrong instinct.

The first is anything that gets parsed by something else — HTML, SQL, shell commands, regular expressions. Splicing an untrusted value into a string that another engine parses is how injection bugs happen, and the type checker cannot tell the difference between a safe string and a hostile one. Both are string.

const searchTerm = 'ada'
 
const unsafeMarkup = `<div>${searchTerm}</div>` // string, no warning, still unsafe

Use a parameterised query, a DOM API such as textContent, or a tag function that escapes as it builds — the sqlish tag above is the shape to copy. A tag can see the values separately from the static chunks, which is exactly the information an escaper needs and a plain template throws away.

The second is user-facing copy that will be translated. Word order is not stable across languages, so a hard-coded template bakes in an English sentence structure:

const fileTotal = 4
 
const hardCoded = `Deleted ${fileTotal} files` // untranslatable
const viaCatalog = translate('deleted_files', { count: fileTotal }) // ✅
 
function translate(key: string, params: Record<string, number | string>): string {
  return `${key}:${JSON.stringify(params)}`
}

Keep interpolation for logs, keys, paths, and anything a developer reads. For anything a user reads in more than one language, pass the values to a catalog and let it decide the order.

Summary

Runtime interpolation is simple; the types around it are the part to remember. Interpolation widens literal types unless you add as const. Every hole is silently toStringed, so objects become [object Object] and undefined becomes "undefined" with no complaint from the compiler — symbol is the only case it blocks. Tagged templates are the escape hatch when you want the holes type-checked, and a narrow rest parameter is all it takes.

If you want to push the string work into the type system itself, start with Template Literal Types, then try String Join and Length of String.

Share this article

Become a TypeScript Pro

Track your progress through 100+ hands-on challenges. Free, sign in with GitHub.

Or start solving right away: explore all TypeScript challenges

Practice with Challenges

Put your typescript string interpolation knowledge to the test with these related challenges.

#3326BEM style string
Medium
#847String Join
Hard
#298Length of String
Medium

Related Concepts

Concepts that build on or relate to typescript string interpolation.

Template Literal TypesJavaScript concat in TypeScriptJavaScript join in TypeScriptTypeScript typeof