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 helpersUppercase<T>, Lowercase<T>, Capitalize<T>, and Uncapitalize<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:

  1. 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.
  2. 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.
  3. All the produced literals are combined into a single union — this union is the resulting template literal type.
  4. 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 string is 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, and Uncapitalize rather 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 Pattern function or a regex check) instead of asserting with as, 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 HttpMethod with the members "get" | "post" | "put" | "delete", then create a template literal type ApiEvent equal 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 an ApiEvent and logs it.
  • Exercise 2: Given type Colors = "red" | "green" | "blue";, use a mapped type with key remapping and Uppercase to build a type ColorConstants whose keys are RED, GREEN, and BLUE, each holding the original lowercase string as its value. Then create a const object 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 string is 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.