TypeScript Advanced #4: Template literal types
In #3 Conditional types and infer we learned branching and extraction. The tool we cover this time is template literal types, which compose strings at the type level. The shape is identical to JavaScript’s `${...}` but it operates in type position.
Basics — composing string literals #
type Greeting = `hello, ${string}`;
const a: Greeting = 'hello, curtis'; // OK
const b: Greeting = 'hi, curtis'; // ✗ must start with 'hello, '
const c: Greeting = 'hello, '; // OK (an empty trailing string is still a string)
Anything string can go in the ${string} slot. The shape must start with hello, . With this, you can enforce the pattern at compile time.
The placeholder accepts number, boolean, and union literals besides string.
type ID = `user-${number}`;
const a: ID = 'user-42'; // OK
const b: ID = 'user-abc'; // ✗ number slot
type Color = 'red' | 'blue' | 'green';
type ColorClass = `color-${Color}`;
// 'color-red' | 'color-blue' | 'color-green'
Putting in a union like Color distributes over the result union. Same principle as the distributive conditionals from #3.
When two unions go in — the Cartesian product #
If there are two slots and both are unions, you get the Cartesian product.
type Side = 'top' | 'right' | 'bottom' | 'left';
type Size = 'sm' | 'md' | 'lg';
type Spacing = `m-${Side}-${Size}`;
// 'm-top-sm' | 'm-top-md' | ... | 'm-left-lg'
// 12 in total
The payoff is huge for cases like CSS class names. We get 12 combinations like m-top-sm, and adding a new size grows the union by 4 automatically.
Built-in string helpers #
Alongside template literal types, there are four built-in helpers.
| Helper | Result |
|---|---|
Uppercase<S> | ‘hello’ → ‘HELLO’ |
Lowercase<S> | ‘HELLO’ → ‘hello’ |
Capitalize<S> | ‘hello’ → ‘Hello’ |
Uncapitalize<S> | ‘Hello’ → ‘hello’ |
type A = Uppercase<'hello'>; // 'HELLO'
type B = Capitalize<'curtis'>; // 'Curtis'
type C = Uncapitalize<'Curtis'>; // 'curtis'
type EventNames = Uppercase<'click' | 'change'>;
// 'CLICK' | 'CHANGE'
Manipulating strings at the type level may look strange, but it shows up frequently when writing library types.
Real-world 1 — auto-generating setter methods #
A pattern we briefly saw in #2. Auto-generate a setter for each key of an object.
type Setters<T> = {
[K in keyof T as `set${Capitalize<string & K>}`]: (value: T[K]) => void;
};
type UserSetters = Setters<{ name: string; age: number; email: string }>;
// {
// setName: (value: string) => void;
// setAge: (value: number) => void;
// setEmail: (value: string) => void;
// }
Three tools combined.
- mapped type — for each key
- template literal —
set+capitalized key Capitalize<string & K>— capitalize the first letter only when K is a string
The meaning of string & K is “K may be a symbol/number key, so keep only the string ones.” A common idiom when using mapped types.
Real-world 2 — event handler name mapping #
Auto-generate handler names from DOM event or custom event names.
type EventName = 'click' | 'change' | 'focus' | 'blur';
type HandlerName = `on${Capitalize<EventName>}`;
// 'onClick' | 'onChange' | 'onFocus' | 'onBlur'
type Handlers = {
[K in HandlerName]: (e: Event) => void;
};
// {
// onClick: (e: Event) => void;
// onChange: (e: Event) => void;
// ...
// }
The event handler naming convention React uses is exactly this pattern. Similar auto-generation shows up inside libraries.
Real-world 3 — extracting route parameters #
Pulling parameter names out of a route pattern string — one of the most famous TypeScript tricks.
type ExtractParams<S extends string> =
S extends `${string}:${infer P}/${infer Rest}`
? P | ExtractParams<`/${Rest}`>
: S extends `${string}:${infer P}`
? P
: never;
type A = ExtractParams<'/users/:id'>; // 'id'
type B = ExtractParams<'/users/:userId/posts/:postId'>;
// 'userId' | 'postId'
Template literal + infer + recursion all in one. It looks scary at first, but reading line by line isn’t hard.
${string}:${infer P}/${infer Rest}— if the pattern is:name/..., capture the name and the restP | ExtractParams<...>— extract more from the rest- If only
:nameis left without anything after, that’s the last parameter
With this in place, a safe router function like below becomes possible.
function navigate<S extends string>(
pattern: S,
params: Record<ExtractParams<S>, string>
): void {
// ...
}
navigate('/users/:id', { id: '42' }); // OK
navigate('/users/:id', { name: '커티스' }); // ✗ id missing
navigate('/users/:id/posts/:postId', {
id: '1',
postId: '7',
}); // OK
Just from the route pattern, the compiler infers exactly which parameters are needed. Similar patterns live inside the type definitions of Next.js and React Router.
Real-world 4 — enforcing CSS units #
If a prop that takes a CSS value is just string, the user could pass “20” without a unit. With template literals you can enforce a unit.
type CSSUnit = 'px' | 'rem' | 'em' | '%';
type CSSValue = `${number}${CSSUnit}`;
const a: CSSValue = '20px'; // OK
const b: CSSValue = '1.5rem'; // OK
const c: CSSValue = '20'; // ✗ no unit
const d: CSSValue = 'auto'; // ✗ to allow 'auto', extend the union
Possibly too strict, but used as a prop in design-system components, it cuts mistakes. Add 'auto' | 'inherit' and similar keywords to the union when needed.
Pitfall — string is too wide #
Putting ${string} in a template literal creates a slot that accepts any string, so the resulting type ultimately lets any string through.
type ApiPath = `/api/${string}`;
const a: ApiPath = '/api/users'; // OK
const b: ApiPath = '/api/'; // OK
const c: ApiPath = '/api/foo bar'; // OK — even spaces pass
To narrow further, use a tighter union instead of ${string}, or pair with separate validation logic. Stay aware that TypeScript alone can’t fully enforce every format.
Pitfall — recursion that’s too deep gets blocked #
Recursive patterns like route parameter extraction are powerful, but TypeScript’s recursion depth limit (around 50) stops too-long inputs.
// Routes with more than 50 :params can't be processed
type Many = ExtractParams<'/a/:p1/:p2/.../:p100'>;
// compiler gives up (Type instantiation is excessively deep)
You rarely hit this in real work, but it shows up occasionally when writing library types. For safety against depth, tail-recursive forms (accumulating the captured result via a parameter) hold up better.
Wrap-up #
What this post covered:
- Template literal type — composing strings with
`${...}` - Slots accept
string/number/literal unions - Putting in a union distributes to a Cartesian product
- Built-in helpers —
Uppercase/Lowercase/Capitalize/Uncapitalize - Mapped + template literal to auto-generate key names (the setter pattern)
- Tricks like route parameter extraction with infer + recursion + template literal
${string}is too wide — separate validation needed to narrow- Mind the recursion depth limit
In the next post (#5 Discriminated unions and type guards in depth) we cover branchable type modeling — discriminated unions in depth — plus user-defined type guards, assertion functions, and branded types.