skip to content

In TypeScript, what does `type Tail<S> = S extends `${infer Head}-${infer Rest}` ? Rest : never` give for `Tail<'a-b-c'>`, and what matching rule explains it?

level: seniorimportance: should knowfreq 35%

answer

  1. the leftmost separator wins
  2. the trailing hole takes everything
  3. two holes together peel one character
  4. recurse to reach the far end
  5. plain string matches nothing

basics

~10 s

It gives "b-c". A placeholder followed by literal text matches as little as possible, so Head captures "a" at the first hyphen and Rest absorbs everything after it, separators included.

solid answer

~40 s

`Tail<"a-b-c">` is `"b-c"`. When the compiler matches a string literal type against a template pattern, a placeholder followed by literal text stops at the **first** occurrence of that text, so `Head` takes `"a"` and `Rest` takes the whole remainder `"b-c"` — the trailing placeholder is unconstrained and swallows the rest, including further hyphens. Two related rules complete the picture. Two adjacent placeholders with nothing between them split off a single character: `` S extends `${infer H}${infer T}` `` gives `H = "a"`, `T = "bc"` for `"abc"`, which is the basis of recursive character-by-character parsing. And a non-literal input never matches: `Tail<string>` takes the false branch and yields `never`, because `string` is not assignable to the pattern. This is how route-parameter extraction, dotted-path helpers and delimiter splitting are written at the type level.

code

typescript · 13 lines
typescript
type Tail<S> = S extends `${infer Head}-${infer Rest}` ? Rest : never;

type A = Tail<"a-b-c">; // "b-c"  first hyphen wins, rest is swallowed
type B = Tail<"abc">;   // never   no separator to align with
type C = Tail<string>;  // never   too imprecise to match

// Adjacent placeholders peel exactly one character
type First<S> = S extends `${infer H}${infer T}` ? H : never;
type D = First<"abc">;  // "a"

// Recursing from the left reaches the final segment
type Last<S extends string> = S extends `${string}-${infer R}` ? Last<R> : S;
type E = Last<"a-b-c">; // "c"

go deeper

for a junior

Recognise that a template literal type on the right of extends is a pattern and that infer names the captured piece. Being able to read the type aloud is enough at this level.

for a middle

Explain first-match splitting, what the trailing placeholder captures, and the single-character peel from adjacent placeholders. Show the recursion that reaches the last segment instead of the first.

for a senior

Demonstrate this on a real shape such as route parameters, get the branch ordering right, and diagnose the common production failure where an upstream widening to string turns every result into never.

for a principal

Decide whether type-level string parsing belongs in the codebase at all. Weigh the refactor safety it buys against compile and editor cost, error messages nobody can read, and the handful of people able to maintain it.

