open atlas
↑ Back to track
TypeScript type system, deep TS · 04 · 05

Template literal types: compute, expand, and parse string types

Template literal types build string types by interpolation. Interpolating unions yields the full cross product — a combinatorial blowup. Intrinsics transform case, and `infer` inside a pattern parses strings. Pattern inference is non-greedy at the first delimiter.

TS Senior ◷ 17 min
Level
FoundationsJuniorMiddleSenior

You write app.get("/users/:id", handler) and your editor types req.params as { id: string } — it read the colon-segment out of the route string at the type level. Or you emit a CSS-in-JS theme and "colors.brand.primary" autocompletes from a nested object. Both are template literal types: string types you can interpolate into, expand across unions, and — with infer inside the pattern — parse character by character. TypeScript turned the type system into a tiny string processor.

By the end you’ll understand both sides of that power: how to extract route params with infer inside a pattern, and exactly when interpolating one union too many blows the compiler’s 100,000-member cap.

Interpolation and the union cross product

Why bother generating string types instead of just listing them? Because the set of valid strings often grows with your data model — a locale like "en-US" should be derivable from Lang and Region types you already maintain, so adding "fr" to Lang automatically produces "fr-US" and "fr-GB" without touching the locale type.

A template literal type looks like a JS template string but operates on types. Interpolating a single literal is unsurprising; interpolating a union expands into every combination — the full cross product:

type Greeting = `Hello, ${string}!`;     // any "Hello, ...!" string
type Hi = Greeting & "Hello, Sam!";      // ^? "Hello, Sam!"  (it's a member)

type Lang = "en" | "ru";
type Region = "US" | "GB";
type Locale = `${Lang}-${Region}`;
// ^? "en-US" | "en-GB" | "ru-US" | "ru-GB"   — 2 x 2 = 4 members

Each interpolation slot multiplies the member count. Two 2-member unions give 4; add a third 3-member union and you get 12; the growth is multiplicative. This is the headline pitfall: a template over several big unions can generate thousands of members and slow or stall the checker.

Common mistake

The cross product is the most common way to blow up compile time with template literals. `${A}${B}${C}` where each is a 50-member union is 50 x 50 x 50 = 125,000 string literals — TypeScript caps unions (currently ~100k members) and will error with “Expression produces a union type that is too complex to represent” beyond that, or simply crawl. Keep interpolated unions small, or push the work into a parser (infer) instead of generating the whole space.

Intrinsic case manipulators

Four built-in (compiler-implemented) string types transform case, with no library definition you could write yourself:

type U = Uppercase<"hello">;     // ^? "HELLO"
type L = Lowercase<"HELLO">;     // ^? "hello"
type C = Capitalize<"hello">;    // ^? "Hello"
type N = Uncapitalize<"Hello">;  // ^? "hello"

These are the pieces behind key renaming (get${Capitalize<K>} from last lesson) and typed event-name builders.

Parsing with infer inside a template pattern

The real power: put infer inside a template literal in a conditional’s extends clause to pull substrings out. To extract a route parameter:

type RouteParam<S extends string> =
  S extends `${string}:${infer Param}/${string}`
    ? Param
    : S extends `${string}:${infer Param}`
      ? Param
      : never;

type P1 = RouteParam<"/users/:id">;        // ^? "id"
type P2 = RouteParam<"/users/:id/posts">;  // ^? "id"

For multiple params you recurse, collecting each into a union — the building block for fully typed routers. Splitting a delimited string is the same idea:

type Split<S extends string, D extends string> =
  S extends `${infer Head}${D}${infer Tail}`
    ? [Head, ...Split<Tail, D>]
    : [S];

type Parts = Split<"a-b-c", "-">;
// ^? ["a", "b", "c"]

Inference is non-greedy at the first delimiter

When a pattern has ${infer Head}<delimiter>${infer Tail}, Head matches as little as possible — up to the first occurrence of the delimiter, not the last:

type FirstSeg<S extends string> =
  S extends `${infer Head}-${infer Tail}` ? [Head, Tail] : [S];

type R = FirstSeg<"a-b-c">;
// ^? ["a", "b-c"]   — Head stops at the FIRST '-', Tail takes the rest

