Infer the params object of a file-based route from its path, covering catch-all and optional catch-all segments, and returning never when two catch-alls are ambiguous.
DynamicRoute<T> reads a file-based route the way a framework router does and hands back the params object that route produces.
type Params = DynamicRoute<'/shop/[slug]/[[...rest]]'>
// expected to be { slug: string; rest?: string[] }Three placeholder shapes carry a name. [slug] matches exactly one path segment and yields slug: string, [...slug] matches one or more and yields slug: string[], and [[...slug]] matches zero or more and yields the optional slug?: string[]. Everything else in the path is a literal. The interesting rule is the failure rule: if two catch-alls can both stretch with nothing fixed between them, nothing pins their boundary, the route is ambiguous, and the answer is never.
Given below routes, infer its dynamic params.
| Route | Params Type Definition |
|---|---|
/blog/[slug]/page.js | { slug: string } |
/shop/[...slug]/page.js | { slug: string[] } |
/shop/[[...slug]]/page.js | { slug?: string[] } |
/[categoryId]/[itemId]/page.js | { categoryId: string, itemId: string } |
/app/[...foo]/[...bar] | never - It's ambiguous as we cannot decide if b on /app/a/b/c is belongs to foo or bar. |
/[[...foo]]/[slug]/[...bar] | never |
/[first]/[[...foo]]/stub/[...bar]/[last] | { first: string, foo?: string[], bar: string[], last: string } |
View on GitHub: https://tsch.js.org/33345
Change the following code to make the test cases pass (no type check errors).
/* _____________ Your Code Here _____________ */
type DynamicRoute<T extends string> = any
/* _____________ Test Cases _____________ */
import type { Equal, Expect } from '../helpers'
type cases = [
Expect<Equal<DynamicRoute<'/shop'>, {}>>,
Expect<Equal<DynamicRoute<'/shop/[]'>, {}>>,
Expect<Equal<DynamicRoute<'/shop/[slug]'>, { slug: string }>>,
Expect<Equal<DynamicRoute<'/shop/[slug]/'>, { slug: string }>>,
Expect<
Equal<DynamicRoute<'/shop/[slug]/[foo]'>, { slug: string; foo: string }>
>,
Expect<
Equal<
DynamicRoute<'/shop/[slug]/stub/[foo]'>,
{ slug: stUnlock 170+ medium, hard, and extreme challenges to master advanced TypeScript.
Monthly subscription. Cancel anytime.
The solution in full:
type Split<S extends string> = S extends `${infer Head}/${infer Rest}`
? [Head, ...Split<Rest>]
: [S]
type Fixed<S extends string> = S extends `[${infer Name}]`
? Name extends ''
? { kind: 'literal' }
: { kind: 'fixed'; params: { [K in Name]: string } }
: { kind: 'literal' }
type Parse<S extends string> = S extends `[[...${infer Name}]]`
? Name extends ''
? Fixed<S>
: { kind: 'catch-all'; params: { [K in Name]?: string[] } }
: S extends `[...${infer Name}]`
? Name extends ''
? Fixed<S>
: { kind: 'catch-all'; params: { [K in Name]: string[] } }
: Fixed<S>
type Walk<
Segments extends string[],
Params,
OpenCatchAll extends boolean,
> = Segments extends [infer Head extends string, ...infer Tail extends string[]]
? Parse<Head> extends { kind: 'catch-all'; params: infer P }
? OpenCatchAll extends true
? never
: Walk<Tail, Params & P, true>
: Parse<Head> extends { kind: 'fixed'; params: infer P }
? Walk<Tail, Params & P, OpenCatchAll>
: Walk<Tail, Params, false>
: Params
type Flatten<T> = { [K in keyof T]: T[K] }
type DynamicRoute<T extends string> = Flatten<Walk<Split<T>, {}, false>>It is a three-stage pipeline: cut the route into segments, classify each segment on its own, then walk the classified list carrying the params built so far. Keeping the middle stage separate is what keeps the code readable, because what [[...foo]] means has nothing to do with the rest of the path.
Split peels one / at a time. Template literal inference is lazy, so Head takes the shortest prefix it can while Rest gets everything after the first slash, and spreading the recursive call keeps the result flat instead of nesting one tuple per separator. A leading slash leaves an empty first segment and a trailing slash an empty last one, and both fall through to literal:
[object Object]Parse tries the longest pattern first, so [[...${infer Name}]] claims the double-bracket form before [...${infer Name}] can see it. Each branch then asks whether the captured name is empty, and that one guard is what makes the awkward segments behave. [...] does match the catch-all pattern, but with Name as ''. An anonymous catch-all is meaningless, so the branch hands the segment back to Fixed, which re-matches it as [${infer Name}] and captures the literal name ...:
// Parse<'[[...foo]]'> is { kind: 'catch-all'; params: { foo?: string[] } }
// Parse<'[...]'> is { kind: 'fixed'; params: { '...': string } }
// Parse<'[]'> is { kind: 'literal' }
// Parse<'[]index.html'> is { kind: 'literal' }The last two differ in why they fail. [] reaches Fixed and captures an empty name. []index.html never matches [${infer Name}] at all, because the pattern requires the segment to end at the closing bracket: a placeholder has to be the whole segment to count. Every branch reports a kind, which is why the walker can match on that tag and read a prepared params payload instead of re-testing the string.
Walk recurses over the segment tuple head-first, carrying two values that a recursive type has no other way to keep: Params, the object under construction, grown with Params & P at every named placeholder, and OpenCatchAll, a flag saying whether a catch-all is still unresolved. That flag is the entire ambiguity rule. A catch-all sets it, a literal clears it, and a fixed placeholder such as [slug] leaves it untouched. The asymmetry is the subtle part: a literal is a landmark, so a router that finds it knows exactly how many segments the earlier catch-all swallowed, while [slug] matches any one segment and pins nothing. Meeting a catch-all while the flag is still set means two stretchy patterns with no landmark between them:
[object Object]Because that never is returned from inside the recursion, it becomes the result of every enclosing call, so one ambiguous pair poisons the route with no extra plumbing.
The accumulator ends as a chain such as {} & { slug: string } & { foo?: string[] }. That is correct, but the tests compare types exactly and an intersection is not identical to the object literal it describes. Flatten rewrites it as one flat object, and because { [K in keyof T]: T[K] } is homomorphic, the optional marker on foo survives the trip. Feeding never through it is safe for the same reason: a homomorphic mapped type distributes over a union, never is the empty union, and the answer stays never.
'/shop' and '/shop/[]' both give {}. No segment carries a usable name, so the accumulator never leaves its {} default.'/shop/[slug]/' gives { slug: string }, since the trailing empty segment is a literal.'/shop/[slug]/stub/[[...foo]]/[...]' gives { slug: string; foo?: string[]; '...': string }. The name ... is odd but legal, and it arrives as a fixed param rather than a catch-all. Appending /[...]index.html changes nothing, because trailing text after the bracket makes that segment a literal.'/[slug]/[[...foo]]/[...bar]' is never: the two catch-alls are adjacent and the leading [slug] is too early to matter.'/[[...foo]]/[slug]/[...bar]' is never too, because the [slug] between them is a placeholder, not a landmark, so the flag stays set.'/[[...foo]]/[...bar]/static' is never as well. The literal clears the flag, but it arrives after a boundary that has already been crossed.'[[...foo]]/stub/[...bar]' is fine. The literal stub sits between the two catch-alls and pins the split, which is exactly what the previous route lacked.This challenge is originally from here.
Track your progress through 100+ hands-on challenges. Free, sign in with GitHub.
Or start solving right away: explore all TypeScript challenges