Build Entity-Friendly react-router Paths Generator with Typescript

Volodymyr Yepishev - May 12 '21 - - Dev Community

So the other day I was thinking if it is possible to create a route generator that would be of any use and would respect entities in URLS, i.e. :entity(post|article).

Naturally, react-router provides means of generating paths, the generatePath function, and while the @types/react-router types package does pretty decent job securing the param names, as of yet, it leaves entities vulnerable, without any kind of restrictions, they are treated same as any other param, meaning you can drop string | number | boolean into them.

Let's fix that with typescript's 4+ template literal types and generics.

First of all let's figure out what types we want to be allowed to be passed to our parameters. We could go with string in string out attitude, since when we extract params they are strings, but for the sake of compatibility and tribute to the original @types/react-router let's go with union string | number | boolean:

type AllowedParamTypes = string | number | boolean;
Enter fullscreen mode Exit fullscreen mode

That's a nice start. Now, we need a type that would represent our union of values for entities, into which we will be dropping all possible values for our entity and recursively adding them to the union:

type EntityRouteParam<T extends string> =
  /** if we encounter a value with a union */
  T extends `${infer V}|${infer R}`
  /* we grab it and recursively apply the type to the rest */
  ? V | EntityRouteParam<R>
  /** and here we have the last value in the union chain */
  : T;
Enter fullscreen mode Exit fullscreen mode

Now we need a param type that can be either an entity which is limited to a union of values, or just a regular param, which is simply an allowed type:

type RouteParam<T extends string> =
  /** if we encounter an entity */
  T extends `${infer E}(${infer U})`
  /** we take its values in union */
  ? { [k in E]: EntityRouteParam<U> }
  /** if it's an optional entity */
  : T extends `${infer E}?`
  /** we make its values optional as well */
  ? Partial<{ [k in E]: AllowedParamTypes }>
  /** in case it's merely a param, we let any allowable type */
  : { [k in T]: AllowedParamTypes };
Enter fullscreen mode Exit fullscreen mode

Now to craft a generic that can break down an url into fragments and extract an interface of params:

type RouteParamCollection<T extends string> =
  /** encounter optional parameter */
  T extends `/:${infer P}?/${infer R}`
  /** pass it to param type and recursively apply current type
   *  to what's left */
  ? Partial<RouteParam<P>> & RouteParamCollection<`/${R}`>
  /** same stuff, but when the param is optional */
  : T extends `/:${infer P}/${infer R}`
  ? RouteParam<P> & RouteParamCollection<`/${R}`>
  /** we encounter static string, not a param at all */
  : T extends `/${infer _}/${infer R}`
  /** apply current type recursively to the rest */
  ? RouteParamCollection<`/${R}`>
  /** last case, when param is in the end of the url */
  : T extends `/:${infer P}`
  ? RouteParam<P>
  /** unknown case, should never happen really */
  : unknown;
Enter fullscreen mode Exit fullscreen mode

That's basically all the magic we need. Now all that's needed is to create a couple of wrapper functions that would provide us with more type safety and run generatePath from react-router inside under their hoods.

A function for path generation with param and entity hints is pretty simple and you can even use enums with it:

function routeBuilder<K extends string>(route: K, routeParams: RouteParamCollection<K>): string {
  return generatePath(route, routeParams as any)
}
routeBuilder('/user/:userId/:item(post|article)/', { item: 'article', userId: 2 });
// ^ will get angry if 'item' receives something else than 'post' or 'article'
Enter fullscreen mode Exit fullscreen mode

Now we can come up with even more advanced function that could generate route fragments of even longer route, and provide same type safety.

In order to craft such function we first need to make a couple of types for crafting path fragments of a given route, respecting the params in it:

type RouteFragment<T extends string, Prefix extends string = "/"> = T extends `${Prefix}${infer P}/${infer _}`
  ? `${Prefix}${RouteFragmentParam<P>}` | RouteFragment<T, `${Prefix}${P}/`>
  : T

type RouteFragmentParam<T extends string> = T extends `:${infer E}(${infer U})`
  ? EntityRouteParam<U>
  : T extends `:${infer E}(${infer U})?`
  ? EntityRouteParam<U>
  : T
Enter fullscreen mode Exit fullscreen mode

And obviously now we need a factory to produce our path builder:

function fragmentedRouteBuilderFactory<T extends string>() {
  return <K extends RouteFragment<T>>(route: K, routeParams: RouteParamCollection<K>): string => {
    return routeBuilder(route, routeParams as any)
  }
}
const fragmentRouteBuilder = fragmentedRouteBuilderFactory<"/user/:userId/:item(post|article)/:id/:action(view|edit)">();
fragmentRouteBuilder('/user/:userId/:item(post|article)/:id', { userId: 21, item: 'article', id: 12 });
Enter fullscreen mode Exit fullscreen mode

Doesn't look that difficult now, does it? :)

Oh, you can also check it out in the typescript playground.

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .