TypeScript Template Literal Types: String Manipulation at the Type Level
You've got an event emitter in your codebase. The emit method takes an event name and a payload. Your IDE autocompletes the event name — great. But the payload type? That's up to you to remember.
// ❌ The problem: payload type depends on event name, but nothing enforces it
class EventBus {
private listeners: Record<string, Function[]> = {};
emit(event: string, payload: unknown): void {
this.listeners[event]?.forEach(fn => fn(payload));
}
}
const bus = new EventBus();
bus.emit("user:login", { userId: 123 }); // OK
bus.emit("user:login", "just a string"); // Also OK — but wrong!
bus.emit("nonexistent:event", { anything: true }); // No error at all
Every event accepts unknown. Wrong payloads compile fine. Misspelled events compile fine. You only find out at runtime — or worse, in production.
Template literal types are the tool that lets you take this further. They let you manipulate string types at compile time, the same way template literals manipulate string values at runtime. And when you combine them with infer, mapped types, and intrinsic string types, you get compile-time guarantees that would otherwise require a separate validation layer.
What Are Template Literal Types?
If you've used JavaScript template literals, you already know the syntax. Template literal types look the same — but they operate on types instead of values:
type EventName = "click" | "focus" | "blur";
type EventHandler = `on${Capitalize<EventName>}`;
// Result: "onClick" | "onFocus" | "onBlur"
Every union member in the placeholder gets expanded into a combination. If you have multiple placeholders, you get the Cartesian product of all possibilities.
The intrinsic string types — Capitalize, Uncapitalize, Uppercase, Lowercase — are built into TypeScript 4.1+ and work exclusively at the type level with template literal types.
Use Case 1: Typed Event Emitter
Let's solve the opening problem properly.
Start by defining your event map as a type:
interface EventMap {
"user:login": { userId: number; sessionId: string };
"user:logout": { userId: number };
"cart:add": { productId: string; quantity: number };
"cart:remove": { productId: string };
}
Now derive the event names and their corresponding payloads:
type EventName = keyof EventMap & string;
// "user:login" | "user:logout" | "cart:add" | "cart:remove"
type PayloadFor<E extends EventName> = EventMap[E];
// { userId: number; sessionId: string } | ...
With these, your typed event bus becomes:
class TypedEventBus {
private listeners = new Map<string, Function[]>();
on<E extends EventName>(
event: E,
handler: (payload: PayloadFor<E>) => void
): void {
const entries = this.listeners.get(event) ?? [];
entries.push(handler);
this.listeners.set(event, entries);
}
emit<E extends EventName>(event: E, payload: PayloadFor<E>): void {
this.listeners.get(event)?.forEach(fn => fn(payload));
}
}
const bus = new TypedEventBus();
bus.on("user:login", (payload) => {
// ✅ payload.userId is number
// ✅ payload.sessionId is string
console.log(`User ${payload.userId} logged in`);
});
bus.emit("user:login", { userId: 1, sessionId: "abc" });
// ✅ Correct payload type enforced
bus.emit("user:login", { userId: 1 });
// ❌ Error: Property 'sessionId' is missing
bus.emit("user:logout", { userId: 1, sessionId: "abc" });
// ❌ Error: 'sessionId' doesn't exist in user:logout payload
No template literal types needed yet — string literal unions plus generics handle this. Template literal types shine when you need to transform or parse string types.
Use Case 2: Event Name Transformations (Template Literals in Action)
Say your event map uses "camelCase" convention, but your DOM API uses "on" + PascalCase:
interface DOMEvents {
click: MouseEvent;
focus: FocusEvent;
blur: FocusEvent;
keydown: KeyboardEvent;
keyup: KeyboardEvent;
}
type ElementListener<E extends keyof DOMEvents & string> =
`on${Capitalize<E>}`;
// "onClick" | "onFocus" | "onBlur" | "onKeydown" | "onKeyup"
type TypedElement = {
[E in keyof DOMEvents as `on${Capitalize<E & string>}`]:
(event: DOMEvents[E]) => void;
};
This is where template literal types + mapped type remapping (key renaming via as) really shine. Each event name gets transformed at the type level before it becomes a property key.
Use Case 3: Parsing Strings with infer
Template literal types let you extract parts of strings using the infer keyword in conditional types. This is like regex for types.
Extracting Route Parameters
type ExtractRouteParams<T extends string> =
T extends `${infer _Start}:${infer Param}/${infer Rest}`
? { [K in Param | keyof ExtractRouteParams<Rest>]: string }
: T extends `${infer _Start}:${infer Param}`
? { [K in Param]: string }
: {};
type UserRoute = ExtractRouteParams<"/users/:id/posts/:postId">;
// { id: string; postId: string }
Here's what's happening:
- Match
${anything}:${paramName}/${rest}— captures a param before a/ - Match
${anything}:${paramName}— captures a trailing param - No match → empty object
This recursively parses the path until all :param segments are extracted.
Parsing CSS Property Strings
type ParseCSSProperty<T extends string> =
T extends `--${infer Name}`
? { custom: true; name: Name }
: T extends `${infer Prop}-${infer Rest}`
? { custom: false; name: `${Prop}${Capitalize<Rest>}` }
: { custom: false; name: T };
type A = ParseCSSProperty<"background-color">;
// { custom: false; name: "backgroundColor" }
type B = ParseCSSProperty<"--primary-color">;
// { custom: true; name: "primary-color" }
type C = ParseCSSProperty<"margin">;
// { custom: false; name: "margin" }
The -${infer Rest} pattern split-then-Capitalize is exactly how libraries like React convert CSS property names to camelCase, but done entirely at the type level.
Extracting Promise Value Types
type UnwrapPromise<T> =
T extends Promise<infer V> ? V : T;
type A = UnwrapPromise<Promise<string[]>>;
// string[]
type B = UnwrapPromise<number>;
// number
Pair this with template literals to parse async patterns:
type ExtractAsyncType<T extends string> =
T extends `${infer _}Async` ? `Promise<${_}>` : T;
type A = ExtractAsyncType<"fetchDataAsync">;
// "Promise<fetchData>"
type B = ExtractAsyncType<"syncOp">;
// "syncOp"
Use Case 4: CSS Class Builder with Intrinsic Types
Here's a practical pattern — building typed CSS class name combinators:
type Size = "sm" | "md" | "lg";
type Color = "primary" | "secondary" | "danger";
type ButtonClass = `${Size}-${Color}`;
// "sm-primary" | "sm-secondary" | "sm-danger" | "md-primary" | ...
function button(config: { class: ButtonClass }): void {
// render button
}
button({ class: "md-primary" }); // ✅
button({ class: "lg-danger" }); // ✅
button({ class: "xl-primary" }); // ❌ "xl" not a valid size
button({ class: "md-warning" }); // ❌ "warning" not a valid color
9 valid combinations, exactly typed. No runtime validation needed.
Use Case 5: String Format Validation
Template literal types also work as compile-time validators for structured strings:
type HexDigit = "0" | "1" | "2" | "3" | "4" | "5" | "6" | "7"
| "8" | "9" | "a" | "b" | "c" | "d" | "e" | "f" | "A" | "B"
| "C" | "D" | "E" | "F";
type HexColor = `#${HexDigit}${HexDigit}${HexDigit}${HexDigit}${HexDigit}${HexDigit}`;
const valid: HexColor = "#ff5733"; // ✅
const invalid: HexColor = "#GGG000"; // ❌ "G" isn't a hex digit
Or for semantic versioning:
type SemVer = `${number}.${number}.${number}`;
const v1: SemVer = "1.0.0"; // ✅
const v2: SemVer = "2.1.5"; // ✅
const v3: SemVer = "1.0"; // ❌ Missing patch version
Note: TypeScript's
numberin template literal types matches any numeric literal string. This means"Infinity.0.0"and"NaN.1.0"would technically pass — template literal types check the shape, not the numerical validity. For stricter validation, pair with a branded type or a runtime check.
Use Case 6: Typed Query String Builder
Combine template literals with mapped types to derive valid query parameter combinations:
type QueryParams = {
page?: number;
limit?: number;
sort?: "asc" | "desc";
filter?: string;
};
type QueryString<T> = {
[K in keyof T as K & string]: `${K & string}=${T[K]}`
};
type QueryEntries = QueryString<Required<QueryParams>>;
// {
// page: "page=number";
// limit: "limit=number";
// sort: "sort=asc" | "sort=desc";
// filter: "filter=string";
// }
type ValidQueryParam = QueryEntries[keyof QueryEntries];
// "page=number" | "limit=number" | "sort=asc" | "sort=desc" | "filter=string"
This is the foundation for type-safe URL builder utilities.
When NOT to Use Template Literal Types
Template literal types are powerful, but they come with sharp edges:
🚨 Recursion depth limit. TypeScript has a ~50-level recursion limit on conditional types. Deeply nested template literal inference (like recursive route parsing with many parameters) will hit this limit. Keep your routes shallow or use union types for complex patterns.
// ❌ Will fail on deeply nested routes (>50 param segments)
type DeepRoute = ExtractRouteParams<"/a/:b/c/:d/e/:f/...">;
🚨 Combinatorial explosion. Every union placeholder multiplies the result set. "${A}-${B}" where A has 5 members and B has 10 members produces 50 combinations. Three placeholders with 10 members each? 1000 combinations. TypeScript handles this poorly and your editor will slow to a crawl.
type A = "a1" | "a2" | ... | "a10";
type B = "b1" | "b2" | ... | "b10";
type C = "c1" | "c2" | ... | "c10";
type Explosion = `${A}-${B}-${C}`;
// 1000 combinations — TypeScript will struggle
🚨 Template literal types are compile-time only. They have zero runtime effect. A valid template literal type doesn't mean the runtime value is valid — it only means the literal string in your source code matches the pattern. If your string comes from an API response or user input, you still need runtime validation.
function processColor(color: HexColor) {
// color is type-checked at compile time
// but if this value comes from an API response:
// typeof color is still string at runtime
}
// Runtime safety requires an actual validator:
function isHexColor(s: string): s is HexColor {
return /^#[0-9a-fA-F]{6}$/.test(s);
}
🚨 infer splits on the first occurrence of the literal separator. When parsing with infer, TypeScript finds the leftmost occurrence of the literal text in the pattern and splits there. This can produce surprising results:
type GreedyParse<T> = T extends `${infer A}/${infer B}` ? [A, B] : never;
type Test = GreedyParse<"a/b/c">;
// ["a", "b/c"] — not ["a/b", "c"]!
TypeScript finds the first / in the string and splits there: A gets everything before it, B gets everything after. If you need right-to-left splitting (matching the last /), you must recurse explicitly or restructure the pattern.
Summary
| Pattern | What it does | Example |
|---|---|---|
| Basic substitution | Expands placeholders across unions | `on${Capitalize<E>}` |
| Key remapping | Renames mapped type keys |
as \get${Capitalize}`` |
infer parsing |
Extracts parts of string types | `${infer Param}:${infer Type}` |
| Recursive inference | Parses structured strings | Route parameter extraction |
| Intrinsic types | Built-in string transformers |
Capitalize, Uppercase, etc. |
| Format validation | Enforces struct patterns | Hex color, semantic versioning |
Template literal types bridge the gap between stringly-typed code and compile-time safety. They won't replace runtime validation — and they shouldn't. But for code that deals with string constants, naming conventions, and structured identifiers, they eliminate entire categories of bugs before your code ever runs.
The best part? They compose beautifully with mapped types (covered in my previous article on mapped types). Mapped types transform object types; template literal types transform string types. Together they handle most of what you'd otherwise reach for code generation to solve.
Have a real-world use case where template literal types saved you? Drop it in the comments. I'm always looking for patterns I haven't seen.
If you're shipping TypeScript and want to debug faster, check out my *10 ChatGPT Prompts for Debugging React & Next.js** — crafted templates that catch the patterns that trip up even senior devs. Or grab the full Developer Prompt Pack Bundle with 70+ prompts covering debugging, API building, React patterns, and code review.*
Top comments (0)