Template literal types in TypeScript
Template literal types let TypeScript generate new string literal types using template syntax at compile time, producing every combination of the provided unions automatically.
Theory
TL;DR
- Like a Mad Libs: you define the blank options (unions), TypeScript generates every possible filled sentence as a distinct type.
- The core behavior is distributive:
`${A | B}-${C}`expands to"A-C" | "B-C", a cartesian product. - Zero runtime cost. Types erase to
string; the expansion happens only in the type checker. - Use when you need exhaustive, type-safe string keys or paths. Skip for simple strings or runtime generation.
- Added in TypeScript 4.1, March 2021.
Quick example
type Size = "small" | "large";
type Color = "red" | "blue";
type TShirt = `${Size}-${Color}`;
// "small-red" | "small-blue" | "large-red" | "large-blue"
const shirt: TShirt = "small-red"; // ✅
const invalid: TShirt = "small"; // ❌ Type '"small"' is not assignable to type 'TShirt'TypeScript takes the two unions, applies the template to each member combination, and produces four exact string literals. You never list them manually.
Key difference from plain unions
Plain string unions are static: you write every member by hand. Template literal types generate the full set from parts. Three sizes times three colors gives nine strings, but you only define six values. That cartesian expansion is the whole point.
Also, template literal types check format, not just value. `user/${number}/profile` accepts "user/123/profile" but rejects "user/abc/profile". Plain unions cannot express that constraint.
When to use
- Generated key sets (event names, CSS class names, getter/setter pairs): use template literals to avoid manual listing.
- API route patterns with dynamic segments: template literals enforce the format at compile time.
- Type-safe event emitters where method names follow a convention like
onEventName. - Simple static strings with fewer than 5 options and no pattern: plain string literals are fine.
- Runtime string construction: use regular template strings. Template literal types are compile-time only.
How the compiler handles this
TypeScript's type checker (4.1+) parses the template syntax during type checking, recursively substitutes each union member into the placeholder, and normalizes the result to a union of string literals. The compiler processes each branch independently. No code runs at runtime. The type erases to string in the emitted JavaScript.
When a placeholder receives string instead of a finite union, the result collapses to a pattern type like `${string}-suffix` rather than a concrete union. That is by design, not a bug.
Built-in string manipulation utilities
TypeScript ships four intrinsic types that work directly on string literals:
type Upper = Uppercase<"hello">; // "HELLO"
type Lower = Lowercase<"HELLO">; // "hello"
type Cap = Capitalize<"hello">; // "Hello"
type Uncap = Uncapitalize<"Hello">; // "hello"
type Event = "click" | "focus" | "blur";
type Handler = `on${Capitalize<Event>}`;
// "onClick" | "onFocus" | "onBlur"These chain left to right and compose with template literal types cleanly.
Common mistakes
1. Assuming it works at runtime
// Runtime function - return type is `string`, not a template literal type
const makeKey = (prefix: string) => `key-${prefix}`;
type Key = ReturnType<typeof makeKey>; // string
// To get the type, define it separately:
type Key = `key-${"a" | "b"}`;Template literal types live in the type system. Functions that build strings at runtime return string.
2. Mixing string into a union
type Prefix = "a" | "b";
type T = `${Prefix | string}-suffix`; // `${string}-suffix`, not "a-suffix" | "b-suffix"string absorbs the finite union and the concrete literals disappear. Keep placeholders as finite unions.
3. Using number as an infinite placeholder
type Id = `user-${number}`; // Valid for assignment checks, but produces no concrete unionnumber expands to all numeric strings, which TypeScript cannot enumerate. The type accepts "user-123" but gives no autocomplete or exhaustiveness checking. Use a finite union of numbers or a branded type if you need those.
4. Deep recursion hitting the limit
Recursive template literal types hit "Type instantiation is excessively deep and possibly infinite" at around 10-20 levels. Flatten the recursion or use mapped types with a known depth if you run into this.
Real-world usage
- React Router v6 style:
`user/${number}/${"view" | "edit" | "delete"}`for type-safe navigation. - TanStack Query: key types like
`user-${number}-profile`to avoid key collisions. - tRPC: procedure path types built from the router shape.
- GraphQL Codegen: resolver types as
`Query.${Keys}`. - Tailwind-like utilities:
`p${Direction}-${Spacing}`for padding class names. - Mapped types with key remapping:
`get${Capitalize<string & K>}`to generate getter names from an interface.
Follow-up questions
Q: What does "distributive" mean here? Give an example.
A: TypeScript applies the template independently to each union member. `${"a" | "b"}-${"x" | "y"}` becomes "a-x" | "a-y" | "b-x" | "b-y". It is a cartesian product, not a zip.
Q: How do Uppercase, Capitalize, and similar utilities interact with template literal types?
A: They chain cleanly. `on${Capitalize<"click" | "focus">}` gives "onClick" | "onFocus". They resolve left to right inside the template.
Q: What is the difference between template literal types and as const?
A: as const narrows an existing value to its literal type. Template literal types generate new string literal types from parts. They solve different problems and can be combined.
Q: How deep can recursion go?
A: Roughly 10-20 levels before TypeScript throws "Type instantiation is excessively deep and possibly infinite". Use mapped types or limit recursion depth explicitly for complex path types.
Q: (Senior) Write a type that extracts the prefix from a `${Prefix}-${Suffix}` pattern.
A:
type ExtractPrefix<T extends `${string}-${string}`> =
T extends `${infer P}-${string}` ? P : never;
type Result = ExtractPrefix<"small-red">; // "small"infer inside a template literal type captures the matched segment as a new type variable.
Examples
Basic: CSS utility class names
type Spacing = 0 | 1 | 2 | 4 | 8;
type Direction = "t" | "b" | "l" | "r";
type PaddingClass = `p${Direction}-${Spacing}`;
// "pt-0" | "pt-1" | "pt-2" | "pt-4" | "pt-8" | "pb-0" | ... 20 combinations
const cls: PaddingClass = "pt-4"; // ✅
const bad: PaddingClass = "pt-3"; // ❌ 3 is not in SpacingTypeScript generates all 20 class names from 4 directions and 5 spacing values. Add a new spacing value to the union and every class type updates automatically.
Intermediate: Type-safe event emitter with mapped types
type Events = {
userCreated: { id: string; name: string };
userDeleted: { id: string };
orderPlaced: { orderId: string; total: number };
};
type EventEmitter = {
[K in keyof Events as `on${Capitalize<string & K>}`]: (data: Events[K]) => void;
};
// Result:
// {
// onUserCreated: (data: { id: string; name: string }) => void;
// onUserDeleted: (data: { id: string }) => void;
// onOrderPlaced: (data: { orderId: string; total: number }) => void;
// }Key remapping with as inside a mapped type, combined with a template literal and Capitalize, is one of the most common patterns in production TypeScript codebases. I use this exact shape in event-driven services to keep the emitter contract in sync with the event map.
Senior: Extracting route params with recursive infer
type ExtractRouteParams<T extends string> =
T extends `${string}:${infer Param}/${infer Rest}`
? Param | ExtractRouteParams<Rest>
: T extends `${string}:${infer Param}`
? Param
: never;
type Params = ExtractRouteParams<"/users/:userId/posts/:postId">;
// "userId" | "postId"
function navigate<T extends string>(
path: T,
params: Record<ExtractRouteParams<T>, string>
) {
// params is { userId: string; postId: string } for "/users/:userId/posts/:postId"
}
navigate("/users/:userId/posts/:postId", { userId: "1", postId: "42" }); // ✅
navigate("/users/:userId/posts/:postId", { userId: "1" }); // ❌ Missing postIdThis is the pattern behind type-safe routers like React Router v6 typed paths. The recursion peels one :param at a time and unions the results. It works fine for typical route shapes and only hits the recursion limit at extreme depths.
Short Answer
Interview readyA concise answer to help you respond confidently on this topic during an interview.