Template literal types for typed route strings
Published September 8, 2026
type ParamNames<Path extends string> = Path extends `${string}:${infer Param}/${infer Rest}`
? Param | ParamNames<Rest>
: Path extends `${string}:${infer Param}`
? Param
: never;
type RouteParams<Path extends string> = { [K in ParamNames<Path>]: string };
export const buildPath = <Path extends string>(path: Path, params: RouteParams<Path>): string =>
path.replace(/:(\w+)/g, (_match, key: string) => (params as Record<string, string>)[key]);What
ParamNames<Path> walks a route string like /users/:id/posts/:postId pattern by pattern and produces the literal union "id" | "postId". RouteParams<Path> turns that union into an object type, so buildPath can require exactly the right params for whatever path string it's given.
Why it matters
The usual way to type a router's params is a hand-written interface per route (UserParams, UserPostParams, ...) that has to be kept in sync with the route string by hand — rename :postId to :id2 in the string and the interface silently goes stale. Deriving the params type from the string itself removes that seam: the route string is the single source of truth, and the param type updates automatically when it changes. This is the same idea behind libraries like React Router or TanStack Router typing their route params, just stripped down to the mechanism.
How it works
ParamNamesrecurses through the string using two conditional branches, both matched with template literal patterns andinfer: one for a:param/followed by more path (peels off one param and recurses onRest), one for a trailing:paramwith nothing after it (the base case).- Each recursive call unions its own
Paramwith whatever the recursive call onRestfinds, so"/users/:id/posts/:postId"accumulates to"id" | "postId". - A path with no
:paramsegments at all falls through to theneverbranch, soRouteParamsfor a static path is{ [K in never]: string }— an empty object type, requiring no params. RouteParamsmaps that literal union into an object type via[K in ParamNames<Path>]: string, andbuildPathuses it as the required shape of its second argument.
Gotchas
- This assumes every param name is scoped to segments separated by
/and that param names don't themselves contain/. A malformed pattern like:a:bwould be parsed as one param nameda:b, not two. - Recursive conditional types like this have a depth limit (a few hundred levels in current TypeScript). It won't matter for a realistic route, but a programmatically generated route string with hundreds of params could hit it.
Related
inferinside a plain conditional type, without a template literal — the same extraction mechanism, applied to a function's return type instead of a string; see theAsyncReturnTypepattern.- Mapped types with
as— used here insideRouteParams, and useful on its own for renaming keys rather than just picking them.