Chapter 10

Template Literal Types: String Operations at the Type Level

Basic Syntax

Template literal types use exactly the same syntax as JavaScript template strings — the only difference is that they operate on types instead of values.

type Greeting = `Hello, ${string}`;

const a: Greeting = "Hello, World";  // ✓
const b: Greeting = "Hello, Alice";  // ✓
const c: Greeting = "Hi, Alice";     // ✗ doesn't match the pattern

Concatenating concrete string literal types:

type Domain = "com" | "net" | "org";
type Protocol = "http" | "https";

type URL = `${Protocol}://${string}.${Domain}`;

const valid: URL = "https://example.com";   // ✓
const invalid: URL = "ftp://example.com";   // ✗ ftp is not in Protocol

Combining with Unions: Cartesian Products

When a template slot contains a union type, TypeScript automatically computes all combinations.

type Direction = "top" | "right" | "bottom" | "left";
type Axis = "X" | "Y";

type ScrollProperty = `scroll${Axis}`;
// "scrollX" | "scrollY"

type MarginProperty = `margin${Capitalize<Direction>}`;
// "marginTop" | "marginRight" | "marginBottom" | "marginLeft"

// Multiple union types — every combination
type Size = "sm" | "md" | "lg";
type Color = "primary" | "secondary";
type ButtonVariant = `${Color}-${Size}`;
// "primary-sm" | "primary-md" | "primary-lg"
// | "secondary-sm" | "secondary-md" | "secondary-lg"

Built-in String Manipulation Types

TypeScript ships four string manipulation utility types designed for template literals:

type S = "helloWorld";

type U = Uppercase<S>;    // "HELLOWORLD"
type L = Lowercase<S>;    // "helloworld"
type C = Capitalize<S>;   // "HelloWorld"
type N = Uncapitalize<S>; // "helloWorld"

// Typical use: generate camelCase handler names
type EventName = "click" | "focus" | "blur";
type EventHandler = `on${Capitalize<EventName>}`;
// "onClick" | "onFocus" | "onBlur"

Parsing Strings with infer Inside Template Literals

Template literals combined with infer let you destructure string patterns.

// Extract everything after "on"
type ExtractAfterOn<T extends string> = T extends `on${infer Event}`
  ? Event
  : never;

type A = ExtractAfterOn<"onClick">;   // "Click"
type B = ExtractAfterOn<"onFocus">;   // "Focus"
type C = ExtractAfterOn<"resize">;    // never

// Extract head and tail of a dot-separated string
type Head<T extends string> = T extends `${infer H}.${string}` ? H : T;
type Tail<T extends string> = T extends `${string}.${infer R}` ? R : never;

type H = Head<"user.name">;   // "user"
type R = Tail<"user.name">;   // "name"

Real Use Case 1: Type-Safe CSS Property Names

type CSSProperty =
  | "background-color"
  | "border-radius"
  | "font-size"
  | "margin-top"
  | "padding-left";

// Convert kebab-case to camelCase (recursive to handle multiple hyphens)
type KebabToCamel<T extends string> =
  T extends `${infer Head}-${infer Tail}`
    ? `${Head}${Capitalize<KebabToCamel<Tail>>}`
    : T;

type CamelCSSProperty = KebabToCamel<CSSProperty>;
// "backgroundColor" | "borderRadius" | "fontSize" | "marginTop" | "paddingLeft"

// Build a type-safe style object
type StyleObject = Partial<Record<CamelCSSProperty, string>>;

function applyStyle(element: HTMLElement, styles: StyleObject) {
  for (const [key, value] of Object.entries(styles)) {
    (element.style as Record<string, string>)[key] = value ?? "";
  }
}

applyStyle(document.body, {
  backgroundColor: "#fff",  // ✓
  borderRadius: "8px",       // ✓
  // backgroundColour: "#fff"  // ✗ typo caught at compile time
});

Real Use Case 2: Type-Safe Event System Handler Names

interface DOMEvents {
  click: MouseEvent;
  focus: FocusEvent;
  blur: FocusEvent;
  keydown: KeyboardEvent;
  resize: UIEvent;
}

// Generate on + capitalized handler names
type EventListeners = {
  [K in keyof DOMEvents as `on${Capitalize<K>}`]: (event: DOMEvents[K]) => void;
};
// { onClick: (event: MouseEvent) => void; onFocus: (event: FocusEvent) => void; ... }

// Reverse: infer the event name from a handler name
type ExtractEvent<T extends string> = T extends `on${infer E}`
  ? Uncapitalize<E>
  : never;

type E = ExtractEvent<"onClick">;  // "click"

// Type-safe event registration
function addEventListener<K extends keyof DOMEvents>(
  element: HTMLElement,
  event: K,
  handler: (e: DOMEvents[K]) => void
): void {
  element.addEventListener(event, handler as EventListener);
}

addEventListener(document.body, "click", (e) => {
  // e is inferred as MouseEvent — you get clientX, clientY, etc.
  console.log(e.clientX, e.clientY);
});

Real Use Case 3: i18n Key Type Safety

