Useful types: Build a route tree with TypeScript

bytimo

Andrei Kondratev

Posted on June 21, 2022

Useful types: Build a route tree with TypeScript

Hello there!

In my previous article I wrote how to extract the type of route's params. Look there if you haven't seen it yet. Now I'm going to describe how I create route definitions in my project.

First of all, let's define some terms that will be used next. react-router-dom@6 allows to use nested routes, so we can define something like this

<Route path="/">
  <Route path="card" element={...}>
    <Route path=":cardId" element={...}>
      {...}
    </Route>
  </Route>
</Route>
Enter fullscreen mode Exit fullscreen mode

In the code above /, card and :cardId are destructed segments of some path. If we join them, we get /card/:cardId. So let's call one of such segments a path and joined segments from some root to each specified path a fullPath.

OK, we need to use a path for some cases and a fullPath for another. And in my mind all data about every single route definition must be stored in one place. Moreover, this definition may have other information about the route, for instance, page title, default query-params, some flags or so on. And, of cause, I want to define route definitions as a tree because of the code above.

Briefly my ideal route definition:

  • can be built as a tree
  • stores all necessary data in the each node
  • automatically infers strong types (yes it's necessary)
  • is an once-declared structure that's shared throughout the application

First of all, let's see how to make a strongly typed tree. We can use an intersection of object types and generics for this. Let's define a type

type SomeData<Other> = {
  path: string;
} & Other;
Enter fullscreen mode Exit fullscreen mode

So, SomeData defines the path property and also other properties from Other.

const q1: SomeData<{}> = {path: ""}
let q2: SomeData<{a: number}> = {path: "", a: 10}
let q3: SomeData<{nested: SomeData<{}>}> = {
  path: "",
  nested: {path: ""}
}
Enter fullscreen mode Exit fullscreen mode

This solution allows to define a tree-like type of our routing definition, but it requires to write types manually. So we can declare some function that creates the definition object and automatically infers its type.

type RouteDefinition<Nested> = {
  path: string;
} & Nested;

function route<Nested>(
  path: string,
  nested: Nested
): RouteDefinition<Nested> {
  return {
    path,
    ...nested,
  }
}
Enter fullscreen mode Exit fullscreen mode

In this case we can use the function route to create one routing definition node and then reuse the function for nested definitions

const q1 = route("card", {}); // {path: "card"}
const q2 = route("card", {
  a: route("a", {})
}); // {path: "card", a: {path: "a"}}
Enter fullscreen mode Exit fullscreen mode

Maybe now it looks like something not very convenient, but we'll return to this in the future.

What about the full path property? We define one part of the full path inside the definition node and all nested definitions must contain this prefix in their full path. I suggest to change the nested object to the function that takes as the first parameter the full path for all nested routes and returns the nested route definitions.

First of all, we'll add the fullPath property to the RouteDefinition type

type RouteDefinition<Nested> = {
  path: string;
  fullPath: string;
} & Nested;
Enter fullscreen mode Exit fullscreen mode

Then we'll need to add the prefix parameter to the route function that it'll be possible to define the node with the nested full path. We'll also change the nested object to the createNested function that's been described above. Let's make it optional for a more convenient use.

function route<Nested>(
  path: string,
  prefix: string,
  createNested?: (fullPath: string) => Nested,
): RouteDefinition<Nested> {
  const fullPath = `${prefix}/${path}`
  const nested = createNested 
    ? createNested(fullPath) 
    : ({} as Nested);

  return {
    path,
    fullPath,
    ...nested,
  }
}
Enter fullscreen mode Exit fullscreen mode

And now we can use this function for defining nested routes like this

const q1 = route("card", ""); // {path: "card", fullPath: "/card"}

const q2 = route("card", "", prefix => ({
  a: route("a", prefix),
  b: route("b", prefix, prefix => ({
    c: route("c", prefix)
  }))
})); 
/**
  {
    path: "card",
    fullPath: "/card",
    a: {
      path: "a",
      fullPath: "/card/a"
    },
    b: {
      path: "b",
      fullPath: "/card/b",
      c: {
        path: "c",
        fullPath: "/card/b/c"
      }
    }
  }
 */
Enter fullscreen mode Exit fullscreen mode

We can create all route definitions as an object and share it throughout our application. You also can append another properties like title, isModal, etc to the node definition. Moreover, such approach can be used not only to create a route tree, but also to create any tree-like structures.

In the next article I would like to describe how to work with parameters in the url and build properties in the route definition that depend on the url parameter. Follow me and see you in the next article.

💖 💪 🙅 🚩
bytimo
Andrei Kondratev

Posted on June 21, 2022

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related