## What the pattern is doing A template literal type in the `extends` position of a conditional type is a *matcher*. The compiler tries to align the candidate string literal type with the pattern; where the pattern has `infer X`, whatever text lines up there is captured as a new string literal type. ```ts type Tail<S> = S extends `${infer Head}-${infer Rest}` ? Rest : never; type R = Tail<"a-b-c">; // "b-c" ``` The literal segment in the pattern is the single hyphen. The compiler scans for it, and a placeholder that is followed by literal text stops at the **first** occurrence of that text. So the split happens at the leftmost hyphen: `Head` is `"a"`, `Rest` is `"b-c"`. The trailing placeholder has nothing after it to constrain it, so it takes the entire remainder — hyphens and all. If you want the *last* segment instead, you invert the shape so the greedy part is at the front: ```ts type Last<S extends string> = S extends `${string}-${infer R}` ? Last<R> : S; type L = Last<"a-b-c">; // "c" ``` Here the recursion keeps chopping from the left until no hyphen remains, and the final `S` is the last segment. The compiler gives no "match greedily" switch; you get the behaviour you want by choosing the recursion, not by a modifier. ## Adjacent placeholders split one character With no literal text between two placeholders, the first captures exactly one character: ```ts type First<S> = S extends `${infer H}${infer T}` ? H : never; type F = First<"abc">; // "a" ``` This is the primitive that character-level type-level programming is built on: capture one character, recurse on the rest, accumulate a result. It also means the empty string `""` fails to match this pattern at all, which is the natural base case for such a recursion. ## Splitting a delimited string The two rules combine into the standard split helper: ```ts type Split<S extends string, D extends string> = S extends `${infer Head}${D}${infer Rest}` ? [Head, ...Split<Rest, D>] : [S]; type Parts = Split<"a-b-c", "-">; // ["a", "b", "c"] ``` Each step peels one leading segment at the first delimiter; the false branch is the base case that wraps whatever is left. ## Route parameters, the canonical use The most-cited real application is turning a route string into the object of its parameters: ```ts type Params<S extends string> = S extends `${string}:${infer Param}/${infer Rest}` ? { [K in Param]: string } & Params<`/${Rest}`> : S extends `${string}:${infer Param}` ? { [K in Param]: string } : {}; type P = Params<"/users/:userId/posts/:postId">; // { userId: string } & { postId: string } ``` The first branch handles a parameter with more path after it; the second handles the final parameter; the third terminates on a path with no parameters left. Notice that ordering matters — the more specific pattern must be tried first, because the compiler takes the first branch whose pattern matches. ## What does not match Matching is over *types*, and only sufficiently precise ones: - `Tail<string>` is `never`. The primitive `string` is not assignable to the pattern, so the false branch wins. Any upstream widening — a `let` binding, a `+` concatenation, a value read from `JSON.parse` — collapses the result the same way. - `Tail<"abc">` is `never`, because there is no hyphen to align with the literal segment. - If the type parameter is a naked union, the conditional distributes and you get one result per member: `Tail<"a-b" | "c-d">` is `"b" | "d"`. ## Costs to respect Every recursive step is work the checker does on every compile and on every keystroke in the editor. Deep recursion over long strings hits the compiler's instantiation-depth guard and reports an excessively deep type, and even before that the editor gets sluggish. Keep the input strings short and known, prefer a single split over a general-purpose parser, and be honest that a helper which parses arbitrary strings at the type level is a maintenance cost someone will pay. And, as always, none of this survives compilation. `Params<"/users/:userId">` describes a shape; extracting the actual `userId` at runtime is still a job for `split` or a router library. The type only checks that the runtime code and the route string agree.

  • How would you capture the last segment instead of the remainder after the first separator?
    Recurse from the left with the greedy part in front: `` S extends `${string}-${infer R}` ? Last<R> : S ``. Each step discards everything up to a separator and continues on what follows; when no separator remains, the surviving `S` is the final segment. There is no greedy-match modifier — the recursion is how you express it.
  • What does `` S extends `${infer H}${infer T}` `` capture when the two placeholders are adjacent?
    `H` captures exactly one character and `T` the rest, so `"abc"` gives `H = "a"`, `T = "bc"`. The empty string does not match this pattern at all, which makes it the natural termination case for a recursion that walks a string character by character.
  • Why does a helper like this silently return `never` in real code more often than in examples?
    Because it needs a string *literal* type, and everyday code widens: `let` bindings widen to `string`, `+` concatenation yields `string`, and values parsed from JSON or read from configuration are `string` or `any`. The pattern is not assignable from `string`, so the false branch wins. Preserving literals usually means `as const` or a generic parameter capturing the literal.
  • What limits how far this kind of recursive matching can go?
    The compiler's instantiation-depth guard: a deep enough recursion is rejected with an excessively-deep type-instantiation error, and long before that the editor slows because the work reruns on every keystroke. Keep inputs short and the recursion shallow, and prefer one targeted split over a general string parser.

saying these in an interview costs you the question

  • Says the placeholder matches greedily to the last separator
  • Expects the pattern to match a value typed string
  • Thinks the extraction happens at runtime
  • Assumes adjacent placeholders split the string in half
  • Believes conditional branch order does not matter

context