// Define the shape of the translation file
interface Translations {
  home: {
    title: string;
    subtitle: string;
    cta: string;
  };
  about: {
    title: string;
    team: string;
  };
  errors: {
    notFound: string;
    serverError: string;
  };
}

// Recursively generate all dot-notation paths
type DotPaths<T, Prefix extends string = ""> = {
  [K in keyof T & string]: T[K] extends object
    ? DotPaths<T[K], `${Prefix}${K}.`>
    : `${Prefix}${K}`;
}[keyof T & string];

type TranslationKey = DotPaths<Translations>;
// "home.title" | "home.subtitle" | "home.cta"
// | "about.title" | "about.team"
// | "errors.notFound" | "errors.serverError"

function t(key: TranslationKey): string {
  return key; // implementation omitted — the key is guaranteed valid by type
}

t("home.title");        // ✓
t("about.team");        // ✓
// t("home.missing");   // ✗ key doesn't exist
// t("HOME.TITLE");     // ✗ case must match exactly

Real Use Case 4: Typed Express-Style Route Params

// Extract all :param names from a route string
type ExtractRouteParams<T extends string> =
  T extends `${string}:${infer Param}/${infer Rest}`
    ? Param | ExtractRouteParams<`/${Rest}`>
    : T extends `${string}:${infer Param}`
    ? Param
    : never;

type Params1 = ExtractRouteParams<"/users/:id">;
// "id"

type Params2 = ExtractRouteParams<"/users/:userId/posts/:postId">;
// "userId" | "postId"

type Params3 = ExtractRouteParams<"/users/:userId/posts/:postId/comments/:commentId">;
// "userId" | "postId" | "commentId"

// Type-safe route handler
type RouteHandler<Path extends string> = (
  params: Record<ExtractRouteParams<Path>, string>
) => void;

function defineRoute<Path extends string>(
  path: Path,
  handler: RouteHandler<Path>
) {
  return { path, handler };
}

defineRoute("/users/:userId/posts/:postId", (params) => {
  // params.userId   ✓ — correctly inferred
  // params.postId   ✓
  // params.postIdx  ✗ — doesn't exist, compile error
  console.log(params.userId, params.postId);
});

The Combinatorial Explosion Problem

Template literal types compute Cartesian products. Large unions can cause serious compiler slowdowns.

// Manageable: 6 × 10 = 60 combinations
type Weight = "100" | "200" | "300" | "400" | "500" | "600" | "700" | "800" | "900" | "950";
type Color = "red" | "green" | "blue" | "yellow" | "purple" | "gray";
type TailwindColor = `${Color}-${Weight}`;  // 60 — acceptable

// Dangerous: 26 × 26 × 26 = 17,576 combinations
type Letter = "a" | "b" | "c" /* ... */ | "z";
type ThreeLetterCode = `${Letter}${Letter}${Letter}`;  // do NOT do this

Rule of thumb: when the union exceeds ~1,000 members, switch to string with runtime validation.

// ✓ Use string + runtime validation instead
function setTailwindClass(className: string): void {
  if (!isValidTailwindClass(className)) {
    throw new Error(`Invalid class: ${className}`);
  }
}

Anti-Patterns

// ❌ Anti-pattern: using template literals where a union would be clearer
type Endpoint = `${"GET" | "POST" | "PUT" | "DELETE"} ${string}`;
// string is too broad — you lose type safety on the path part

// ✓ Better: separate method and path types
type Method = "GET" | "POST" | "PUT" | "DELETE";
type KnownPath = "/users" | "/posts" | "/comments";
interface Request {
  method: Method;
  path: KnownPath;
}

// ❌ Anti-pattern: infer nested more than two levels — unreadable
type DeepExtract<T extends string> =
  T extends `${infer A}_${infer B}_${infer C}_${infer D}`
    ? [A, B, C, D]
    : T extends `${infer A}_${infer B}_${infer C}`
    ? [A, B, C]
    : T extends `${infer A}_${infer B}`
    ? [A, B]
    : [T];
// Works, but a runtime string.split("_") with typed output is far clearer

Comparison Table

Use Case Tool Example
Concatenate string types `${A}${B}` "get" + "User""getUser"
Capitalize first letter Capitalize<S> "click""Click"
All uppercase Uppercase<S> "get""GET"
Extract substring infer in template "onClick""Click"
Expand unions Insert union into slot "a"|"b""xa"|"xb"
Route params Recursive infer ":id""id"
i18n paths Recursive DotPaths Interface → all dot paths

Chapter Summary

Concept Key Point
Basic syntax Same as JS template strings; operates on types
Union expansion Automatically produces Cartesian products
String utility types Uppercase / Lowercase / Capitalize / Uncapitalize
infer extraction Reverse-extract substrings from string patterns
Route parameters Recursive infer extracts all :param tokens
i18n keys Recursive DotPaths generates dot-path union
Combinatorial explosion Over ~1,000 members: use string + runtime validation

What's Next

Chapter 11 covers variance — covariance, contravariance, and invariance. You'll fully understand why (a: Animal) => void is assignable to (a: Dog) => void, and why Array<Dog> is not assignable to Array<Animal>. This is the foundation for understanding generic type safety.

Rate this chapter
4.5  / 5  (34 ratings)

💬 Comments