Skip to content

Template Literal Types

advanced13 min read

Strings as Types

Imagine this: you have a route pattern like /users/:id. Wouldn't it be wild if TypeScript could look at that string, extract id, and force you to provide { id: string } when building the URL? Template literal types do exactly that — string parsing at compile time. CSS units, API routes, event names, environment variable keys — all type-checked from the string pattern itself. This is where TypeScript starts feeling like a superpower.

Mental Model

Think of template literal types as string formatting at compile time. Just like JavaScript's template literals build strings at runtime (`Hello, ${name}`), template literal types build string types at compile time (`on${Capitalize<Event>}`). The result isn't a value — it's a type that constrains which strings are allowed.

Basic Template Literal Types

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

const a: Greeting = "Hello, Alice";    // OK
const b: Greeting = "Hello, Bob";      // OK
const c: Greeting = "Goodbye, Alice";  // ERROR: doesn't start with "Hello, "

// Combining literal unions — creates the cartesian product
type Color = "red" | "blue";
type Size = "sm" | "lg";
type ClassName = `${Color}-${Size}`;
// "red-sm" | "red-lg" | "blue-sm" | "blue-lg"

// With numbers
type Port = `${number}`;
const port: Port = "3000"; // OK — any number as string

Intrinsic String Manipulation Types

TypeScript provides four built-in string manipulation types:

type Upper = Uppercase<"hello">;       // "HELLO"
type Lower = Lowercase<"HELLO">;       // "hello"
type Cap = Capitalize<"hello">;        // "Hello"
type Uncap = Uncapitalize<"Hello">;    // "hello"

// Combining with template literals
type EventName = "click" | "hover" | "focus";
type EventHandler = `on${Capitalize<EventName>}`;
// "onClick" | "onHover" | "onFocus"

