#33345Extreme

Dynamic Route

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.

Challenge Instructions: Dynamic Route

Extreme

Given below routes, infer its dynamic params.

RouteParams 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).

ChallengeSolution
/* _____________ 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: st

Pro Challenge

Unlock 170+ medium, hard, and extreme challenges to master advanced TypeScript.

Monthly subscription. Cancel anytime.

Detailed Explanation

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.

Splitting and classifying

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.

Walking with two accumulators

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.

Flattening the intersection

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.

Edge cases the tests cover

This challenge is originally from here.

Share this challenge

Related Challenges

Learn the Concepts

Become a TypeScript Pro

Track your progress through 100+ hands-on challenges. Free, sign in with GitHub.

Or start solving right away: explore all TypeScript challenges