Skip to content

Template Literal Types

Template literal types look like JavaScript template strings, but they build types. You interpolate other types into a string-shaped type:

// @expect-error TS2322
type 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.

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 names
type 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"]
A union distributes across a template literal type

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" prefix
type 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 parts
type 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.

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 half

Every 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.

  • Typed keys: `${Theme}-${Shade}` for a design-token type.
  • Event maps: turning "click" into "onClick" for prop types.
  • Route params: extracting id from "/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.

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 TS2590
type 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 /" instead
type 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: 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", ",">;
type _splitCheck = Expect<Equal<R, ["a", "b", "c"]>>;
// event map built from mapped-type key remapping + template literal
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>;
Open in TS Playground
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"]
A template literal's union size multiplies; [T] wrapping tests the whole union at once

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.

ChoiceBenefitCost
Interpolating multiple unions in one templateGenerates every valid combination automatically — no manual enumerationCombination count multiplies, not adds — a handful of medium unions can hit TS2590 far sooner than expected
Naked T against a template patternAnswers “does this member match” per member, useful for filtering a unionDistributes into a boolean when you actually wanted one whole-union answer
[T] extends [pattern]Answers “does the whole union match,” a single decisive answerLoses 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 timeReintroduces the recursion-depth ceiling from the Recursive Types lesson for very long strings
  • 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 why TS2590 can appear with surprisingly modest-looking unions.
  • A generic template literal type conditional like T extends `/${string}` ? true : false always tells you about the union as a whole. It distributes over a naked T, producing one branch per member unioned together — for a mixed union that is boolean, not a single meaningful answer. [T] extends [pattern] is the fix when you want the whole-union question instead.
  • Capitalize<K> where K is keyof T needs no extra work. keyof T is only known to be assignable to string | number | symbol in general, and the string-manipulation intrinsics require an actual string-assignable type — string & K (or a K extends string constraint) is what makes the compiler accept it.
  • 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 TS2590 risk 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 produces boolean, silently accepting the call site instead of rejecting a union with one bad member, because boolean is truthy-shaped enough to type-check against most surrounding code without complaint.
  • A Record<string, T> used for a route/permission/config table with no satisfies anywhere 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 routes
function 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.

What is the type ${Lang}-${Kind} where Lang = "en" | "th" and Kind = "a" | "b"?
What does Capitalize<"click"> produce?
In T extends \on${infer E}\ ? E : never, what is infer E doing?
In TypeScript 7, what is HeadTail<"😀abc"> when HeadTail peels the first character with ${infer Head}${infer Tail}?
What does EventName<"click"> return if EventName<T> = T extends \on${infer E}\ ? E : never?
Why does this fail to compile?
type Digit = "0" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9";
type Big = `${Digit}${Digit}${Digit}${Digit}${Digit}`;
What is R, and how would you make it answer "does the whole union match" instead?
type StartsWithSlash<T extends string> = T extends `/${string}` ? true : false;
type R = StartsWithSlash<"/users" | "profile">;