TypeScript Template Literal Types
Template literal types let you build new string literal types out of other string literal types, using the same `${...}` syntax you already know from JavaScript template strings — but evaluated entirely by the compiler, at the type level. Instead of describing a value’s shape, they describe the exact text pattern a string must match. This is enormously useful for modeling APIs where strings follow a structured convention: CSS property names, event names, route paths, SQL-ish query fragments, and object keys derived from other keys.
Overview / How it works
A template literal type looks exactly like a JavaScript template string, except it appears in a type position and its interpolated slots hold types instead of values:
type Loud = `${string}!`;
Loud is not a value — it is the type of every string that ends with an exclamation mark. If you interpolate a union of string literals instead of the generic string type, TypeScript does something powerful: it distributes the template over every member of the union and produces a new union of concrete string literal types, one per combination. For example:
type Size = "sm" | "md" | "lg";
type SizeClass = `size-${Size}`;
// SizeClass is exactly: "size-sm" | "size-md" | "size-lg"
This distributive behavior is the heart of the feature. Interpolate two unions and you get the full cross product of every combination; interpolate N unions with a, b, c… members and you get a × b × c resulting literal types. This is why the type checker can validate that a string argument matches one of a fixed, known set of patterns — something plain string can never express.
Template literal types are almost always combined with three other features: union types (to supply the variety that gets distributed), the built-in intrinsic string manipulation types (Uppercase<T>, Lowercase<T>, Capitalize<T>, Uncapitalize<T>), and mapped type key remapping (the as clause inside a mapped type), which lets you transform an object’s keys into new template-literal-shaped keys. All three appear in the examples below.
Like all TypeScript types, template literal types are a compile-time-only construct. They influence what the compiler will accept, but they are completely erased when the code is compiled to JavaScript — at runtime a value typed as `margin-${Direction}` is just a plain string, indistinguishable from any other string.
Syntax
type Result = `prefix-${UnionOrType}-suffix`;
- Backticks — the whole type is written inside backticks, exactly like a JS template string.
${...}interpolation slots — each slot can hold a string literal type, a union of literal types,string,number,boolean,bigint,null,undefined, or another template literal type.- Literal text — any characters outside the
${...}slots are matched or produced verbatim. - Distribution — if a slot holds a union of literal types, the compiler expands the template into a union covering every combination.
- Intrinsic helpers —
Uppercase<T>,Lowercase<T>,Capitalize<T>, andUncapitalize<T>can wrap a slot to transform the casing of the resulting literal(s).
Examples
Example 1: A type-safe set of CSS-like keys
type Direction = "top" | "right" | "bottom" | "left";
type Margin = `margin-${Direction}`;
const m: Margin = "margin-top";
console.log(m);
function setMargin(prop: Margin, value: string): string {
return `${prop}: ${value};`;
}
console.log(setMargin("margin-left", "10px"));
Output:
margin-top
margin-left: 10px;
Margin expands to the union "margin-top" | "margin-right" | "margin-bottom" | "margin-left". Passing anything else, like "margin-diagonal", would be a compile error. This is far stronger than typing prop: string, which would accept any text at all.
Example 2: Cross product of two unions
type Corner = "top-left" | "top-right" | "bottom-left" | "bottom-right";
type Size = "sm" | "md" | "lg";
type ButtonClass = `btn-${Corner}-${Size}`;
function applyClass(cls: ButtonClass): void {
console.log(`Applying class: ${cls}`);
}
applyClass("btn-top-left-md");
Output:
Applying class: btn-top-left-md
Because two unions are interpolated (four corners, three sizes), ButtonClass actually expands to 12 concrete string literal types. This demonstrates why you should interpolate at most a small number of small unions — the number of resulting literals multiplies quickly, and huge unions can slow down the compiler or produce error messages that are hard to read.
Example 3: Generating keys with mapped-type remapping
type Fields = "name" | "age" | "email";
type Getters = {
[K in Fields as `get${Capitalize}`]: () => string;
};
const record: Getters = {
getName: () => "Ada",
getAge: () => "36",
getEmail: () => "ada@example.com",
};
console.log(record.getName());
console.log(record.getAge());
console.log(record.getEmail());
Output:
Ada
36
ada@example.com
The mapped type’s as clause rewrites each original key (name, age, email) into a new key by wrapping it in a template literal and running it through Capitalize. The result, Getters, requires exactly the properties getName, getAge, and getEmail, each a no-argument function returning string. This pattern — deriving getter/setter, event-handler, or action-creator key names from a base set of field names — is one of the most common real-world uses of template literal types.
How it works step by step / Under the hood
When the compiler encounters a template literal type, it evaluates it structurally, the same way it evaluates any other type:
- It looks at each interpolated slot. If the slot’s type is a union of string (or numeric) literals, the compiler treats the whole template as distributive over that union.
- For every combination of members across all interpolated unions, it concatenates the literal text pieces with each member to produce one new string literal type.
- All the produced literals are combined into a single union — this union is the resulting template literal type.
- When you later use that type (as a parameter type, a mapped-type key, a return type, and so on), assignability is checked exactly like any other union of literals: the value must exactly match one of the union’s members.
Here’s the distribution rule made visible — three languages in, three concrete keys out:
type Lang = "en" | "fr" | "de";
type LocaleKey = `greeting_${Lang}`;
const translations: Record = {
greeting_en: "Hello",
greeting_fr: "Bonjour",
greeting_de: "Hallo",
};
function translate(key: LocaleKey): string {
return translations[key];
}
console.log(translate("greeting_fr"));
Output:
Bonjour
LocaleKey is really the union "greeting_en" | "greeting_fr" | "greeting_de", so the Record<LocaleKey, string> forces the translations object to define all three keys and no others — leaving one out, or adding greeting_es, is a compile error. Crucially, none of this exists once the file is compiled: the emitted JavaScript is just a plain object literal and a function that indexes into it. There is no type information left at runtime; typeof key in the compiled output is simply "string". Template literal types are a purely static tool for catching mistakes before the code ever runs.
Common Mistakes
Mistake 1: Assigning a plain string to a template literal type
Beginners often assume any string can flow into a template literal type as long as it “looks right” at runtime. It can’t — the compiler only sees the static type, not the value:
type Direction = "top" | "right" | "bottom" | "left";
type Margin = `margin-${Direction}`;
function applyMargin(input: string) {
const m: Margin = input;
console.log(m);
}
tsc reports: Type 'string' is not assignable to type 'Margin'. Even though every value that reaches applyMargin at runtime might genuinely be a valid margin string, the parameter’s declared type is the general string, and TypeScript never narrows a wide type to a narrower one just because it might work out. The fix is to validate the value first with a type guard so the compiler can narrow it:
type Direction = "top" | "right" | "bottom" | "left";
type Margin = `margin-${Direction}`;
function isMargin(value: string): value is Margin {
return /^margin-(top|right|bottom|left)$/.test(value);
}
function applyMargin(input: string): void {
if (isMargin(input)) {
console.log(`Valid margin: ${input}`);
} else {
console.log(`Invalid margin: ${input}`);
}
}
applyMargin("margin-top");
applyMargin("padding-top");
Output:
Valid margin: margin-top
Invalid margin: padding-top
Mistake 2: Expecting inference to work with a mismatched pattern
When a generic parameter is embedded inside a template literal type, TypeScript tries to infer it by pattern-matching the argument against the template. If the argument doesn’t actually contain the required literal suffix or prefix, inference fails outright:
function on(eventName: `${K}Changed`): void {
console.log(`Listening for ${eventName}`);
}
on("firstName");
tsc reports something like: Argument of type '"firstName"' is not assignable to parameter of type '`${string}Changed`'. The string "firstName" doesn’t end in "Changed", so there is no value of K that makes the pattern match. The fix is to pass a string that actually fits the template’s shape:
function on(eventName: `${K}Changed`): void {
console.log(`Listening for ${eventName}`);
}
on("firstNameChanged");
Output:
Listening for firstNameChanged
Best Practices
- Reach for template literal types when a string follows a fixed, known convention (CSS properties, event names, route segments, i18n keys) — not for free-form user text, where
stringis the honest and correct type. - Keep the number of interpolated unions small; each additional union multiplies the size of the resulting type and can make compiler errors long and hard to read.
- Combine template literal types with mapped-type key remapping (
as) to auto-derive related keys (getters, setters, event handlers) instead of writing them out by hand and letting them drift out of sync. - Use
Uppercase,Lowercase,Capitalize, andUncapitalizerather than manually re-declaring casing variants of the same literal union. - When you need to accept a runtime string and treat it as a template literal type, write an explicit type guard (a
value is Patternfunction or a regex check) instead of asserting withas, which bypasses the check entirely. - Remember types are erased: never rely on a template literal type to perform runtime validation. It only prevents mistyped literals from compiling; malformed data from
JSON.parse, user input, or network responses still needs a runtime check.
Practice Exercises
- Exercise 1: Define a union type
HttpMethodwith the members"get" | "post" | "put" | "delete", then create a template literal typeApiEventequal to`api-${HttpMethod}-start` | `api-${HttpMethod}-end`style values (hint: you can build this either by interpolating a second union of"start" | "end", or by writing two separate template literal types and unioning them). Write a function that accepts only anApiEventand logs it. - Exercise 2: Given
type Colors = "red" | "green" | "blue";, use a mapped type with key remapping andUppercaseto build a typeColorConstantswhose keys areRED,GREEN, andBLUE, each holding the original lowercase string as its value. Then create aconstobject that satisfies it. - Exercise 3: Write a generic function
withPrefix<P extends string, K extends string>(prefix: P, key: K)that returns a value typed as “ `${P}${K}` “ (hint: its return type should be the template literal type built from the two type parameters, and its implementation can simply return the concatenated string). Call it with"user_"and"id"and check that the inferred return type is exactly"user_id".
Summary
- Template literal types build new string literal types using
`${...}`syntax evaluated at the type level, not the value level. - Interpolating a union of literals makes the template distribute, producing a union that covers every combination — the type-level equivalent of a cross product.
- They pair naturally with
Uppercase,Lowercase,Capitalize,Uncapitalize, and mapped-type key remapping (as) to derive new keys from existing ones. - A plain
stringis never automatically assignable to a template literal type; use a type guard to narrow it safely. - Generic inference against a template literal type fails if the argument doesn’t actually match the required pattern.
- Like all TypeScript types, template literal types are fully erased at compile time — they exist only to catch mistakes before the code runs, never to validate data at runtime.
