Template Literal Types

TypeScript · Lesson 19 of 28 5 min read Updated

JavaScript template literals build strings from pieces: `PNR-${number}`. TypeScript lets you use the same backtick syntax for types. A template literal type describes a pattern of strings, such as "any string that starts with #" or "a number followed by px", or produces every combination of two unions.

In this lesson you will write string pattern types, combine unions, use the built-in case-changing types, and finally parse strings at the type level with infer. These features are used by routers, CSS-in-JS libraries and event systems to catch typos in strings.

String patterns

Inside a template literal type you can place ${string}, ${number}, ${boolean} or ${bigint}, as well as literal text. The result accepts only strings that match the pattern.

TypeScript
type Pixels = `${number}px`;
type HexColour = `#${string}`;
type Pnr = `PNR-${number}`;

const width: Pixels = "320px";
const gap: Pixels = "16";
const brand: HexColour = "#3178c6";
const ticket: Pnr = "PNR-4521890";
const wrong: Pnr = "pnr-4521890";
Output
app.ts(6,7): error TS2322: Type '"16"' is not assignable to type '`${number}px`'.
app.ts(9,7): error TS2322: Type '"pnr-4521890"' is not assignable to type '`PNR-${number}`'.

The checks happen only when TypeScript can see the actual string, such as a literal in your code. A value typed as plain string (for example from user input) cannot be assigned to Pixels without a check.

Combining unions

When you put a union inside a template literal type, TypeScript creates every combination. Two unions of three and two members give six strings:

TypeScriptRun it yourself
type Size = "S" | "M" | "L";
type Colour = "red" | "blue";
type Sku = `${Size}-${Colour}`;
// "S-red" | "S-blue" | "M-red" | "M-blue" | "L-red" | "L-blue"

const stock: Partial<Record<Sku, number>> = {
  "S-red": 12,
  "M-blue": 4,
  "L-red": 0,
};

const sizes: Size[] = ["S", "M", "L"];
const colours: Colour[] = ["red", "blue"];

for (const size of sizes) {
  const row = colours.map((colour) => {
    const sku: Sku = `${size}-${colour}`;
    return `${sku}: ${stock[sku] ?? "-"}`;
  });
  console.log(row.join("   "));
}
Output
S-red: 12   S-blue: -
M-red: -   M-blue: 4
L-red: 0   L-blue: -

A typo such as "XL-red" or "M-green" in the stock object is now a compile error. Notice that `${size}-${colour}` in the loop is checked too: TypeScript knows size is a Size and colour is a Colour, so the result is a valid Sku.

Be careful with large unions: three unions of 10 members each produce 1,000 combinations, which slows the compiler. TypeScript reports an error if a union becomes extremely large.

Changing case

TypeScript has four built-in types that change the case of string types: Uppercase, Lowercase, Capitalize (first letter upper case) and Uncapitalize. They are often combined with template literals and mapped types to generate names:

TypeScriptRun it yourself
type FormEvent = "submit" | "reset" | "change";
type HandlerName = `on${Capitalize<FormEvent>}`; // "onSubmit" | "onReset" | "onChange"

type FormHandlers = { [E in FormEvent as `on${Capitalize<E>}`]?: (fields: number) => void };

type Shout<S extends string> = `${Uppercase<S>}!`;
const banner: Shout<"seat confirmed"> = "SEAT CONFIRMED!";

function fire(handlers: FormHandlers, event: FormEvent, fields: number): void {
  const name = `on${event.charAt(0).toUpperCase()}${event.slice(1)}` as HandlerName;
  const handler = handlers[name];
  if (handler) handler(fields);
  else console.log(`No handler for ${name}`);
}

const admissionForm: FormHandlers = {
  onSubmit: (n) => console.log(`Submitted ${n} fields`),
  onReset: () => console.log("Form cleared"),
};

fire(admissionForm, "submit", 8);
fire(admissionForm, "change", 1);
fire(admissionForm, "reset", 0);
console.log(banner);
Output
Submitted 8 fields
No handler for onChange
Form cleared
SEAT CONFIRMED!

