Template Literal and Mapped Types in Component APIs
Sometimes the shape of your props is not a hand-written interface—it is derived from other types. Event name lists, design-token class prefixes, and route path strings already exist in your codebase; template literal types and mapped types let you generate prop keys and value unions from them so documentation and implementation cannot drift.
The pain without this machinery is familiar: stringly-typed event props (onSomething spelled
wrong), CSS modules whose allowed combinations are only documented in Storybook, and router param
objects that omit a segment silently. The advanced-pattern payoff is exhaustive, checkable
APIs—at the cost of needing discipline when unions get too large.
Mapped types and the as clause for key remapping
A mapped type iterates keys of a union or interface and produces new properties. The as clause
renames keys—crucial for React-style handler names:
type EventName = "click" | "hover" | "focus" | "blur";
type EventHandlers = {
[K in EventName as `on${Capitalize<K>}`]: (event: Event) => void;
};
Capitalize is a built-in utility type; the result is equivalent to:
type EventHandlers = {
onClick: (event: Event) => void;
onHover: (event: Event) => void;
onFocus: (event: Event) => void;
onBlur: (event: Event) => void;
};
You can attach this to a component that programmatically wires DOM events:
type InstrumentedDivProps = React.HTMLAttributes<HTMLDivElement> & EventHandlers;
declare function InstrumentedDiv(props: InstrumentedDivProps): JSX.Element;
If you extend EventName with "keydown", both the literal union and the generated onKeydown
appear together—one edit, consistent surface.
Template literal unions for design tokens
Button variants often combine independent axes: size and color. Template literals express the cross product of allowed class tokens:
type Size = "sm" | "md" | "lg";
type Variant = "primary" | "secondary" | "danger";
type ButtonClass = `btn-${Size}-${Variant}`;
ButtonClass is the union of all nine strings (btn-sm-primary, …). You can use it to type a
data-variant attribute, a className helper’s return type, or a mapping object that must cover
every combination:
const classByToken: Record<ButtonClass, string> = {
"btn-sm-primary": "px-2 py-1 bg-blue-600",
"btn-sm-secondary": "px-2 py-1 bg-gray-200",
"btn-sm-danger": "px-2 py-1 bg-red-600",
"btn-md-primary": "px-3 py-1.5 bg-blue-600",
"btn-md-secondary": "px-3 py-1.5 bg-gray-200",
"btn-md-danger": "px-3 py-1.5 bg-red-600",
"btn-lg-primary": "px-4 py-2 bg-blue-600",
"btn-lg-secondary": "px-4 py-2 bg-gray-200",
"btn-lg-danger": "px-4 py-2 bg-red-600",
};
Miss one permutation and TypeScript errors. That is the “why”: design systems change; generated unions force the style map to stay complete.
Recursive template types for route params
Libraries and apps increasingly use template literal pattern inference to extract dynamic segments from path strings:
type ExtractParams<T extends string> = T extends `${string}:${infer Param}/${infer Rest}`
? Param | ExtractParams<`/${Rest}`>
: T extends `${string}:${infer Param}`
? Param
: never;
type Params = ExtractParams<"/users/:userId/posts/:postId">;
Params becomes "userId" | "postId". You can connect that to a generic Link component or a
navigate helper so route definitions typed as const propagate param names into hooks:
declare function buildPath<P extends string>(
pattern: P,
args: Record<ExtractParams<P>, string | number>,
): string;
const path = buildPath("/users/:userId/posts/:postId", {
userId: 1,
postId: 2,
});
The ergonomics depend on path strings being literal types at the call site. If pattern is only
string, inference collapses and you lose the extraction—another reminder that advanced types
cooperate best with as const inputs and generic boundaries.
Performance: when the compiler catches fire
Template unions multiply cardinality. Three unions of twenty members each do not yield twenty options—they yield up to twenty cubed (eight thousand) strings. Type checking is not free; huge unions inside hot paths (every component in a monorepo importing the same mega-type) can slow IDE feedback.
Mitigations:
- Narrow the published surface: export a small union of real tokens you ship, not every theoretical combination.
- Prefer enums at runtime, literals at compile time: generate types from a single config file if combinations are data-driven.
- Reach for branded types when uniqueness matters more than enumerating every string combination.
Branded types as an alternative
Sometimes you do not need all combinations—you need distinct strings that must not mix. Branded nominal typing uses intersection with a unique symbol:
declare const brand: unique symbol;
type UserId = string & { [brand]: "UserId" };
function UserId(id: string): UserId {
return id as UserId;
}
function loadUser(id: UserId) {
// ...
}
declare const raw: string;
// loadUser(raw) // error
loadUser(UserId("u_123"));
In component APIs, branding works well for route params, opaque server IDs, and CSS variable names—places where any string is too wide but a full template cross-product would be worse.
Practical guidance for React teams
Use template literal and mapped types when:
- The same source list drives both runtime behavior and prop names.
- You want
Record<Union, ...>completeness checks. - Route or URL patterns are stable literals worth inferring from.
Avoid them when:
- Unions explode in size or are driven by user-generated data at type level.
- Simpler generics or discriminated unions already encode the invariant.
- Compile-time cost visibly degrades editor performance—profile by temporarily replacing a
mega-union with
stringand watching check time.
Pick-style whitelists with mapped types
Libraries sometimes whitelist DOM keys from a larger element type:
type AllowedButtonKeys = "type" | "disabled" | "name" | "value";
type PickButtonProps = {
[K in AllowedButtonKeys]: React.ButtonHTMLAttributes<HTMLButtonElement>[K];
};
The same mapped-type machinery can back analytics data-* attributes or controlled aria-*
suffixes—always derived from a shared literal union so additions stay consistent.
const assertions and literal preservation
Template-heavy helpers need literal string types. Use as const on route tables and token
arrays so routes[number] stays a union of paths rather than string. Without that, param
extraction and class-token helpers collapse to useless widened types.
Codegen and design tokens
Teams often generate unions from OpenAPI, GraphQL, or token JSON. Feeding those generated literals into mapped types avoids maintaining two parallel lists by hand; components stay thin consumers of a single source of truth.
Advanced TypeScript in React is not about maximal cleverness; it is about keeping generated types aligned with real tokens—and knowing when to stop before the type checker becomes the bottleneck.