10LOC
TypeScriptadvanced

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

  • ParamNames recurses through the string using two conditional branches, both matched with template literal patterns and infer: one for a :param/ followed by more path (peels off one param and recurses on Rest), one for a trailing :param with nothing after it (the base case).
  • Each recursive call unions its own Param with whatever the recursive call on Rest finds, so "/users/:id/posts/:postId" accumulates to "id" | "postId".
  • A path with no :param segments at all falls through to the never branch, so RouteParams for a static path is { [K in never]: string } — an empty object type, requiring no params.
  • RouteParams maps that literal union into an object type via [K in ParamNames<Path>]: string, and buildPath uses 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:b would be parsed as one param named a: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

  • infer inside a plain conditional type, without a template literal — the same extraction mechanism, applied to a function's return type instead of a string; see the AsyncReturnType pattern.
  • Mapped types with as — used here inside RouteParams, and useful on its own for renaming keys rather than just picking them.