Lesson 22 · Module 3.5 — Type-level programming

Template-literal types: parse a string with infer and recursion

Template-literal types let a type look like a string with holes: `${A}-${B}`. Drop infer into those holes and the type system becomes a tiny pattern-matcher — it can split a string and capture the pieces. Add recursion and you have a parser that pulls every {param} out of a path, entirely at compile time.

The one rule

A template-literal pattern with infer — like `${string}{${infer Name}}${infer Rest}` — matches a string and binds each infer hole to the text that filled it. Name gets the part inside the braces; Rest gets everything after, ready to feed back into the same type.

One match: capture a placeholder's name

Start with a single step. The pattern below says “some prefix, then {…}, then a tail.” The infer holes name the captured pieces:

type FirstParam<S extends string> =
  S extends `${string}{${infer Name}}${infer Rest}` ? Name : never;

type A = FirstParam<"/users/{id}/posts">;
// Name  = "id",  Rest = "/posts"  →  resolves to "id"

Read the pattern left to right: ${string} soaks up "/users/", the literal { and } bracket the placeholder, infer Name grabs "id", and infer Rest grabs "/posts". The conditional then returns Name. We threw Rest away here — but recursion will reuse it.

Recurse: collect every placeholder into a tuple

To gather all params, return Name and call the type again on Rest. A type calling itself is recursion; a base case stops it when no braces remain:

type GetParams<S extends string> =
  S extends `${string}{${infer Name}}${infer Rest}`
    ? [Name, ...GetParams<Rest>]   // found one → keep it, recurse on the tail
    : [];                          // no more braces → stop, empty tuple

type P = GetParams<"/users/{id}/posts/{postId}">;
// resolves to:  ["id", "postId"]

The ...GetParams<Rest> is a spread inside a tuple type: it splices the recursive result onto the front element. Each call peels one placeholder and hands the rest back to itself.

Trace it by hand

Reading recursion means unrolling it. Substitute the string at each step until the base case fires:

GetParams<"/users/{id}/posts/{postId}">
  // match: Name="id",  Rest="/posts/{postId}"
  => ["id", ...GetParams<"/posts/{postId}">]
    // match: Name="postId", Rest=""
    => ["id", "postId", ...GetParams<"">]
      // "" has no braces → base case → []
      => ["id", "postId"]

Each line drops one placeholder and shortens the string until nothing matches and [] closes the tuple. That is the whole technique: a template-literal pattern to split, infer to capture, and recursion on the tail to keep going.

Why the base case matters

Without the : [] fallback the recursion would have no way to stop, and TypeScript would refuse it with a “type instantiation is excessively deep” error. Every type-level recursion needs a branch where the pattern fails and returns a concrete result.

Check yourself

Recall, don’t re-read. (Answers reveal on click.)

In S extends `${string}{${infer Name}}${infer Rest}`, what does Rest capture?
What is the purpose of the : [] branch in the recursive GetParams?
What does GetParams<"/a/{x}/b/{y}"> resolve to?

Primary source

The official handbook page is TypeScript Handbook — Template Literal Types; the inference-within-template section pairs it with Inferring Within Conditional Types. Best way to feel it: paste GetParams into the TS Playground and hover the result as you add more {…} segments to the input string.

Try it yourself

Stuck unrolling a recursive type? Take it with a sample input and trace it one step at a time, substituting the string at each call until the base case fires. Next lesson (3.6) closes the module with a single dense idiom — turning a record of payloads into a discriminated union.