0

To begin with, I have a tree of routes as follows:

interface RouteObject {
  id: string;
  path: string;
  children?: RouteObject[];
}

const routeObjects: RouteObject[] = [
  {
    id: 'root',
    path: '/',
    children: [
      {
        id: 'auth',
        path: 'auth',
        children: [
          {
            id: 'login',
            path: 'login',
            children: [
              {
                id: 'vishal',
                path: 'vishal',
              },
            ],
          },
          {
            id: 'register',
            path: 'register',
          },
          {
            id: 'resetPassword',
            path: 'reset-password',
          },
          {
            id: 'resendConfirmation',
            path: 'resend-confirmation',
          },
        ],
      },
      {
        id: 'playground',
        path: 'playground',
        children: [
          {
            id: 'playgroundFormControls',
            path: 'form-controls',
          },
        ],
      },
    ],
  },
];

I am trying to achieve a flat object of id vs path as follows:

{
  root: '/',
  auth: '/auth',
  login: '/auth/login',
  vishal: '/auth/login/vishal',
  register: '/auth/register',
  resetPassword: '/auth/reset-password',
  resendConfirmation: '/auth/resend-confirmation',
  playground: '/playground',
  playgroundFormControls: '/playground/form-controls'
} 

Here is the Typescript code (without generics) that is capable of producing the output:

function createRoutes(routeObjects: RouteObject[], parentPath = '', routes: Record<string, string> = {}) {
  for (let { id, path: relativePath, children } of routeObjects) {
    let rootRelativePath =
      relativePath === '/' || parentPath === '/' ? `${parentPath}${relativePath}` : `${parentPath}/${relativePath}`;
    if (id) routes[id] = rootRelativePath;
    if (children) createRoutes(children, rootRelativePath, routes);
  }
  return routes;
}

Here is the link of Playground: Working code without using Generics

The above code produces correctly, but cannot produce static types. By static types, I mean, the return type of createRoutes should be an object with real ids and real paths, not just Record<string, string>.

So, I tried to use generics, but cannot go farther than this: Generics non working playground

It will be helpful, if someone can even point me to the right direction. Thanks.

