Template Literal Types in TypeScript: Building Strings at the Type Level
We cover union products with template literal types, patterns like ${number}, the Uppercase and Capitalize intrinsics, deriving event names, generating keys with mapped types and parsing routes with infer.
Version note: The code in this post targets TypeScript 7.0 and Node 26.
Predict First
We want to define the CSS class names for a button component as a type:
type Size = "sm" | "lg";
type Color = "red" | "blue";
type ClassName = `btn-${Size}-${Color}`;
Two questions: how many members does the ClassName union have? And if we add the line const className: ClassName = "btn-md-red";, does it compile? If it errors, what does the compiler tell us? The answers come in the next two sections.
Joining Strings at the Type Level
Template literal types arrived in TypeScript 4.1. The syntax is exactly the template literal we used in the JavaScript series; the only difference is that it works with types instead of values. Inside ${…} you write a type, not a variable, and the result is a new string literal type:
type Name = "Ada";
type Greeting = `Hello, ${Name}!`; // "Hello, Ada!"
type Flag = `${boolean}`; // "false" | "true"
type Mixed = `${null}-${undefined}-${1n}`; // "null-undefined-1"
Inside ${…} you can put string, number, bigint, boolean, null and undefined types; the compiler writes each one the way JavaScript would turn it into a string. Look at the result for boolean: again a two-member union. The reason is the subject of the next section.
Multiplying Unions
When you put a union inside a template literal, the compiler places each member separately. If there are several unions, it produces every combination; in other words, it takes the Cartesian product:
type Size = "sm" | "lg";
type Color = "red" | "blue";
type ClassName = `btn-${Size}-${Color}`;
// "btn-lg-blue" | "btn-lg-red" | "btn-sm-blue" | "btn-sm-red"
// Error: Type '"btn-md-red"' is not assignable to type '"btn-lg-blue" | "btn-lg-red" | "btn-sm-blue" | "btn-sm-red"'. Did you mean '"btn-sm-red"'?
const className: ClassName = "btn-md-red";
The answer to the prediction: 2 × 2 = 4 members. The assignment is rejected with TS2820, and the compiler even suggests the closest candidate. Since we never defined an "md" size, this class name can't exist; a typo is caught before the code ever runs.
The product comes at a price: the number of members grows very quickly. Let's try it with digits:
type Digit = "0" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9";
type Pin = `${Digit}${Digit}${Digit}${Digit}`; // 10,000 members, no problem
// Error: Expression produces a union type that is too complex to represent.
type LongPin = `${Digit}${Digit}${Digit}${Digit}${Digit}`;
Four digits make 10,000 members, and the compiler handles that. With the fifth digit it reaches 100,000 and stops with TS2590. While unions are small, the product is very handy; but the "list every possible value" approach doesn't work for large sets. For those, there are the patterns in the next section.
Template Literals as Patterns
If you put a wide type like string or number inside ${…}, the result is not a list but a pattern. Infinitely many strings match that pattern, and the compiler checks assignments against it:
type Px = `${number}px`;
const width: Px = "12px";
const offset: Px = "-1.5px";
// Error: Type '"px"' is not assignable to type '`${number}px`'.
const empty: Px = "px";
// Error: Type '"abcpx"' is not assignable to type '`${number}px`'.
const wrong: Px = "abcpx";
Notice that the type in the error message isn't expanded; it stays as `${number}px`, since there are no members to list. These patterns are very useful for CSS values, prefixed IDs such as "user_42", or paths starting with `/${string}`.
Intrinsic String Types
Along with template literal types, TypeScript 4.1 brought four more helper types: Uppercase, Lowercase, Capitalize and Uncapitalize. They differ from other utility types; their definitions in lib contain no mapped or conditional type:
// From lib.es5.d.ts; they are already global, so don't copy them into your own file
type Uppercase<S extends string> = intrinsic;
type Capitalize<S extends string> = intrinsic;
intrinsic means "the compiler computes this itself". Using them is what you'd expect:
type A = Uppercase<"click">; // "CLICK"
type B = Capitalize<"click">; // "Click"
type C = Uncapitalize<"UserId">; // "userId"
type D = Uppercase<"istanbul">; // "ISTANBUL"
type E = Lowercase<"IRMAK">; // "irmak"
The last two lines matter to anyone who writes Turkish. These conversions don't depend on locale; they work like JavaScript's toUpperCase(), not like toLocaleUpperCase("tr-TR"). That's why "istanbul" becomes "ISTANBUL", not "İSTANBUL". For technical strings like keys and event names, that's the right behavior; you don't transform text meant for users at the type level anyway.
Deriving Event Names
The most common use of template literal types is deriving new names from existing ones. Let's produce handler names from a list of events:
type DomEvent = "click" | "keydown" | "scroll";
type HandlerName = `on${Capitalize<DomEvent>}`; // "onClick" | "onKeydown" | "onScroll"
A more realistic example: a small store whose changes you can listen to. Event names like nameChanged and ageChanged should be created automatically for each property, and the callback should get the right type:
interface Profile {
name: string;
age: number;
}
function createStore<T extends object>(state: T) {
const listeners: Partial<Record<string, (value: unknown) => void>> = {};
return {
on<K extends keyof T & string>(event: `${K}Changed`, callback: (value: T[K]) => void) {
listeners[event] = callback as (value: unknown) => void;
},
set<K extends keyof T & string>(key: K, value: T[K]) {
state[key] = value;
listeners[`${key}Changed`]?.(value);
},
};
}
const store = createStore<Profile>({ name: "Ada", age: 36 });
store.on("ageChanged", (age) => console.log(`age is now ${age.toFixed(0)}`));
store.set("age", 37); // age is now 37
// Error: Argument of type '"ageChange"' is not assignable to parameter of type '"ageChanged" | "nameChanged"'.
store.on("ageChange", () => {});
Three things work together here. The `${K}Changed` parameter lets the compiler work backwards from the string "ageChanged" and infer K = "age". As a result, the age parameter in the callback is a number, and toFixed can be used freely. A misspelled event name is rejected with TS2345, listing every valid name. The as inside the store is there so the listeners can live in a single map; the API exposed to the outside is fully type safe.
Together with Mapped Types
In the previous post we saw that we can rename keys with as. Template literal types are that as's most natural partner:
type Expand<T> = { [K in keyof T]: T[K] } & {};
interface Profile {
name: string;
age: number;
}
type Getters<T> = { [K in keyof T as `get${Capitalize<K & string>}`]: () => T[K] };
type ProfileGetters = Expand<Getters<Profile>>;
// { getAge: () => number; getName: () => string; }
interface AppEvents {
login: { userId: number };
logout: undefined;
}
type Handlers<E> = { [K in keyof E as `on${Capitalize<K & string>}`]: (payload: E[K]) => void };
type AppHandlers = Expand<Handlers<AppEvents>>;
// { onLogin: (payload: { userId: number; }) => void; onLogout: (payload: undefined) => void; }
From a single event map we derived both the handler names and each handler's payload type. When you add a new event to AppEvents, AppHandlers updates on its own. We'll see why K & string is there in the pitfalls section.
Parsing Strings with infer
Combine template literals with infer and you can take strings apart at the type level. Let's extract the parameter names from a route path:
type RouteParams<Path> =
Path extends `${string}:${infer Param}/${infer Rest}`
? Param | RouteParams<`/${Rest}`>
: Path extends `${string}:${infer Param}`
? Param
: never;
type P1 = RouteParams<"/users/:userId/posts/:postId">; // "postId" | "userId"
type P2 = RouteParams<"/about">; // never
function buildPath<P extends string>(path: P, params: Record<RouteParams<P>, string>): string {
return path.replace(/:(\w+)/g, (_, name: string) => params[name as RouteParams<P>]);
}
console.log(buildPath("/users/:userId/posts/:postId", { userId: "42", postId: "7" })); // /users/42/posts/7
// Error: Property 'userId' is missing in type '{}' but required in type 'Record<"userId", string>'.
buildPath("/users/:userId", {});
The first branch captures the name after the colon up to the next / and calls RouteParams again for the rest of the path. The second branch captures the last parameter at the end of the path. If there are no parameters, it stays never. buildPath now requires every parameter in the path to be supplied; a missing one is caught with TS2741.
Common Pitfalls
The first is that keyof doesn't only return strings. An object type's keys can be string | number | symbol, and a symbol can't go inside a template literal. Try `get${K}` without K & string and the compiler objects like this: "Type 'K' is not assignable to type 'string | number | bigint | boolean | null | undefined'." (TS2322). K & string or keyof T & string keeps only the string keys.
The second is mistaking `${number}` for a validation tool. The compiler accepts strings that JavaScript can convert to a number; "1e3px", "0x10px", and even values with leading whitespace like " 12px" match this pattern too. The pattern only checks the literals you write at compile time; you still have to validate data coming from users at runtime.
The third is the product blowing up. Combining a few unions in one template is easy, but every new ${…} multiplies the member count. If the editor slows down or you see TS2590, consider turning the list into a pattern (`${string}`, `${number}`).
Finally, with TypeScript 7, template literal inference keeps Unicode characters whole. On "😀abc", the pattern `${infer First}${string}` gives "😀" in TypeScript 7.0.2. Since the Playground still runs the 6.x compiler written in JavaScript, you may see a different result in edge cases like this.
A Small Experiment
★☆☆ Predict: given type Axis = "x" | "y";, how many members does `margin-${Axis}` | `padding-${Axis}` have? Then verify with the null trick.
★★☆ Write a Setters<T> similar to Getters: for Profile the result should be { setAge: (value: number) => void; setName: (value: string) => void; }.
★★★ Write KebabToCamel<S> so that "background-color" gives "backgroundColor". Hint: the `${infer Head}-${infer Tail}` pattern, Capitalize, and calling itself as RouteParams does.
RouteParams and KebabToCamel call themselves; at each step they handle one piece and hand the rest back to themselves until the string runs out. In the next post we'll put that idea at the center: recursive types, a self-referencing JSON type, DeepReadonly and DeepPartial, and where the compiler says "enough" to that recursion.