Template literal types
Strings as types, joined like template strings. Use them for event names, route params, and CSS keys.
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.
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.
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.
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.
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", () => {}); // errorEventName 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.
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">; // falseThe 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.
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.
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.
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.
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.
type R = "post-2024-12" extends `post-${infer Year}-${infer Month}` ? [Year, Month] : never;, what is R?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
type R = `${"a" | "b"}-${"x" | "y"}`?"_event"?type Setter<K extends string> = `set${Capitalize<K>}`. What is Setter<"id" | "name">?