15.4 Parsing String Patterns with Template Literal Types and infer
Right, so you’ve mastered the basics of template literal types. You can combine a few strings and unions and feel pretty clever. "user-" | "admin-" and all that. Good for you. Now, let’s get to the part that separates the novices from the wizards: actually parsing and extracting information from string patterns. This is where we stop just describing strings and start interrogating them. It feels a bit like magic, and frankly, it’s one of the coolest party tricks in the TypeScript type system.
The secret weapon here is combining template literal types with the infer keyword. You’ve seen infer in conditional types, probably with function parameters or return types. Well, it turns out infer is just as happy to work inside a string pattern. It’s the digital equivalent of saying, “Alright, string, you look like "Hello, [name]". I don’t know what [name] is yet, but I’m going to grab it and use it as a type.”
The Basic Anatomy of String Inference
Let’s start with a classic example. Imagine you have a route string and you want to extract the dynamic parameter from it.
type ExtractRouteParams<T extends string> =
T extends `${string}:${infer Param}`
? Param
: never;
// Let's see it in action:
type Example1 = ExtractRouteParams<"user/:id">; // type is "id"
type Example2 = ExtractRouteParams<"post/:postId/author/:authorId">; // type is "postId"
Wait, what? Why is Example2 just "postId" and not "postId/author/:authorId"? This is the first critical thing to understand: infer matches greedily until the end of the string unless you constrain it. Our pattern ${string}:${infer Param} means “match any prefix (${string}), then a colon, and then infer everything that’s left as Param”. It doesn’t know or care about subsequent slashes.
To get all params, we need recursion. But let’s walk before we run.
Constraining the Inference with Delimiters
To make this useful, we need to tell infer where to stop. We do this by putting the delimiter after the infer keyword. Think of it as the pattern the inferred part must be followed by.
type ExtractRouteParamsProperly<T extends string> =
T extends `${string}:${infer Param}/${infer Rest}`
? Param | ExtractRouteParamsProperly<`${Rest}`>
: T extends `${string}:${infer Param}`
? Param
: never;
type Example3 = ExtractRouteParamsProperly<"post/:postId/author/:authorId">;
// type is "postId" | "authorId"
Now we’re talking. Let’s break down the logic:
- First condition: Does the string match the pattern
stuff:Param/Rest? If yes, then we yieldParamand recursively process theRest. - Second condition: If there’s no more slash, does it match
stuff:Param? If yes, yield that finalParam. - Fallback: If neither pattern matches, it’s
never.
This is the core of almost every parsing operation. You define the structure you expect, using literal characters (like / or -) to box in the part you want to infer.
Beyond Routes: A Practical Example with API Endpoints
Let’s say you’re dealing with a wonky API where the response type is embedded in the endpoint path. A real-world example? Probably not. A fantastic way to learn? Absolutely.
type GetResponseType<T extends string> =
T extends `/api/${infer Entity}/${string}`
? Entity extends 'user'
? User
: Entity extends 'product'
? Product
: never
: never;
interface User { name: string; id: number; }
interface Product { title: string; price: number; }
// Usage:
declare function fetchEndpoint<T extends string>(path: T): GetResponseType<T>;
const userData = fetchEndpoint('/api/user/123'); // type: User
const productData = fetchEndpoint('/api/product/abc'); // type: Product
const badData = fetchEndpoint('/api/order/123'); // type: never
This is incredibly powerful. The function’s return type is now directly tied to the static analysis of the string literal you pass in. The runtime doesn’t know this is happening; it’s all happening in the type system at compile time. It’s like getting free, type-safe validation for the cost of a few lines of type code.
The Rough Edges and Pitfalls
This isn’t all rainbows and unicorns. The type system isn’t a full-fledged parser, and it will fight you on complex patterns.
- No Negative Lookahead: You can’t express “infer until you find a character that isn’t X”. The patterns are positive matches only.
- Greediness is Default: As we saw initially,
inferwill grab the whole string unless you explicitly box it in with a literal. Always constrain your inference. - Performance on Long Strings: Deeply recursive types on very long string literals can sometimes hit performance limits or even “expression too complex” errors. It’s rare, but it happens. If you’re trying to parse a full SQL query… maybe don’t.
- It Only Works on Literals: This magic only works with string literal types. If you pass a value of type
string(a wide, dynamic string), the type will fall back to its base constraint (likeneverin our examples) because there’s no literal pattern for TypeScript to inspect.
The power here is genuinely absurd. You’re effectively building a mini-parser in your type definitions. It’s a testament to the flexibility of TypeScript’s type system. Use it to make your APIs bulletproof, to generate complex derived types, and to generally write code that’s so type-safe it makes other developers slightly nervous. Just remember the constraints, and you’ll be fine. Now go parse something.