Lesson 53 of 80
Template literal types build new string types; combined with recursive conditional types and the four intrinsic string types, you can parse, transform, and validate string literal types at compile time — not just interpolate them.
15 min read
Every option explains itself — why the right one is right, and why each wrong one is wrong. Miss one and it comes back later on a spaced-repetition schedule.
This topic is about judgment and practical tradeoffs more than a single right answer — a mock interview is the better way to test yourself on it.
Be interviewed on this →Hints without spoilers, and a plain explanation of why a wrong answer was wrong — answered only from this lesson. Free account, no card.
Sign up freeWhat interviewers actually ask about this, and what a strong answer has to cover.
A strong answer covers
Likely follow-ups
The template literal types lesson covered building new string unions by interpolating — `margin-${Direction}`. That's construction. This lesson is about the other half: taking a string literal type apart, recursively, the same way you'd write a parser over runtime strings, except the "parser" runs entirely in the type checker and produces types, not values.
The tool for taking a template literal type apart is pattern matching with infer inside a conditional type:
type FirstChar<S extends string> = S extends `${infer First}${string}` ? First : never;
type A = FirstChar<"hello">; // "h"
type B = FirstChar<"">; // never
`${infer First}${string}` says "match a string that starts with some character, call it First, followed by anything." TypeScript tries to fit "hello" against that pattern; First gets inferred as "h". This is structurally the same infer you've already used in other conditional types — just matching against a string pattern instead of a function signature or array shape.
type Split<S extends string, Delim extends string> =
S extends `${infer Head}${Delim}${infer Tail}`
? [Head, ...Split<Tail, Delim>]
: [S];
type Parts = Split<"user.address.city", ".">;
// ["user", "address", "city"]
Each recursive step peels off everything before the next delimiter (Head), keeps the remainder (Tail), and recurses on Tail until there's no more delimiter to find — at which point the base case [S] wraps whatever's left. The result is a real tuple type, each element still a distinct string literal, computed entirely from the shape of the input string. This is precisely how libraries type a dot-path accessor (get(obj, "user.address.city")) against a nested object type, catching a typo'd path segment at compile time instead of undefined at runtime.
Uppercase<"a" | "b"> is "A" | "B", not a single combined type — each member of the union is transformed independently, the same distribution behavior conditional types have.Split<"a.b.c", "."> computes a type; if you also need the actual split array as a value, you still write "a.b.c".split(".") in real code — the type-level version exists to check that your runtime logic and its declared type stay in sync, not to replace the runtime logic."How would you type a function that only accepts dot-path strings valid for a given nested object type, like get(obj, \"user.address.city\"), and rejects a typo'd path at compile time?" is a strong exercise that requires exactly this: recursive template literal pattern matching walking both the string and the object type in parallel, one segment at a time.