Template Literal Types
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.
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 combinationsBe careful with large unions — the cartesian product can explode. A template literal with two 100-member unions produces 10,000 types.
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 }
| What developers do | What 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. |
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.
- 1Template literal types build string types from other types using template syntax:
prefix${Type}suffix - 2Unions in template literals create cartesian products — watch for combinatorial explosion with large unions
- 3Four intrinsic types: Uppercase, Lowercase, Capitalize, Uncapitalize — for compile-time string transformation
- 4Template literals + infer enable string parsing at the type level — extract route params, parse paths, convert cases
- 5Use literal unions for autocomplete, string/number for pattern validation — infinite types can't be enumerated