Template Literal Types
Strings you can compute
Section titled “Strings you can compute”Template literal types look like JavaScript template strings, but they build types. You interpolate other types into a string-shaped type:
// @expect-error TS2322type Greeting = `Hello, ${string}`;
const a: Greeting = "Hello, Ada"; // ✅const b: Greeting = "Hi, Ada"; // ❌ must start with "Hello, "Interpolate a literal union and the template distributes over it — producing every combination as a union of literal types:
type Lang = "en" | "th";type Kind = "page" | "post";
type Key = `${Lang}-${Kind}`;// ^? "en-page" | "en-post" | "th-page" | "th-post"Two unions of two members give four literal types. This is how libraries generate exact string keys — CSS properties, event names, i18n keys — with zero runtime code.
The intrinsic string manipulators
Section titled “The intrinsic string manipulators”TypeScript ships four built-in types that transform string literal types: Uppercase, Lowercase, Capitalize, and Uncapitalize.
type Loud = Uppercase<"hello">; // "HELLO"type Cap = Capitalize<"name">; // "Name"
// Combine with a template to build event handler namestype Handler<E extends string> = `on${Capitalize<E>}`;type ClickHandler = Handler<"click">; // "onClick"flowchart LR u["union: en or th"] -->|"interpolate into 'X-page'"| out["en-page or th-page"]
Pattern matching with infer
Section titled “Pattern matching with infer”The real power arrives when you combine template literals with a conditional type and infer: you can destructure a string type, capturing part of it into a new type variable.
// Capture the event name after the "on" prefixtype EventName<T> = T extends `on${infer E}` ? E : never;
type E1 = EventName<"onClick">; // "Click"type E2 = EventName<"onHover">; // "Hover"type E3 = EventName<"click">; // never — no "on" prefix
// Split a route into its partstype Method<T> = T extends `${infer M} ${string}` ? M : never;type M = Method<"GET /users">; // "GET"infer E says “match anything here and call it E.” This is genuine parsing at the type level — the technique behind typed routers that extract :id params from a path string and libraries that validate format strings.
TypeScript 7 and Unicode
Section titled “TypeScript 7 and Unicode”Until TypeScript 6, infer inside a template literal split strings by UTF-16 code unit — the unit that JavaScript’s .length counts. That silently broke on emoji and other astral characters. TypeScript 7 splits by Unicode code point:
type HeadTail<S extends string> = S extends `${infer Head}${infer Tail}` ? [Head, Tail] : never;
type R = HeadTail<"😀abc">;// ^? TypeScript 7: ["😀", "abc"]// TypeScript 6: ["\ud83d", "\ude00abc"] — a surrogate pair cut in halfEvery recursive string type that peels one character at a time — a Split, a Reverse, a character counter — now sees whole characters. That is almost always what you wanted, but it is a breaking change if a type depended on the old count: a type-level “length” compared against runtime .length (which still counts code units) will disagree on "😀". If you maintain such a type, test it with non-BMP input when you upgrade.
Where this shows up
Section titled “Where this shows up”- Typed keys:
`${Theme}-${Shade}`for a design-token type. - Event maps: turning
"click"into"onClick"for prop types. - Route params: extracting
idfrom"/users/:id"into{ id: string }. - ORM/query builders: parsing a column string into a typed shape.
Use it where a string genuinely encodes structure. Reach for it too eagerly and you get types no teammate can read — the recurring trade-off of type-level programming.
Union cross-products can blow up: TS2590
Section titled “Union cross-products can blow up: TS2590”Each interpolated union multiplies the total number of literal combinations. Two unions of ten members each do not give you twenty possibilities — they give you a hundred, and the growth is multiplicative with every additional interpolation:
// @expect-error TS2590type Digit = "0" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9";type Big = `${Digit}${Digit}${Digit}${Digit}${Digit}`;Ten digits interpolated five times is 10 ** 5 = 100,000 distinct string literal types — enough to cross TypeScript’s union-size ceiling and produce TS2590: Expression produces a union type that is too complex to represent. Four repetitions (10,000 combinations) compiles fine; this is the smallest reproducible version of the error, found by counting: 10 ** n crosses 100,000 at n = 5. The lesson is not “avoid template literals with unions” — it is “know that the size is ∏ of each interpolated union’s member count, not a sum,” before reaching for a template literal type to encode something like every possible five-character alphanumeric code.
Suppressing distribution with [T], inside a template-adjacent conditional
Section titled “Suppressing distribution with [T], inside a template-adjacent conditional”A generic template literal type distributes over a union the same way any other conditional-adjacent construct does, when the type parameter is naked:
type StartsWithSlash<T extends string> = T extends `/${string}` ? true : false;
type Result = StartsWithSlash<"/users" | "profile">;// ^? boolean -- one branch per union member, unioned"/users" matches and gives true; "profile" does not and gives false; unioned, that is boolean — not a useful yes/no answer about the union as a whole. The same [T] extends [U] idiom from the Conditional Types lesson fixes it here too, testing the whole union against the template pattern in one shot instead of per member:
type Equal<A, B> = (<T>() => T extends A ? 1 : 2) extends (<T>() => T extends B ? 1 : 2) ? true : false;type Expect<T extends true> = T;
type StartsWithSlashAll<T extends string> = [T] extends [`/${string}`] ? true : false;
type NotAll = StartsWithSlashAll<"/users" | "profile">;type _notAll = Expect<Equal<NotAll, false>>;
type AllSlash = StartsWithSlashAll<"/users" | "/profile">;type _allSlash = Expect<Equal<AllSlash, true>>;StartsWithSlashAll answers “does every member of this union start with /,” which is a genuinely different (and sometimes more useful) question than “does this particular member start with /.”
infer inside a template, recursively: Split
Section titled “infer inside a template, recursively: Split”Combine infer inside a template pattern with recursion and you get real string parsing — the classic example is splitting a delimited string into a tuple of its parts, the type-level equivalent of "a,b,c".split(","):
type Split<S extends string, D extends string> = S extends `${infer Head}${D}${infer Rest}` ? [Head, ...Split<Rest, D>] : [S];
type R = Split<"a,b,c", ",">;// ^? ["a", "b", "c"]Each step captures everything before the next delimiter as Head and recurses on Rest; the base case (no more delimiter found) wraps whatever is left in a single-element tuple. Join is the mirror image — recursively concatenating a tuple of strings back into one, with a separator between each pair — and Trim is the same peel-one-character idea applied to leading/trailing spaces instead of a delimiter:
type TrimLeft<S extends string> = S extends ` ${infer Rest}` ? TrimLeft<Rest> : S;type TrimRight<S extends string> = S extends `${infer Rest} ` ? TrimRight<Rest> : S;type Trim<S extends string> = TrimLeft<TrimRight<S>>;
type T1 = Trim<" hello ">;// ^? "hello"Pattern-matching string keys into an event map
Section titled “Pattern-matching string keys into an event map”The mapped-type as remapping from the previous lesson and template literal interpolation combine naturally to build a typed event-handler map from a plain event-name-to-payload interface — a pattern you will recognize from every typed DOM or UI event system:
interface EventMap { click: MouseEvent; hover: PointerEvent;}
type OnHandlers<T> = { [K in keyof T as `on${Capitalize<string & K>}`]: (event: T[K]) => void;};
type Handlers = OnHandlers<EventMap>;// ^? { onClick: (event: MouseEvent) => void; onHover: (event: PointerEvent) => void }Capitalize<string & K> needs the string & because K ranges over keyof T, which is only known to be assignable to string | number | symbol in general — intersecting with string tells the compiler “and also treat it as a string for Capitalize’s purposes,” which is safe here because every key of EventMap actually is a string literal.
Template literals plus satisfies for a route table
Section titled “Template literals plus satisfies for a route table”A template literal type as the value type of a Record, combined with satisfies, validates every entry in a route table against a shape (starts with /) while keeping each individual route’s literal string type for later use — the same satisfies benefit from Literals & Widening, applied to a whole table at once:
const routes = { home: "/", userProfile: "/users/:id", postDetail: "/posts/:id/comments/:commentId",} satisfies Record<string, `/${string}`>;
// Each entry keeps its own literal type, not the widened `` `/${string}` `` template.type HomeRoute = typeof routes.home;// ^? "/"If any route value were changed to, say, "users/:id" (missing the leading slash), the satisfies check against that template-literal-valued Record would catch it immediately — a plain : Record<string, string> annotation would not, and an as cast would not either.
Union cross-products, [T] suppression, and recursive Split with infer
type Equal<A, B> = (<T>() => T extends A ? 1 : 2) extends (<T>() => T extends B ? 1 : 2) ? true : false;type Expect<T extends true> = T;
// distribution gives boolean for "does this member start with /"type StartsWithSlash<T extends string> = T extends `/${string}` ? true : false;type Result = StartsWithSlash<"/users" | "profile">;
// [T] suppression answers "does the WHOLE union start with /" insteadtype StartsWithSlashAll<T extends string> = [T] extends [`/${string}`] ? true : false;type NotAll = StartsWithSlashAll<"/users" | "profile">;type _notAll = Expect<Equal<NotAll, false>>;
// recursive infer inside a template: Splittype Split<S extends string, D extends string> = S extends `${infer Head}${D}${infer Rest}` ? [Head, ...Split<Rest, D>] : [S];
type R = Split<"a,b,c", ",">;type _splitCheck = Expect<Equal<R, ["a", "b", "c"]>>;
// event map built from mapped-type key remapping + template literalinterface EventMap { click: MouseEvent; hover: PointerEvent; }type OnHandlers<T> = { [K in keyof T as `on${Capitalize<string & K>}`]: (event: T[K]) => void };type Handlers = OnHandlers<EventMap>; Mental Model
Section titled “Mental Model”flowchart TD
interp["Each interpolated union position"] --> mult["Multiplies the total combination count"]
mult --> limit{"Total combinations over ~100,000?"}
limit -->|yes| ts2590["TS2590: too complex to represent"]
naked["Naked T checked against a template pattern"] --> perMember["Distributes: one branch per union member"]
wrapped["[T] extends [pattern]"] --> whole["Tests the whole union as one type -- no distribution"] Think of every interpolated position in a template literal type as a multiplication, not an addition — the same mental model that explains why TS2590 shows up sooner than intuition suggests. Treat a conditional built around a template pattern exactly like any other conditional: naked distributes per member, [T] tests the whole union at once — the mechanism has nothing to do with strings specifically, it is the same distribution rule from Conditional Types, just with a template pattern as the U side.
Trade-offs
Section titled “Trade-offs”| Choice | Benefit | Cost |
|---|---|---|
| Interpolating multiple unions in one template | Generates every valid combination automatically — no manual enumeration | Combination count multiplies, not adds — a handful of medium unions can hit TS2590 far sooner than expected |
Naked T against a template pattern | Answers “does this member match” per member, useful for filtering a union | Distributes into a boolean when you actually wanted one whole-union answer |
[T] extends [pattern] | Answers “does the whole union match,” a single decisive answer | Loses the per-member breakdown — if you need both, you need both forms |
Recursive infer inside a template (Split, Trim) | Real string parsing, evaluated entirely at compile time | Reintroduces the recursion-depth ceiling from the Recursive Types lesson for very long strings |
Common Misconceptions
Section titled “Common Misconceptions”- Interpolating two unions of ten members each gives twenty possible strings. It gives
10 × 10 = 100— union sizes multiply across interpolated positions, not add, which is exactly whyTS2590can appear with surprisingly modest-looking unions. - A generic template literal type conditional like
T extends `/${string}` ? true : falsealways tells you about the union as a whole. It distributes over a nakedT, producing one branch per member unioned together — for a mixed union that isboolean, not a single meaningful answer.[T] extends [pattern]is the fix when you want the whole-union question instead. Capitalize<K>whereKiskeyof Tneeds no extra work.keyof Tis only known to be assignable tostring | number | symbolin general, and the string-manipulation intrinsics require an actualstring-assignable type —string & K(or aK extends stringconstraint) is what makes the compiler accept it.
Reading AI-generated code
Section titled “Reading AI-generated code”- A generated type that interpolates several small-looking unions to build a “complete” enumeration of valid strings (product codes, permission strings, hex-like identifiers) is a
TS2590risk that will not show up in a quick manual review of the union member counts — multiply them before assuming it is safe. - A conditional built on a template pattern (
T extends `prefix-${string}` ? ... : ...) applied to a union and expected to gate “is this whole list of things okay” is a common AI-generated footgun: it distributes and producesboolean, silently accepting the call site instead of rejecting a union with one bad member, becausebooleanis truthy-shaped enough to type-check against most surrounding code without complaint. - A
Record<string, T>used for a route/permission/config table with nosatisfiesanywhere loses the exact literal type of every entry and validates nothing about its shape — if the code later relies on a specific route string type (or forgets to add the leading/), nothing in the type system will catch it.
🐛 Spot the bug
type ApiRoute = "/users" | "/posts" | "/comments";
type IsValidRoute<T extends string> = T extends ApiRoute ? true : false;
// "validate" that every route a client requests is one of our known routesfunction checkRoutes<T extends string>(routes: T[]): IsValidRoute<T> {return routes.every((r) => (["/users", "/posts", "/comments"] as string[]).includes(r)) as IsValidRoute<T>;}
const result = checkRoutes(["/users", "/unknown"]);Show the answer
IsValidRoute<T> distributes over T because T is naked on the checked side, so for T = "/users" | "/unknown" it evaluates to true | false — plain boolean — not a single decisive answer about whether the whole call is valid. The function’s return type ends up boolean for this call no matter what, so result type-checks fine either way, and the as IsValidRoute<T> cast at the end silences any mismatch between the real runtime check (.every(...), which does compute one true/false answer for the whole array) and what the type system believes the return type is. Fixing the type to match the intent — one decisive answer for the whole array — means wrapping the checked side: type IsValidRoute<T extends string> = [T] extends [ApiRoute] ? true : false;. With that fix, IsValidRoute<"/users" | "/unknown"> correctly resolves to the single literal false (since "/unknown" is not part of ApiRoute), matching what .every(...) actually computes at runtime instead of silently producing an always-boolean type that the as cast was hiding.