Section 3 · Type-Level Programming
Template Literal Types
17 min3 min readCode playground
Lesson at a glance
Build string types from other types with template literals, transform them with Uppercase and Capitalize, and parse route patterns with infer to type path parameters.
- 1Building strings from types
- 2Transforming strings
- 3Parsing strings with infer
- 4Template literals as keys
No video here — just the real thing: a written lesson, runnable examples, a quiz and practice.
Cartwheel’s checkout emits events like order.shipped and payment.failed, and its API client builds URLs from patterns like /orders/:orderId/lines/:lineId. Both are strings with structure. Typing them as string means a typo in an event name or a missing path parameter only shows up when a listener never fires or a request 404s. Template literal types let the compiler see the structure.
Building strings from types
A template literal type uses the same backtick syntax as JavaScript template strings, but with types in the placeholders:
type OrderStatus = 'processing' | 'shipped' | 'delivered' | 'cancelled' | 'returned';
type OrderEvent = `order.${OrderStatus}`;
// 'order.processing' | 'order.shipped' | 'order.delivered' | 'order.cancelled' | 'order.returned'
type CheckoutEvent = OrderEvent | `payment.${'captured' | 'failed'}`;When a placeholder holds a union, the result has one member per union member. With two placeholders, you get every combination: `${'s' | 'm' | 'l'}-${'red' | 'blue'}` is six strings. This is the feature’s power and its danger, as you will see at the end of the lesson.
Now an event bus can type its event names from the domain types you already have:
declare function on(event: CheckoutEvent, handler: () => void): void;
on('order.shipped', () => {});
on('order.shiped', () => {});
// Error: Argument of type '"order.shiped"' is not assignable to parameter of type 'CheckoutEvent'.Add a status to OrderStatus and order.on-hold becomes a valid event name automatically.
Transforming strings
TypeScript ships four intrinsic string types that transform literal types: Uppercase, Lowercase, Capitalize and Uncapitalize. You used Capitalize for setter names in Mapped Types and Key Remapping. Another common use is environment variables and feature flags:
type Feature = 'express-shipping' | 'gift-wrap';
type FlagEnvVar = `CHECKOUT_${Uppercase<Feature>}`;
// 'CHECKOUT_EXPRESS-SHIPPING' | 'CHECKOUT_GIFT-WRAP'They are built into the compiler rather than defined in TypeScript, so you cannot see their source, but they behave like any other generic type.
Parsing strings with infer
Template literal types also work as patterns in a conditional type. Combined with infer, they pull substrings out of a string literal type:
type Method<R> = R extends `${infer M} ${string}` ? M : never;
type M1 = Method<'POST /orders/:id/cancel'>; // 'POST'The pattern `${infer M} ${string}` means “some text, a space, then anything”. TypeScript matches it against the literal and binds M to the part before the first space.
Here is the type that makes the URL builder safe. It collects every :param in a route pattern:
type PathParams<P extends string> =
P extends `${string}:${infer Param}/${infer Rest}`
? Param | PathParams<Rest>
: P extends `${string}:${infer Param}`
? Param
: never;
type P1 = PathParams<'/orders/:orderId/lines/:lineId'>; // 'orderId' | 'lineId'
type P2 = PathParams<'/orders'>; // neverRead it as two cases. If there is a parameter followed by more path, capture the parameter and recurse on the rest. If there is a parameter at the end, capture it. Otherwise there are no parameters. When TypeScript matches a pattern, each infer placeholder takes the shortest text that lets the rest of the pattern match, so Param stops at the first /.
The recursion (PathParams<Rest>) is the subject of the next lesson. For now, see what it gives you:
function buildPath<P extends string>(pattern: P, params: Record<PathParams<P>, string | number>): string {
// Every :param in the pattern has a key in params, by construction.
const values = params as Record<string, string | number>;
return pattern.replace(/:(\w+)/g, (_, name: string) => encodeURIComponent(String(values[name])));
}
buildPath('/orders/:orderId/lines/:lineId', { orderId: 1042, lineId: 3 }); // '/orders/1042/lines/3'
buildPath('/orders/:orderId/lines/:lineId', { orderId: 1042 });
// Error: … Property 'lineId' is missing in type '{ orderId: number; }' …P is inferred as the literal pattern, PathParams<P> turns it into the required keys, and Record turns those into the shape of params. A route with no parameters takes {}.
Template literals as keys
Event names are only half the story; each event also carries a payload. A mapped type with an as clause can build the whole name-to-payload table from the status union in one go:
type OrderEventPayloads = {
[S in OrderStatus as `order.${S}`]: { orderId: number; status: S };
};
type ShippedPayload = OrderEventPayloads['order.shipped'];
// { orderId: number; status: 'shipped' }The loop runs over statuses, the as clause turns each one into an event name, and the payload type can still refer to the original S. This table is exactly what a typed event emitter needs, and you will build one on top of it in Type-Safe Event Emitters and Builders. Notice that nothing here is new: it is a mapped type, a template literal and an indexed access, combined. Most real type-level code is a few small pieces like these, put together carefully.
In the exercise you will type Cartwheel’s event names and URL builder. Next lesson looks at recursion in types: what it is good for, how deep it can go, and when a simpler type is the better engineering choice.
Key takeaways
- A template literal type like
`order.${OrderStatus}`builds string literal types, and a union inside it produces every combination. Uppercase,Lowercase,CapitalizeandUncapitalizeare built-in types that transform string literal types.inferinside a template literal pattern captures substrings, which lets you parse route patterns such as/orders/:orderIdat the type level.${number}and${string}placeholders accept loose patterns; they document a format but do not validate it strictly.- Unions inside template literals multiply, and TypeScript refuses unions over 100,000 members, so keep the inputs small.