TypeScript advanced types · 4 / 8
lesson 4

Template literal types

Strings as types, joined like template strings. Use them for event names, route params, and CSS keys.

~ 18 min read·lesson 4 of 8
0 / 8

You're typing an event emitter. Every event name in your app starts with the feature, then an underscore, then the action — cart_open, cart_close, auth_login. Hand-listing every possible name is brittle; you'd rather describe the shape. Template literal types do exactly that. They're regular template strings, except every part of them is a type, and the result is a type.

How they look

A template literal type is a string literal type built with backticks. The placeholders accept other types instead of values.

basics.ts
type Greeting = `hello, ${string}`;

const a: Greeting = "hello, world";   // ok
const b: Greeting = "hello, friend";  // ok
const c: Greeting = "hi there";       // error — doesn't start with "hello, "

The interpolated ${string} slot accepts any string, so Greeting is the type of all strings that begin with hello, . Without that slot, you'd have an exact match — \hello, world`is just the literal"hello, world"`.

When the placeholders hold unions, the template literal expands across every combination. This is where the real power shows up.

combinations.ts
type Side = "left" | "right";
type Edge = "top" | "bottom";

type Corner = `${Edge}-${Side}`;
// "top-left" | "top-right" | "bottom-left" | "bottom-right"

Corner becomes a union of all four combinations. Two unions of size two produce a union of size four. The math gets out of hand quickly — three unions of size five is 125 strings — but the syntax stays the same.

check your understanding
What is type R = `v${1 | 2 | 3}`?

Event-name unions

The event-emitter use case lands cleanly. Define the features and actions separately, then describe how they combine.

events.ts
type Feature = "cart" | "auth" | "search";
type Action = "open" | "close" | "submit";

type EventName = `${Feature}_${Action}`;
// "cart_open" | "cart_close" | "cart_submit" |
// "auth_open" | ... | "search_submit"

function on(name: EventName, handler: () => void) { /* ... */ }
on("cart_open", () => {});  // ok
on("cart_kick", () => {});  // error

EventName is now a fixed union of nine strings. The signature of on accepts only those nine, so a typo like cart_kick fails the check at compile time. When you add a new feature, you add it to Feature, and every combination it forms with Action is automatically valid.

This pattern shows up in router types too. A path like /users/:id/posts/:postId carries information about its parameters, and you can describe that shape with a template type.

route-params.ts
type Path = "/users/:id/posts/:postId";

// Match strings of the form /users/<something>/posts/<something>
type IsUserPostPath<P> = P extends `/users/${string}/posts/${string}`
? true
: false;

type A = IsUserPostPath<"/users/42/posts/9">; // true
type B = IsUserPostPath<"/posts/42">;         // false

The pattern with ${string} slots acts like a wildcard match — a primitive form of regex written in the type system.

Uppercase, Lowercase, and friends

TypeScript ships four built-in types that transform string literals: Uppercase<S>, Lowercase<S>, Capitalize<S>, and Uncapitalize<S>. They only work on strings, but combined with template types they turn into a tiny string-manipulation toolkit.

case.ts
type A = Uppercase<"hello">;   // "HELLO"
type B = Capitalize<"hello">;  // "Hello"
type C = Uncapitalize<"Hello">;// "hello"

type Setter<K extends string> = `set${Capitalize<K>}`;
type S = Setter<"name">;  // "setName"

Setter<"name"> builds the string setName — the kind of name a setter method would have. Combined with mapped types, this is how you generate getter/setter shapes from a data type.

Tip

These intrinsics are written in the compiler, not in TypeScript. You can't reimplement Uppercase in user code — there's no built-in CharCode or Index at the type level. Treat them as primitives.

Parsing strings at the type level

Combine template literals with infer and you can pull pieces back out of a string. This is how libraries like routers extract typed parameters from a path string.

parse-route.ts
type ExtractParam<Path> = Path extends `${string}:${infer P}/${string}`
? P
: never;

type A = ExtractParam<"/users/:id/profile">;  // "id"

The pattern says some prefix, then a colon, then capture letters until the next slash, then some suffix. infer P binds the captured chunk. With more infer slots you can pull out every parameter in a path, then build them into an object type.

route-params-deep.ts
type Params<Path> =
Path extends `${string}:${infer P}/${infer Rest}`
  ? { [K in P]: string } & Params<`/${Rest}`>
  : Path extends `${string}:${infer P}`
    ? { [K in P]: string }
    : {};

type R = Params<"/users/:id/posts/:postId">;
// { id: string } & { postId: string }

Two cases: colon followed by something else (recurse on the rest) and colon at the end (terminate). The recursion peels one parameter off at a time and merges the result with what came before. This is real type-level programming — readable once you've seen the building blocks.

check your understanding
Given type R = "post-2024-12" extends `post-${infer Year}-${infer Month}` ? [Year, Month] : never;, what is R?
Watch out

Template literal patterns are greedy in the order they're written. With multiple ${string} slots you can get surprising matches. Anchor with literal characters (like - or /) to make patterns unambiguous.

Try it yourself

check your understanding
What is type R = `${"a" | "b"}-${"x" | "y"}`?
check your understanding
Which definition produces a type matching any string ending with "_event"?
check your understanding
You have type Setter<K extends string> = `set${Capitalize<K>}`. What is Setter<"id" | "name">?
← prevnext lesson →
KeepLearningcertificate
for completing
TypeScript advanced types
0 of 8 read