strings the compiler can read
template literal types let you compose string types the same way
template strings compose string values. the syntax is identical — a
backtick string with $… holes — but inside the
holes you put types, not values. the result is a type
representing every concatenation that fits.
type Greeting = \`hello, ${string}\`;
const a: Greeting = "hello, world"; // OK
const b: Greeting = "hello, sadi"; // OK
const c: Greeting = "hey there"; // type error
type Color = "red" | "green" | "blue";
type Shade = "light" | "dark";
type Token = \`${Shade}-${Color}\`;
// ^? "light-red" | "light-green" | "light-blue"
// | "dark-red" | "dark-green" | "dark-blue" when a hole has a literal union, the template literal type is the cartesian product. two-by-three becomes six. the compiler enumerates them. nothing exotic — it's the same combinatorial expansion you'd write by hand, except you don't have to.
route patterns
the obvious use case. you have a router that wants a string like
/users/:id/orders/:orderId and you'd like the compiler to
know that handlers for that route receive { id, orderId }
as parsed params. template literal types — combined with conditional
types and infer from lesson 4 — make this a one-liner.
type Params<T extends string> =
T extends \`${string}:${infer P}/${infer Rest}\`
? { [K in P | keyof Params<\`/${Rest}\`>]: string }
: T extends \`${string}:${infer P}\`
? { [K in P]: string }
: {};
type R1 = Params<"/users/:id">;
// ^? { id: string }
type R2 = Params<"/users/:id/orders/:orderId">;
// ^? { id: string; orderId: string }
function get<T extends string>(route: T, handler: (p: Params<T>) => void) { /* … */ }
get("/users/:id/orders/:orderId", ({ id, orderId }) => { /* … */ });
// ^^^^^^^^^^^^ both typed string
that pattern uses infer to pull the segment after each
colon out as a type variable, then recurses on the rest. the
compiler runs the recursion at compile time. your route table
has types.
event names and case transforms
typescript ships four intrinsic helpers that act on template literal
types: Uppercase, Lowercase,
Capitalize, Uncapitalize. combine them with
holes and you can express event-name conventions like dom uses.
type EventNames = "click" | "focus" | "submit";
type Handlers = {
[K in EventNames as \`on${Capitalize<K>}\`]: (e: Event) => void;
};
// ^? { onClick: (e: Event) => void;
// onFocus: (e: Event) => void;
// onSubmit: (e: Event) => void }
the as clause in the mapped type (lesson 5 covers this in
full) remaps each key through the template. one line of types
produces the entire on* handler interface — and adding
an event to the union adds a handler.
check yourself
template literals — 4 questions
trace the literal types. don't worry about Params recursion — focus on the simple holes.
type Size = "sm" | "lg";
type Color = "red" | "blue";
type Class = `btn-${Size}-${Color}`;
// ^?what is `Class`?
type Action = "create" | "delete";
type Verb = `do_${Uppercase<Action>}`;
// ^?what is `Verb`?
type Hex = `#${string}`;
function paint(c: Hex) { /* … */ }
paint("#fff"); // ?
paint("white"); // ?
paint("fff"); // ?
paint(`#${"abc"}`); // ?which of the four calls compiles?
type Prefix<T> = T extends `${infer P}_${string}` ? P : never;
type X = Prefix<"user_id">;
// ^?
type Y = Prefix<"orderId">;
// ^?what are `X` and `Y`?
where the limits are
template literal types can recurse — that's how Params
above works — but the compiler caps recursion depth (currently around
50 levels). long paths and deep parsers will hit it. for production
code, this means: yes for route patterns, yes for simple parsers, no
for trying to build a full regex engine in the type system. the
fallback is always a runtime parser and a hand-typed return shape.
three concrete uses
tabs across three places template literals earn their seat.
type Space = 0 | 1 | 2 | 4 | 8 | 16;
type Side = "t" | "r" | "b" | "l";
type Margin = \`m${Side}-${Space}\`;
const ok: Margin = "mt-4";
const no: Margin = "mt-3"; // type error: 3 not in Space// Margin =
// "mt-0" | "mt-1" | "mt-2" | "mt-4"
// "mt-8" | "mt-16"
// ... (× 4 sides)
//
// 24 valid strings, every other rejected.your design system, encoded. typos and off-by-one spacing values become compile errors.
type Entity = "user" | "order" | "invoice";
type Verb = "created" | "updated" | "deleted";
type Topic = \`${Entity}.${Verb}\`;
function publish(topic: Topic, body: unknown) { /* ... */ }
publish("user.created", {}); // OK
publish("user.archived", {}); // type error
publish("customer.created", {}); // type error// Topic = 3 × 3 = 9 strings:
// "user.created" | "user.updated"
// | "user.deleted" | "order.created"
// | ... | "invoice.deleted"typos in event topics are the silent reliability killer of pub/sub systems. cartesian unions catch them at the call site.
type ParseParams<T> =
T extends \`${string}:${infer P}/${infer R}\`
? P | ParseParams<\`/${R}\`>
: T extends \`${string}:${infer P}\`
? P
: never;
type R = ParseParams<"/u/:id/o/:oid">;
// ^?// R = "id" | "oid"
//
// then wrap with mapped types:
// type Params<T> = { [K in ParseParams<T>]: string }
// -> { id: string; oid: string }real router libraries (zod, hono, ts-rest) use this exact pattern in production. it's the test case the feature was built for.
when not to reach for it
template literal types are computed at compile time. every union
member is enumerated, every recursion is unrolled. that's fine for
small alphabets (a dozen sides × spaces × variants) but expensive
for big ones. if your union has thousands of members, expanding it
through a template will slow tsc to a crawl. when that happens, fall
back to a plain string typed-newtype with a brand
(lesson 2), or assert at the boundary with a runtime regex.
what you took away
- template literal types compose like template strings, but the compiler enumerates the cartesian product at compile time.
-
pair them with
infer(next lesson) and you can parse structured strings — route patterns, event topics, color tokens — and recover their parts as types. -
four built-in case transforms (
Uppercase,Lowercase,Capitalize,Uncapitalize) let you express naming conventions directly. - the limit is recursion depth and union size. for big alphabets, switch to brands. for small ones, template literals are the sharpest tool in the box.
further reading
- typescript handbook · template literal types — the canonical spec, including the four intrinsics.
- /posts/ · template literal types, in practice — the long-form article on real-world uses.
- github · type-challenges — exercises if you want to drill template-literal parsing.