So Split above peels one segment per recursion from the left. If you assumed Head would greedily swallow "a-b", the resolved type would surprise you — the non-greedy, leftmost-delimiter rule is what makes left-to-right recursive parsing work.

The cost side: a parser in the type system is a parser you can’t debug

Template literal types are the point where TypeScript stops being a type-checker and starts being a string-processing language — and that is exactly where to be most disciplined. The cross-product cost is real and steep: a single template that interpolates four segments where each ranges over the alphabet is 26⁴457,000 members, far past the 100,000-member cap, so tsc either errors with Expression produces a union type that is too complex to represent or, with recursion in the mix, exhausts the heap and OOMs (Node’s default 2–4 GB) outright. Even short of the cap, a recursive Split/parser over a long string is non-tail recursion that hits the instantiation-depth wall (50 deep) and falls back to string, silently discarding the precision you wrote the type for.

The senior breakpoint: a parsed template type buys end-to-end string safety — the :id in a route is checked against the handler’s req.params with no runtime cost and no duplicated declaration. That is worth a lot for a library boundary where the string format is the API (a router, a typed i18n key, an SQL-ish DSL). It is rarely worth it inside application code: a 40-line recursive template parser is unreadable to the next engineer, produces error messages that point at the type machinery rather than their mistake, and taxes every editor keystroke. When you find yourself parsing a non-trivial grammar at the type level, that is the signal to move it to a build/codegen step — generate the route map, the i18n key union, or the query types as flat named types from the source of truth. The generated type Routes = "/users/:id" | ... is greppable, hover-clean, and compiles instantly; the recursive template that derives it is a parser no debugger can step through. Reach for templates when the string itself is the contract; reach for codegen when the string needs parsing.

lesson.inset.warning

The failure mode is worse than slow — it is silent imprecision. A recursive template parser that exceeds the ~50-deep instantiation limit doesn’t error; it quietly resolves to string, so a route typed RouteParams<"/a/:x/b/:y/c/:z/..."> past the limit gives you string for the param object and every call site silently loses its check. Catch it with tsc --extendedDiagnostics: a template-heavy file pushing Instantiation count into the millions and Check time up by 5–20s is the tell, and the editor degrades in lockstep — hovers and autocomplete on the affected file drop from instant to multi-second because IntelliSense runs the same instantiation to render the tooltip. When you see that, cap the recursion depth or move the parse to codegen before it ships.

Quiz

What does FirstSeg<'a-b-c'> resolve to, where FirstSeg<S> = S extends `${infer Head}-${infer Tail}` ? [Head, Tail] : [S]?

Order the steps

Order how TypeScript resolves the Locale type `${Lang}-${Region}` for Lang = 'en' | 'ru' and Region = 'US' | 'GB':

  1. 1 Identify the two interpolation slots: the Lang union and the Region union
  2. 2 Distribute the first slot: produce 'en-...' and 'ru-...' branches
  3. 3 For each, distribute the second slot across 'US' and 'GB'
  4. 4 Collect the cross product into 'en-US' | 'en-GB' | 'ru-US' | 'ru-GB'
Recall before you leave
  1. 01
    What happens when you interpolate unions into a template literal type, and why is it a performance pitfall?
  2. 02
    What are the four intrinsic string types, and how do you parse a route param or split a string with infer inside a template?
  3. 03
    Is template-pattern inference greedy or non-greedy, and what does FirstSeg<'a-b-c'> resolve to for FirstSeg<S> = S extends `${infer Head}-${infer Tail}` ? [Head, Tail] : [S]?
Recap

Template literal types completed the type-system-deep toolkit: you can now compute string types by interpolation, you respect the multiplicative cross-product blowup, and you can parse strings with infer inside a pattern under the non-greedy first-delimiter rule. Conditionals, infer, mapped types, key remapping, and templates are the five primitives that make types programmable. Next unit, type-level programming, composes them into recursion, distributive logic, utility types from scratch, and even type arithmetic — and revisits the performance limits these primitives can hit at scale. Now when you see a router type that autocompletes :id from the path string, you’ll know it is infer inside a template pattern — and you’ll know to check tsc --extendedDiagnostics before shipping any recursive version of it.

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 7 done
Connected lessons

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

Apply this

Put this lesson to work on a real build.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources2
expand
  1. 01
  2. 02

Trademarks belong to their respective owners. Editorial reference only.