Vishal
  • 6,238
  • 10
  • 82
  • 158
  • For this to be a [mre] you should probably define `RouteObject` in plaintext in the question itself, and ideally you would not add dependencies on third-party libraries to do so. Could you [edit] to do that? – jcalz Apr 06 '23 at 17:48
  • @jcalz Sure, will do that and will update you shortly! Thanks – Vishal Apr 06 '23 at 17:48
  • 1
    @jcalz I have removed the dependency by creating my own interface. Please check the updated link. – Vishal Apr 06 '23 at 17:52
  • Not just in a link, but plaintext in the question itself, as I mentioned before. – jcalz Apr 06 '23 at 17:53
  • So `id` and `path` are optional? I assume if some node in the tree has no `path` then there's no point in recursing down, right? – jcalz Apr 06 '23 at 17:55
  • Sorry, just updated the question text. Actually there is a point in continue recursing down even when there is no `path` or `id`. My code is not tested against that possibility, but , we should recurse down by skipping that node, because react-router behaves similar to that. – Vishal Apr 06 '23 at 17:58
  • I don't get it, how would you join the path if one of them is blank? Please ether provide a [mre] that demonstrates the use cases you care about, or remove the requirement and deal with it yourself offline. – jcalz Apr 06 '23 at 18:02
  • It will take some time for me to implement the minimal reproducible example, so choose to deal with it offline. – Vishal Apr 06 '23 at 18:05
  • Okay, so then please [edit] the question so that `id` and `path` are required properties, and let me know if [this approach](https://tsplay.dev/WKPPZw) meets your needs or not. If it meets your needs I'll write up an answer explaining; if not, what am I missing? – jcalz Apr 06 '23 at 18:12
  • @jcalz The approach you used looks excellent. Please post your solution as answer, I will try to find more on how to deal with empty id and path offline now. Thank you very much for a fast and precise solution. – Vishal Apr 06 '23 at 18:12
  • Okay I'll write up an answer when I get a chance. – jcalz Apr 06 '23 at 18:12
  • @jcalz your solution works fine in TS Playground, but not in my VSCode editor. I am using Typescript 4.9.5 in my VSCode and in my project. I tried to use same version on TS Playground. It works there but not in my local system. Do I need to turn on any additional settings in tsconfig? Error message is a bit long to display here, so I will update the question with it and will notify you. – Vishal Apr 06 '23 at 18:49
  • 1
    sorry, I just read your answer and can see that you are using `children?: readonly RouteObject[]`. I actually never knew that I had to make it readonly for the solution to work. I just changed in my IDE and now it works fine. Thank you for the great answer. I will mark it as answer. – Vishal Apr 06 '23 at 18:56
  • It's possible for it to be made to work without `readonly` but then you can't use `as const` directly and it would be a whole big digression. – jcalz Apr 06 '23 at 18:57
  • Nope, no changes required as the answer fits my needs. Thanks again for the great help. – Vishal Apr 06 '23 at 19:01

1 Answers1

1

I'm going to operate under the assumption that id and path are required, and that you don't actually require children to be a mutable array type, so my definition of RouteObject is

type RouteObject = {
  id: string,
  path: string,
  children?: readonly RouteObject[]
};

(Note that readonly arrays are less restrictive than mutable ones, even though the name might imply otherwise.)


Also, in order for this to possibly work, you need the compiler to keep track of the string literal types of all the nested id and path properties of your route objects. If you annotate routeObjects like this:

const routeObjects: RouteObject[] = [ ⋯ ];

then you are telling the compiler to throw away any more specific information from that initializing array literal. Even if you leave off the annotation like:

const routeObjects = [ ⋯ ];

the compiler will not infer anything more specific than string for the nested id and path properties, since usually people don't want such narrow inference. Since we do want narrow inference, we should tell the compiler that via a const assertion:

const routeObjects = [ ⋯ ] as const;

If you look at the type of routeObjects now, you'll see that the compiler infers a quite specific readonly tuple type:

/* const routeObjects: readonly [{
    readonly id: "root";
    readonly path: "/";
    readonly children: readonly [{
        readonly id: "auth";
        readonly path: "auth";
        readonly children: readonly [⋯] // omitted for brevity
    }, {⋯}]; // omitted for brevity
}] */

and this is why I widened children to allow readonly RouteObject[] above; otherwise this would fail to be a RouteObject[] and we'd have to play more games to get it to be accepted.

Anyway, now we're finally at a place where we can start to give strong typings.


I'm not going to touch the implementation of createRoutes() much at all, since there's no way the compiler will ever be able to verify that it conforms to what will turn out to be quite a complex call signature. Instead I'll just hide the implementation behind a single-call-signature overload, or the equivalent. For the rest of the answer I'm not going to worry about implementation of the function and just look at the call signature:

declare function createRoutes<T extends readonly RouteObject[]>(
  routeObjects: T
): Routes<T>;

So, createRoutes() will be generic in the type T of routeObjects which has been constrained to readonly RouteObject[]. And it returns an object of type Routes<T>, which we will define as... oh boy here it comes:

type Routes<T extends readonly RouteObject[]> = {
  [I in keyof T]: (x:
    Record<T[I]['id'], T[I]['path']> & (
      T[I] extends {
        path: infer P extends string,
        children: infer R extends readonly RouteObject[]
      } ? { [K in keyof Routes<R>]:
        `${P extends `${infer PR}/` ? PR : P}/${Extract<Routes<R>[K], string>}`
      } : {}
    )
  ) => void
}[number] extends (x: infer U) => void ?
  { [K in keyof U]: U[K] } : never;

That's quite complex, but possibly not any more complex than the type manipulation you want to see. It's a recursive type, where Routes<T> is defined in terms of Routes<R> for each R corresponding to the children property the elements of T.

Let's break down what Routes<T> does:

  • It walks through each element (at index I) of the tuple type T and makes an object type whose only key is the id property and whose value at that key is the path property. That's what Record<T[I]['id'], T[I]['path']> (using the Record utility type) means. So for the first (and only) element of routeObjects it makes the equivalent of {root: "/"}.

  • If the element has a children property R (which is checked via conditional type inference using T[I] extends { ⋯ children: infer R ⋯ } ? ⋯ ), it also recursively evaluates the object type Routes<R>. So for that element it makes something like { auth: "auth"; login: "auth/login"; vishal: "auth/login/vishal"; register: "auth/register"; ⋯ }

  • It takes Routes<R> and maps it to a version where we prepend the current path property to it with a slash (unless it ends in a slash in which case we suppress the slash; this is a fiddly bit you might want to tweak depending on the implementation). That's what { [K in keyof Routes<R>]: `${P extends `${infer PR}/` ? PR : P}/${Extract<Routes<R>[K], string>}`} means. So for that element it makes something like { auth: "/auth"; login: "/auth/login"; vishal: "/auth/login/vishal"; register: "/auth/register"; ⋯ }

  • It takes both of these object types and intersects them together. (Note that if the element has no children then we just intersect the empty object type {} instead).

  • Now what we do is collect all these individual pieces and intersect them all together to get one huge intersection U. This is done via the same technique as TupleToIntersection<T> as described in the answer to "TypeScript merge generic array"; we put each piece in a contravariant type position, get a union of those, and then infer a single type from that position, which results in a huge intersection. That's what { [I in keyof T]: (x: ⋯ ) => void}[number] extends (x: infer U) => void ? ⋯ : never does. So if walking through the array gives us something like [{a: "b"} & {c: "b/d"}, {e: "f"} & {g: "f/h"}], then it is converted into the single intersection {a: "b"} & {c: "b/d"} & {e: "f"} & {g: "f/h"}

  • Finally because a huge intersection is ugly, we collect them into a single object type by doing a simple identity mapped type over U; that's what { [K in keyof U]: U[K] } does. So if U is {a: "b"} & {c: "b/d"} & {e: "f"} & {g: "f/h"}, then the output is {a: "b"; c: "b/d"; e: "f"; g: "f/h"}.

Whew, that was rough. Okay, let's test it:

const routes = createRoutes(routeObjects);
/* const routes: {
    root: "/";
    auth: "/auth";
    login: "/auth/login";
    vishal: "/auth/login/vishal";
    register: "/auth/register";
    resetPassword: "/auth/reset-password";
    resendConfirmation: "/auth/resend-confirmation";
    playground: "/playground";
    playgroundFormControls: "/playground/form-controls";
} */

Hooray, that's exactly what you wanted to see.

Playground link to code

jcalz
  • 264,269
  • 27
  • 359
  • 360