Writing onSumbit in admissionForm would be reported as an unknown property. Frameworks use exactly this technique to type event props.

Parsing strings with infer

Template literal types can also be used inside conditional types with infer, which lets TypeScript pull pieces out of a string type. This is how some web frameworks know which parameters a route such as /trains/:number/coach/:coach has.

TypeScriptRun it yourself
type RouteParams<Path extends string> =
  Path extends `${string}:${infer Param}/${infer Rest}`
    ? { [K in Param | keyof RouteParams<Rest>]: string }
    : Path extends `${string}:${infer Param}`
      ? { [K in Param]: string }
      : {};

type CoachRoute = RouteParams<"/trains/:number/coach/:coach">;
// { number: string; coach: string }

function buildUrl<P extends string>(path: P, params: RouteParams<P>): string {
  let url: string = path;
  for (const [key, value] of Object.entries(params)) {
    url = url.replace(`:${key}`, encodeURIComponent(String(value)));
  }
  return url;
}

console.log(buildUrl("/trains/:number/coach/:coach", { number: "12951", coach: "B4" }));
console.log(buildUrl("/stations/:code", { code: "NDLS" }));
Output
/trains/12951/coach/B4
/stations/NDLS

RouteParams looks for :name/ in the path, keeps the name, and repeats on the rest of the string (a recursive type). If you forget a parameter, or misspell one as { numbr: "12951" }, the call to buildUrl is a compile error. You do not need to write types like this every day, but now you can read them in library code.

Common mistakes

  • Expecting run-time checks. `${number}px` checks literals in your code, not strings typed by users. Validate input with real code.
  • Creating huge unions. Combining several large unions explodes into thousands of members. Keep combined unions small.
  • Forgetting string & with keyof. In `on${Capitalize<keyof T>}`, keyof T may include numbers or symbols; write Capitalize<string & keyof T>.
  • Over-using string tricks. If a value has structure, an object ({ size: "M", colour: "red" }) is often clearer than a combined string.

Key points

  • Template literal types use backticks to describe string patterns such as `${number}px` or `PNR-${number}`.
  • A union inside a template literal type produces every combination.
  • Uppercase, Lowercase, Capitalize and Uncapitalize change the case of string types.
  • With as in mapped types they generate property names like onSubmit.
  • With infer in conditional types they parse strings, for example extracting route parameters.

Exercise

A school timetable uses period codes such as "Mon-1" to "Fri-8". Write a type Day (Mon to Fri), a type Period (1 to 8 as number literal types) and a template literal type Slot for every valid code. Then write parseSlot(slot: Slot): { day: Day; period: Period } and test it with "Wed-3". Finally, write a Slot typo, such as "Sat-2", and see the compiler reject it.

Show solution
TypeScript
type Day = "Mon" | "Tue" | "Wed" | "Thu" | "Fri";
type Period = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8;
type Slot = `${Day}-${Period}`; // 40 valid codes

function parseSlot(slot: Slot): { day: Day; period: Period } {
  const [day, period] = slot.split("-");
  return { day: day as Day, period: Number(period) as Period };
}

console.log(parseSlot("Wed-3")); // { day: 'Wed', period: 3 }
// parseSlot("Sat-2"); // error: '"Sat-2"' is not assignable to parameter of type ...

The 5 days times 8 periods give exactly 40 allowed strings. split returns plain strings, so two small assertions convert them back; that is safe here because the input was already checked as a Slot.

Quick Quiz

1. Which value fits the type `${number}px`?

2. How many members does `${"A" | "B"}-${1 | 2 | 3}` have?

3. What is `on${Capitalize<"click">}`?

4. What does T extends ${string}:${infer P} ? P : never do for "/users/:id"?

Learn TypeScript with a mentor

Free lessons get you started. Our Full Stack Web Development training adds real projects and code reviews from working developers.

  • Project-based learning
  • Mentor code reviews
  • Free counselling call