Annotated solution
Published July 23, 2026The solution
type ExtractParams<Path extends string> = Prettify< Path extends `${string}:${infer Param}/${infer Rest}` ? { [K in Param]: string } & ExtractParams<`/${Rest}`> : Path extends `${string}:${infer Param}` ? { [K in Param]: string } : {} >
The common wrong answer
type ExtractParams<Path extends string> = Path extends `${string}:${infer Param}/${infer Rest}` ? { [K in Param]: string } & ExtractParams<`/${Rest}`> : Path extends `${string}:${infer Param}` ? { [K in Param]: string } : {}
Everything here is right except the shape of the answer. This produces `{ id: string } & { postId: string }`, which is assignable to `{ id: string; postId: string }` but is not the same type — and the checks test exact equality. Wrapping the whole thing in `Prettify` collapses the intersection into one object.
Line by line
`${string}:${infer Param}/${infer Rest}`The leading `${string}` absorbs whatever comes before the colon, so the pattern does not care that the path starts with `/users`. `Param` binds up to the next slash; `Rest` takes everything after it.
ExtractParams<`/${Rest}`>The slash is put back before recursing. Without it, a trailing segment like `posts/:postId` would still match the first pattern and the recursion would not terminate cleanly.
Path extends `${string}:${infer Param}`The second pattern has no trailing slash, so it only matches when the parameter is the last thing in the string. This is the base case that stops the recursion.
Prettify<...>Maps over the accumulated keys once more, producing a fresh object type. The `& {}` at the end of `Prettify` is what forces TypeScript to actually evaluate the mapped type rather than keeping it lazy.
Takeaway
Template literal patterns match greedily from the left. When you accumulate object types through recursion you get an intersection, and exact-equality checks will reject it — flatten before you return.