TypeScript String Interpolation
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: stringThis 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 errorThat 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 neededEscape 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 | numberThat 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 unsafeUse 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.
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