Phase 5 · Type-level programming · Lesson 5.5
AdvancedTemplate literal types
String manipulation at the type level: build string unions from other unions, change case with the intrinsic helpers, parse strings with `infer`, and derive getter names, event names and route params from a single string.
25 min
Your event bus accepts names like "user:created" and "post:deleted". Your router has paths like "/users/:id/posts/:postId", and handlers read params.id. Typed as plain string, a typo such as "user:craeted" or params.userId compiles fine and fails silently at runtime. Template literal types let the type system build, check and even parse strings, so those names and params become exact, autocompleted types derived from one source.
The basics: backticks in a type
A template literal type uses the same syntax as a JavaScript template string, but in a type position, and the placeholders hold types:
type Expect<T extends true> = T;
type Equal<X, Y> = (<T>() => T extends X ? 1 : 2) extends (<T>() => T extends Y ? 1 : 2) ? true : false;
type Greeting = `hello ${"world"}`;
type _t1 = Expect<Equal<Greeting, "hello world">>;
type Version = 5;
type Tag = `v${Version}`;
type _t2 = Expect<Equal<Tag, "v5">>;
type _t3 = Expect<Equal<`${true}`, "true">>;
type _t4 = Expect<Equal<`${null}-${undefined}`, "null-undefined">>;A placeholder accepts string, number, bigint, boolean, null and undefined types (and literals of them). The value is converted the same way JavaScript's ${} would convert it. symbol is not allowed, which is why you'll often write K & string when K comes from keyof.
Pattern types: ${number} and ${string}
If a placeholder holds a non-literal type, the result is not a single string but a pattern that matches many strings:
type Pixels = `${number}px`;
type Id = `user_${string}`;
const width: Pixels = "12.5px";
const id: Id = "user_8f2a";
// @ts-expect-error -- Type '"12em"' is not assignable to type '`${number}px`'.
const bad: Pixels = "12em";
// @ts-expect-error -- Type '"admin_1"' is not assignable to type '`user_${string}`'.
const wrong: Id = "admin_1";${number} accepts any string that parses as a JavaScript number, so "12.5px" works. It's more permissive than you might expect: strings like "1e3" and "0x10" also count as numbers.
Unions multiply
Put a union in a placeholder and the result is a union. Put unions in several placeholders and you get every combination, the cartesian product:
type Expect<T extends true> = T;
type Equal<X, Y> = (<T>() => T extends X ? 1 : 2) extends (<T>() => T extends Y ? 1 : 2) ? true : false;
type Size = "sm" | "md" | "lg";
type Color = "red" | "blue";
type ButtonClass = `btn-${Size}-${Color}`;
type _t1 = Expect<Equal<
ButtonClass,
"btn-sm-red" | "btn-sm-blue" | "btn-md-red" | "btn-md-blue" | "btn-lg-red" | "btn-lg-blue"
>>;Three sizes times two colors: six members. That growth is the thing to watch. Four placeholders with 10 options each is 10,000 members, and five would be 100,000, which is past the limit: TypeScript stops with Expression produces a union type that is too complex to represent.
Quick check
How many members does Cell have?
type Row = "A" | "B" | "C";
type Col = 1 | 2 | 3;
type Cell = `${Row}${Col}`;Intrinsic string types
TypeScript ships four string helpers. They're called intrinsic because they're built into the compiler, not written in TypeScript:
type Expect<T extends true> = T;
type Equal<X, Y> = (<T>() => T extends X ? 1 : 2) extends (<T>() => T extends Y ? 1 : 2) ? true : false;
type _t1 = Expect<Equal<Uppercase<"hello">, "HELLO">>;
type _t2 = Expect<Equal<Lowercase<"HeLLo">, "hello">>;
type _t3 = Expect<Equal<Capitalize<"userName">, "UserName">>;
type _t4 = Expect<Equal<Uncapitalize<"UserName">, "userName">>;
// They distribute over unions.
type _t5 = Expect<Equal<Uppercase<"get" | "set">, "GET" | "SET">>;On a non-literal string they stay as a marker type (Uppercase<string>), which still checks: only upper-case strings are assignable to it.
Key remapping: getters and handlers
Combined with key remapping from the mapped types lesson, you can generate method names from property names:
type Expect<T extends true> = T;
type Equal<X, Y> = (<T>() => T extends X ? 1 : 2) extends (<T>() => T extends Y ? 1 : 2) ? true : false;
type Getters<T> = {
[K in keyof T as `get${Capitalize<K & string>}`]: () => T[K];
};
type ChangeHandlers<T> = {
[K in keyof T as `on${Capitalize<K & string>}Change`]: (next: T[K]) => void;
};
interface Form {
email: string;
age: number;
}
type _t1 = Expect<Equal<Getters<Form>, { getEmail: () => string; getAge: () => number }>>;
type _t2 = Expect<Equal<
ChangeHandlers<Form>,
{ onEmailChange: (next: string) => void; onAgeChange: (next: number) => void }
>>;The K & string does real work: keyof T may include number and symbol keys, and Capitalize only accepts strings. The intersection keeps string keys and turns the others into never, which the as clause then drops.
Parsing strings with infer
Template literal types can be used as patterns in a conditional type, with infer capturing the parts. This is how you parse strings at compile time.
Starts with, split at a separator
type Expect<T extends true> = T;
type Equal<X, Y> = (<T>() => T extends X ? 1 : 2) extends (<T>() => T extends Y ? 1 : 2) ? true : false;
type StartsWith<S extends string, P extends string> = S extends `${P}${string}` ? true : false;
type _t1 = Expect<Equal<StartsWith<"onClick", "on">, true>>;
type _t2 = Expect<Equal<StartsWith<"click", "on">, false>>;
type Split<S extends string, D extends string> =
S extends `${infer Head}${D}${infer Tail}` ? [Head, ...Split<Tail, D>] : [S];
type _t3 = Expect<Equal<Split<"a,b,c", ",">, ["a", "b", "c"]>>;
type _t4 = Expect<Equal<Split<"no-commas", ",">, ["no-commas"]>>;
type FirstDot = "a.b.c" extends `${infer A}.${infer B}` ? [A, B] : never;
type _t5 = Expect<Equal<FirstDot, ["a", "b.c"]>>;The matching rule to remember: an infer followed by a literal takes the shortest match, up to the first occurrence of that literal. That's why FirstDot splits at the first dot and puts the rest, "b.c", into B.
Character by character
When two infers are adjacent with nothing between them, the first takes exactly one character and the last takes the rest:
type Expect<T extends true> = T;
type Equal<X, Y> = (<T>() => T extends X ? 1 : 2) extends (<T>() => T extends Y ? 1 : 2) ? true : false;
type HeadTail<S extends string> = S extends `${infer H}${infer T}` ? [H, T] : never;
type _t1 = Expect<Equal<HeadTail<"hello">, ["h", "ello"]>>;
type _t2 = Expect<Equal<HeadTail<"">, never>>;
type KebabCase<S extends string> =
S extends `${infer H}${infer T}`
? `${H extends Lowercase<H> ? H : `-${Lowercase<H>}`}${KebabCase<T>}`
: S;
type _t3 = Expect<Equal<KebabCase<"backgroundColor">, "background-color">>;KebabCase walks the string one character at a time. For each character it checks whether it's already lower-case; if not, it emits a dash plus the lower-case version.
Trim and Replace
type Expect<T extends true> = T;
type Equal<X, Y> = (<T>() => T extends X ? 1 : 2) extends (<T>() => T extends Y ? 1 : 2) ? true : false;
type Whitespace = " " | "\n" | "\t";
type TrimLeft<S extends string> = S extends `${Whitespace}${infer Rest}` ? TrimLeft<Rest> : S;
type TrimRight<S extends string> = S extends `${infer Rest}${Whitespace}` ? TrimRight<Rest> : S;
type Trim<S extends string> = TrimLeft<TrimRight<S>>;
type _t1 = Expect<Equal<Trim<" \n hello world \t ">, "hello world">>;
type Replace<S extends string, From extends string, To extends string> =
From extends "" ? S :
S extends `${infer Before}${From}${infer After}` ? `${Before}${To}${After}` : S;
type ReplaceAll<S extends string, From extends string, To extends string> =
From extends "" ? S :
S extends `${infer Before}${From}${infer After}`
? `${Before}${To}${ReplaceAll<After, From, To>}`
: S;
type _t2 = Expect<Equal<Replace<"a-b-c", "-", "+">, "a+b-c">>;
type _t3 = Expect<Equal<ReplaceAll<"a-b-c", "-", "+">, "a+b+c">>;
type _t4 = Expect<Equal<ReplaceAll<"abc", "", "x">, "abc">>;▶ Try it in the TypeScript Playground
Notes worth saying out loud in an interview:
- A union of patterns in one placeholder (
${Whitespace}) matches any of them. ReplaceAllrecurses onAfteronly, never on the replaced text, so replacing"a"with"aa"can't loop forever.- The
From extends ""guard matters: an empty pattern would match at every position, and without the guard you'd get a surprising result.
Quick check
What is Parts?
type Parts = "2024-01-15" extends `${infer Y}-${infer Rest}` ? [Y, Rest] : never;Typed event names
Build the full set of names from their parts, then parse a name back into its parts to find the payload:
type Entity = "user" | "post";
type Action = "created" | "deleted";
type EventName = `${Entity}:${Action}`;
interface Payloads {
user: { id: number };
post: { slug: string };
}
type EntityOf<E extends string> = E extends `${infer En extends Entity}:${string}` ? En : never;
declare function on<E extends EventName>(event: E, handler: (payload: Payloads[EntityOf<E>]) => void): void;
on("post:deleted", (payload) => {
const slug: string = payload.slug;
console.log(slug);
});
// @ts-expect-error -- Argument of type '"user:craeted"' is not assignable to parameter of type 'EventName'.
on("user:craeted", () => {});EventName gives autocomplete for all four names and rejects typos. EntityOf pulls "post" back out of "post:deleted", and the constraint infer En extends Entity guarantees it's a valid key of Payloads. Add an entity to the union and every name for it appears automatically.
Extracting route params
The same idea turns a route string into a params object. Split on /, keep the segments that start with ::
type Expect<T extends true> = T;
type Equal<X, Y> = (<T>() => T extends X ? 1 : 2) extends (<T>() => T extends Y ? 1 : 2) ? true : false;
type ParamNames<Path extends string> =
Path extends `${string}:${infer Name}/${infer Rest}`
? Name | ParamNames<Rest>
: Path extends `${string}:${infer Name}`
? Name
: never;
type Params<Path extends string> = { [K in ParamNames<Path>]: string };
type _t1 = Expect<Equal<ParamNames<"/users/:id/posts/:postId">, "id" | "postId">>;
type _t2 = Expect<Equal<Params<"/users/:id/posts/:postId">, { id: string; postId: string }>>;
type _t3 = Expect<Equal<Params<"/about">, {}>>;
function buildPath<P extends string>(path: P, params: Params<P>): string {
return path.replace(/:(\w+)/g, (_, key: string) => (params as Record<string, string>)[key]);
}
buildPath("/users/:id", { id: "42" });
// @ts-expect-error -- Object literal may only specify known properties, and 'userId' does not exist in type 'Params<"/users/:id">'.
buildPath("/users/:id", { userId: "42" });Trace ParamNames<"/users/:id/posts/:postId">:
- The first pattern matches:
${string}skips"/users/",Name="id"(up to the next/),Rest="posts/:postId". - Recurse on
"posts/:postId": the first pattern needs a/after the name, and there isn't one, so the second pattern matches withName="postId". - The result is
"id" | "postId", andParamsmaps that union to an object.
Collecting the names as a union first, then mapping once, gives a clean flat object type. Building intersections at each step ({ id: string } & { postId: string }) works too, but it's harder to read in hovers and error messages.
Spot the error
This should turn an object type into setter names like setName. It doesn't compile. Why?
type Setters<T> = {
[K in keyof T as `set${Capitalize<K>}`]: (value: T[K]) => void;
};Show the answer
keyof T can contain number and symbol keys, not just strings. Capitalize requires K extends string, so the compiler reports Type 'K' does not satisfy the constraint 'string'. (Even a plain `set${K}` would fail, because symbol can't go in a template literal.) Keep only the string keys:
type Expect<T extends true> = T;
type Equal<X, Y> = (<T>() => T extends X ? 1 : 2) extends (<T>() => T extends Y ? 1 : 2) ? true : false;
type Setters<T> = {
[K in keyof T as `set${Capitalize<K & string>}`]: (value: T[K]) => void;
};
type _t1 = Expect<Equal<Setters<{ name: string; 0: number }>, { setName: (value: string) => void }>>;The numeric key 0 became never (0 & string) and was dropped. If you want to keep numeric keys, use `set${Capitalize<`${K & (string | number)}`>}` instead: that converts numbers to their string form first.
Recap
- Template literal types use backtick syntax with types in the placeholders:
`v${Version}`. - Placeholders accept
string,number,bigint,boolean,null,undefined, notsymbol: useK & stringfor keys. ${number}and${string}create patterns, not single strings.- Unions in placeholders produce the cartesian product; around 100,000 members is the hard limit.
Uppercase,Lowercase,Capitalize,Uncapitalizeare compiler intrinsics and distribute over unions.- In a conditional, template literals are patterns:
inferbefore a literal stops at the first match; adjacentinfers take one character, then the rest. - Real uses: getter and handler names via
asremapping, typed event names, route params, CSS unit strings.
Interview cards
1 / 7