Strings Are a Type, Not Just a Value
Most people's mental model of stringstops at "any sequence of characters." Template literal types break that model on purpose. `change:${K}`isn't a hint or a naming convention you have to remember โ it's a real type, checked at every call site, that happens to be built out of other types glued together with string syntax. Event names, route params, CSS custom properties: anywhere you've been passing a "just trust me" string, there's a decent chance a template literal type can make the compiler enforce it instead.
- Your event emitter takes a plain
stringfor the event name and you find out about typos at runtime, in production - You've hand-written a route param type instead of deriving it from the actual route string, and the two have already drifted once
- You've seen
error TS2590: Expression produces a union type that is too complex to representand worked around it by just widening everything tostring - You want compile-time checked CSS variable names or Tailwind-style class combinations without a runtime library doing the work
Template literal types are combinatorial by nature โ that's what makes them powerful and also what makes them slow tsserver down if you're not paying attention to how many unions you're multiplying together.
The Basics: Combinations, Not Concatenation
type Size = 'sm' | 'md' | 'lg';
type Color = 'primary' | 'secondary' | 'danger';
type ButtonClass = `btn-${Color}-${Size}`;
// 'btn-primary-sm' | 'btn-primary-md' | 'btn-primary-lg'
// | 'btn-secondary-sm' | 'btn-secondary-md' | 'btn-secondary-lg'
// | 'btn-danger-sm' | 'btn-danger-md' | 'btn-danger-lg' โ 9 members, not 1
const cls: ButtonClass = 'btn-primary-md'; // โ
const bad: ButtonClass = 'btn-primary-xl'; // โ 'xl' isn't a SizeEvery union you cross into a template literal multiplies the member count. Two unions of three each gives nine. That's fine. It stops being fine faster than people expect.
The Combinatorial Explosion
type Entity = 'user' | 'post' | 'comment' | 'like' | 'follow' | 'notification' /* + 20 more */;
type Action = 'created' | 'updated' | 'deleted' | 'archived' /* + 10 more */;
type Actor = 'system' | 'admin' | 'user' | 'api' /* + 8 more */;
// 26 ร 14 ร 12 โ 4,368 literal members โ TypeScript has to materialize every single one
type EventName = `${Entity}.${Action}.by-${Actor}`;
// Cross a large enough set of unions and the compiler gives up outright:
// error TS2590: Expression produces a union type that is too complex to represent.Nobody hits this with two ten-member unions. It shows up when a third or fourth union gets multiplied in โ someone adds an Actordimension to an already-large event name type and tsserver goes from instant to visibly laggy on every keystroke in that file, sometimes well before TS2590 actually fires. Past a few hundred combinations, the type safety usually isn't worth what it costs the editor. A plain string validated at the boundary with a Zod regex or enum does the same job without asking the compiler to enumerate every possibility up front.
Type-Safe Event Emitter Keys
interface UserState {
name: string;
email: string;
role: 'admin' | 'member';
}
class TypedEmitter<T extends Record<string, unknown>> {
private listeners = new Map<string, Set<(value: unknown) => void>>();
on<K extends Extract<keyof T, string>>(event: `change:${K}`, handler: (value: T[K]) => void): void {
const set = this.listeners.get(event) ?? new Set();
set.add(handler as (value: unknown) => void);
this.listeners.set(event, set);
}
emit<K extends Extract<keyof T, string>>(event: `change:${K}`, value: T[K]): void {
this.listeners.get(event)?.forEach((fn) => fn(value));
}
}
const state = new TypedEmitter<UserState>();
state.on('change:role', (value) => console.log(value)); // value: 'admin' | 'member'
state.on('change:rolee', () => {}); // โ not assignable โ no such key on UserStatePulling Data Back Out With infer
Template literal types run in both directions. You can build a string from parts, or take a string pattern apart and capture pieces of it with infer inside a conditional type โ which is how you get a route string to produce its own params type instead of writing one by hand and hoping it stays in sync.
flowchart LR
R["'/users/:id/orders/:orderId'"] --> E["ExtractParams<Path>"]
E --> S1["Split on first ':'<br/>infer Param, infer Rest"]
S1 --> S2["Recurse into Rest<br/>until no ':' remains"]
S2 --> O["{ id: string; orderId: string }"]
style R fill:#6b7280,color:#fff,stroke:#4b5563
style O fill:#16a34a,color:#fff,stroke:#15803d
type ExtractParams<Path extends string> = Path extends `${string}:${infer Param}/${infer Rest}`
? { [K in Param]: string } & ExtractParams<`/${Rest}`>
: Path extends `${string}:${infer Param}`
? { [K in Param]: string }
: Record<string, never>;
type OrderParams = ExtractParams<'/users/:id/orders/:orderId'>;
// { id: string } & { orderId: string } โ effectively { id: string; orderId: string }
function route<Path extends string>(path: Path, handler: (params: ExtractParams<Path>) => void) {
/* ... */
}
route('/users/:id/orders/:orderId', (params) => {
params.id; // โ
string
params.orderId; // โ
string
params.missing; // โ Property 'missing' does not exist
});The Built-In Intrinsics
TypeScript ships four string-manipulation types that operate purely at the type level โ no runtime cost, because they never run, they just rewrite a literal type during checking: Uppercase, Lowercase, Capitalize, Uncapitalize.
type Entity = { name: string; age: number; email: string };
type Getters<T> = {
[K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};
type EntityGetters = Getters<Entity>;
// { getName: () => string; getAge: () => number; getEmail: () => string }
type CSSVarName<T extends string> = `--${Lowercase<T>}`;
type ThemeVar = CSSVarName<'PrimaryColor'>; // '--primarycolor'Putting It Together: A Type-Safe Route Builder
type Route = '/users/:id' | '/users/:id/orders/:orderId' | '/health';
function buildPath<R extends Route>(route: R, params: ExtractParams<R>): string {
let path: string = route;
for (const key in params) {
path = path.replace(`:${key}`, String(params[key as keyof typeof params]));
}
return path;
}
buildPath('/users/:id', { id: '42' }); // โ
buildPath('/users/:id/orders/:orderId', { id: '42' }); // โ missing 'orderId'
buildPath('/health', {}); // โ
Record<string, never> accepts {}One More Pitfall Worth Knowing
The `${number}`pattern matches TypeScript's idea of a numeric literal, not what your business logic considers a valid number. It happily accepts decimals.
type UserId = `user-${number}`;
const a: UserId = 'user-42'; // โ
const b: UserId = 'user-4.2'; // โ
still matches โ 4.2 is a valid numeric literal, decimal or not
const c: UserId = 'user-NaN'; // โ 'NaN' isn't a numeric literal tokenKey Takeaways
- Every union crossed into a template literal type multiplies the total member count โ three medium unions is often already too many.
- TS2590 isn't a bug to work around with a cast; it's the compiler telling you the type is doing a runtime validator's job.
- infer works in reverse just as well as forward โ derive param types from the route string instead of writing both by hand.
- A
numbertemplate segment checks "is this a numeric literal," not "is this the integer my code expects" โ decimals slip through.