// Generate getter names
type Getters<T> = {
  [K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};

interface User {
  name: string;
  age: number;
}

type UserGetters = Getters<User>;
// { getName: () => string; getAge: () => number }

Pattern Matching with Template Literals

Now this is where it gets seriously cool. Template literal types work with infer for powerful string parsing:

// Extract parts of a string type
type ExtractId<T extends string> = T extends `user_${infer Id}` ? Id : never;
type A = ExtractId<"user_123">;  // "123"
type B = ExtractId<"post_456">;  // never

// Parse dot-separated paths
type FirstSegment<T extends string> =
  T extends `${infer First}.${string}` ? First : T;

type C = FirstSegment<"user.profile.name">;  // "user"
type D = FirstSegment<"active">;              // "active"

// Convert kebab-case to camelCase
type KebabToCamel<S extends string> =
  S extends `${infer Head}-${infer Tail}`
    ? `${Head}${KebabToCamel<Capitalize<Tail>>}`
    : S;

type E = KebabToCamel<"background-color">;       // "backgroundColor"
type F = KebabToCamel<"border-top-left-radius">;  // "borderTopLeftRadius"

// Convert camelCase to kebab-case
type CamelToKebab<S extends string> =
  S extends `${infer Head}${infer Tail}`
    ? Head extends Uppercase<Head>
      ? `-${Lowercase<Head>}${CamelToKebab<Tail>}`
      : `${Head}${CamelToKebab<Tail>}`
    : S;

type G = CamelToKebab<"backgroundColor">;  // "background-color"
How template literal distribution works

When a template literal type contains a union, TypeScript generates the cartesian product of all combinations:

type Method = "GET" | "POST";
type Path = "/users" | "/posts";
type Route = `${Method} ${Path}`;
// "GET /users" | "GET /posts" | "POST /users" | "POST /posts"

With three unions, the combinations multiply:

type A = "a" | "b";    // 2
type B = "1" | "2";    // 2
type C = "x" | "y";    // 2
type All = `${A}${B}${C}`; // 2 × 2 × 2 = 8 combinations

Be careful with large unions — the cartesian product can explode. A template literal with two 100-member unions produces 10,000 types.

Common Trap

Template literal types with string or number create infinite types. TypeScript handles these specially — they match any string following the pattern but can't be enumerated:

type ApiRoute = `/api/${string}`;
// Matches "/api/users", "/api/posts/123", "/api/anything"
// But can't autocomplete — string is infinite

type Port = `${number}`;
// Matches "3000", "8080", "0" — any numeric string
// But not "abc" or "3.14.1"

If you need autocomplete, use explicit literal unions instead of string/number.

Production Scenario: Type-Safe CSS Units

// CSS length units
type CSSUnit = "px" | "rem" | "em" | "vh" | "vw" | "%";
type CSSLength = `${number}${CSSUnit}`;

function setWidth(element: HTMLElement, width: CSSLength) {
  element.style.width = width;
}

setWidth(document.body, "100px");   // OK
setWidth(document.body, "2.5rem");  // OK
setWidth(document.body, "100");     // ERROR: not a CSSLength
setWidth(document.body, "big");     // ERROR: not a CSSLength

// Type-safe CSS custom properties
type CSSVar = `--${string}`;
type CSSVarRef = `var(${CSSVar})` | `var(${CSSVar}, ${string})`;

function setCSSVar(name: CSSVar, value: string) {
  document.documentElement.style.setProperty(name, value);
}

setCSSVar("--primary-color", "#3b82f6"); // OK
setCSSVar("color", "#3b82f6");           // ERROR: doesn't start with --

// Type-safe event system
type DomEvent = "click" | "mouseenter" | "mouseleave" | "keydown" | "keyup" | "scroll";
type CustomEvent = "themeChange" | "dataLoad" | "routeChange";
type AllEvents = DomEvent | CustomEvent;

type EventHandlerMap = {
  [E in AllEvents as `on${Capitalize<E>}`]: (event: Event) => void;
};

// {
//   onClick: (event: Event) => void;
//   onMouseenter: (event: Event) => void;
//   onThemeChange: (event: Event) => void;
//   ...
// }

Route Typing with Template Literals

Let's build something real. This pattern is used in production by frameworks like tRPC and Hono.

// Type-safe API routes
type ApiRoutes = {
  "GET /users": User[];
  "GET /users/:id": User;
  "POST /users": User;
  "PUT /users/:id": User;
  "DELETE /users/:id": void;
  "GET /posts": Post[];
  "GET /posts/:id/comments": Comment[];
};

// Extract params from route pattern
type ExtractParams<T extends string> =
  T extends `${string}:${infer Param}/${infer Rest}`
    ? Param | ExtractParams<Rest>
    : T extends `${string}:${infer Param}`
      ? Param
      : never;

type UserIdParams = ExtractParams<"GET /users/:id">;  // "id"
type CommentParams = ExtractParams<"GET /posts/:id/comments">; // "id"

// Build params object from route
type RouteParams<T extends string> =
  ExtractParams<T> extends never
    ? {}
    : Record<ExtractParams<T>, string>;

type Params = RouteParams<"GET /users/:id">; // { id: string }
Execution Trace
Input
'GET /users/:id/posts/:postId'
Route with two dynamic segments
Match 1
':id/posts/:postId' matches ':$`{Param}`/$`{Rest}`'
Param = 'id', Rest = 'posts/:postId'
Recurse
ExtractParams<'posts/:postId'>
Continue with remaining path
Match 2
':postId' matches ':$`{Param}`' (no more /)
Param = 'postId', base case reached
Result
'id' | 'postId'
Union of all extracted param names
What developers doWhat they should do
Using large unions in template literal types causing combinatorial explosion
Template literals create cartesian products. Two 50-member unions produce 2,500 types, slowing compilation.
Keep unions small or use string as a catch-all with runtime validation
Expecting autocomplete with template literal types containing string or number
string and number are infinite — the compiler can validate patterns but can't enumerate completions
Use explicit literal unions for autocomplete. Use string for pattern validation only.
Not handling the empty string case in recursive string parsing
Without a base case, recursive string parsing may not terminate correctly for edge cases
Always add a base case for empty strings in recursive template literal patterns
Forgetting that Capitalize only affects the first character
Capitalize transforms only the first character to uppercase. The rest stays unchanged.
Capitalize<'onClick'> is 'OnClick', not 'ONCLICK'. Use Uppercase for full uppercase.
Quiz
What is type R = ${'a' | 'b'}-${'1' | '2'}?
Quiz
What does KebabToCamel<'font-size'> evaluate to?
Quiz
Why can't TypeScript autocomplete values for type CSSLength = ${number}px?

Challenge: Build a Type-Safe Path Builder

// Build a type-safe URL path builder that:
// 1. Takes a route pattern like "/users/:userId/posts/:postId"
// 2. Returns a function that requires the correct params
// 3. Returns the built URL string
//
// const buildPath = createPathBuilder("/users/:userId/posts/:postId");
// buildPath({ userId: "123", postId: "456" })
// // Returns: "/users/123/posts/456"
//
// buildPath({ userId: "123" })
// // ERROR: missing postId
//
// buildPath({ userId: "123", postId: "456", extra: "x" })
// // ERROR: extra property 'extra'

// Your implementation here:
type ExtractParams<T extends string> =
  T extends `${string}:${infer Param}/${infer Rest}`
    ? Param | ExtractParams<`/${Rest}`>
    : T extends `${string}:${infer Param}`
      ? Param
      : never;

type ParamsObject<T extends string> =
  [ExtractParams<T>] extends [never]
    ? {}
    : { [K in ExtractParams<T>]: string };

function createPathBuilder<T extends string>(pattern: T) {
  return (params: ParamsObject<T>): string => {
    let path: string = pattern;
    for (const [key, value] of Object.entries(params)) {
      path = path.replace(`:${key}`, value as string);
    }
    return path;
  };
}

const buildUserPostPath = createPathBuilder("/users/:userId/posts/:postId");
buildUserPostPath({ userId: "123", postId: "456" }); // "/users/123/posts/456"
// buildUserPostPath({ userId: "123" }); // ERROR: missing postId
// buildUserPostPath({ userId: "123", postId: "456", extra: "x" }); // ERROR

const buildHomePath = createPathBuilder("/home");
buildHomePath({}); // "/home" — no params needed

The type system extracts parameter names from the route pattern using template literal inference, then constructs a params object type requiring exactly those keys. The runtime implementation does simple string replacement.

Key Rules
  1. 1Template literal types build string types from other types using template syntax: prefix${Type}suffix
  2. 2Unions in template literals create cartesian products — watch for combinatorial explosion with large unions
  3. 3Four intrinsic types: Uppercase, Lowercase, Capitalize, Uncapitalize — for compile-time string transformation
  4. 4Template literals + infer enable string parsing at the type level — extract route params, parse paths, convert cases
  5. 5Use literal unions for autocomplete, string/number for pattern validation — infinite types can't be enumerated