EduFlow

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.

  1. 1Building strings from types
  2. 2Transforming strings
  3. 3Parsing strings with infer
  4. 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:

TypeScript
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:

TypeScript
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:

TypeScript
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:

TypeScript
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:

TypeScript
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'>;                        // never

Read 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:

TypeScript
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:

TypeScript
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, Capitalize and Uncapitalize are built-in types that transform string literal types.
  • infer inside a template literal pattern captures substrings, which lets you parse route patterns such as /orders/:orderId at 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.

Further reading

Check your understanding

4 questions

Question 1 of 4

How many members does `${'s' | 'm' | 'l'}-${'red' | 'blue'}` have?

Question 2 of 4

What does PathParams<'/orders/:orderId/lines/:lineId'> give with the recursive definition from the lesson?

Question 3 of 4

Which value is accepted by type Price = ${number}``?

Question 4 of 4

A teammate builds `${Locale}/${Currency}/${Category}/${Sku}` from four unions with 40, 30, 15 and 500 members. What happens?

Practice

Code playground

Type event names and URL patterns

Use template literal types to type Cartwheel’s strings:

  • CheckoutEvent: order.<status> for every OrderStatus, plus payment.<outcome> for every PaymentOutcome.
  • FlagEnvVar: CHECKOUT_ followed by each Feature in upper case.
  • PathParams<P>: the names of every :param in a route pattern (never when there are none).

Then implement buildPath(pattern, params) at runtime: replace each :param with its value from params, URL-encoded with encodeURIComponent.

type OrderStatus = 'processing' | 'shipped' | 'delivered' | 'cancelled' | 'returned';
type PaymentOutcome = 'captured' | 'failed';
type Feature = 'express-shipping' | 'gift-wrap';

type CheckoutEvent = string;
type FlagEnvVar = string;
type PathParams<P extends string> = string;

function buildPath<P extends string>(pattern: P, params: Record<PathParams<P>, string | number>): string {
  return pattern;
}

// ---- Type tests (do not edit) ----
type T1 = Expect<Equal<CheckoutEvent,
  | 'order.processing' | 'order.shipped' | 'order.delivered' | 'order.cancelled' | 'order.returned'
  | 'payment.captured' | 'payment.failed'>>;
type T2 = Expect<Equal<FlagEnvVar, 'CHECKOUT_EXPRESS-SHIPPING' | 'CHECKOUT_GIFT-WRAP'>>;
type T3 = Expect<Equal<PathParams<'/orders/:orderId/lines/:lineId'>, 'orderId' | 'lineId'>>;
type T4 = Expect<Equal<PathParams<'/customers/:customerId'>, 'customerId'>>;
type T5 = Expect<Equal<PathParams<'/orders'>, never>>;

// @ts-expect-error: 'order.shiped' is a typo
const typo: CheckoutEvent = 'order.shiped';

// @ts-expect-error: lineId is missing
buildPath('/orders/:orderId/lines/:lineId', { orderId: 1042 });

console.log(buildPath('/orders/:orderId/lines/:lineId', { orderId: 1042, lineId: 3 }));
Loading the editor…

Ctrl/⌘ + Enter to run · Runs in a sandbox in your browser. Nothing leaves your device.

Console

(no output)

Checks

  • The file type-checks with zero errors, including all type tests
  • buildPath fills in every parameter and leaves parameter-free paths alone
  • Parameter values are URL-encoded