Strongly Typed Routes
Note: This article will be made redundant when @tanstack/router is released
If you just want to dive into the code, here’s the codesandbox.
Some context: the project I’m currently working on involves multiple teams coming together to build a unified frontend. We use NX to build a monorepo where components and utilities are shared between teams.
Due to the scope of the project, the number of routes (i.e. pages) will only grow over time. As the number of teams involved also start to grow, there may also be discoverability problems on existing routes.
To tackle this issue, I wrote a few utilities on top of react-location
for type-safety.
Desired Outcome
Before we get into the implementation, let’s think about the desired outcome.
Treating routes as state, we need to strongly type both “get” and “set”.
Glossary
Note the 2 terms in react-location
:
params
are variables within the path
search
is the object in the search string (also known as query string)
Given a path /user/:userId/bucket/:bucketId?sortBy=date&sortDesc=true
params
= userId, bucketId
search
= sortBy, sortDesc
Getting the State
To get state, we can use these utilities out of the box.
Setting the State (navigating)
Similar to the utilities above, useNavigate
and Link
expose generics for params & search.
However, this is not good enough for navigation because they are not tied to a particular path. When devs want to create links or navigate somewhere, we want them to be able to:
- Pick a path from an
enum
, like an “address book” - Enter params and search string according to the chosen path
In the gif example, intellisense tells me to enter a userId
parameter for the User
path:
The rest of this article will go through implementing this specifically, but the codesandbox also has examples to getting state.
Setup
To make the utilities we need type-safe, we first need to define some things.
-
Paths
Declare all the paths in your app in anenum
enum Paths { Home = "/", Users = "/user/:userId", }
-
Route Definitions
A big reason why I chose to usereact-location
overreact-router
is the typescript support. To define the params and search of each path, we use theMakeGenerics
utility.// e.g. /?someId=123 type HomePageRoute = MakeGenerics<{ Search: { someId: number; }; }>; // e.g. /user/:userId type UserRoute = MakeGenerics<{ Params: { userId: string; }; }>;
-
Path-Route Mapping
type Routes = { [Paths.Home]: HomePageRoute; [Paths.Users]: UserRoute; };
Custom Utilities
Now that we have everything defined, we’ll build utilities to make use of it.
There are 3 utilities, but I’ll focus on the base utility since the other 2 builds on top of it.
Base Utility: buildUrl
buildUrl
is a function that accepts to
, type
, search
, and params
to build a url.
const url = buildUrl({ to: Paths.Users, params: { userId: "abc" } });
// url = /user/abc
Recall what we want out of this: params and search types should be based on the path chosen.
Given the setup we have, we can use the Path-Route mapping we created.
type BuildUrlParams<TPath extends Paths> = {
type?: "absolute" | "relative";
to: TPath;
} & { search: Routes[TPath]["Search"] } & { params: Routes[TPath]["Params"] };
Unfortunately, this doesn’t work.
Typescript will throw an error Type '"Search"' cannot be used to index type 'Routes[TPath]'
.
The error is a little cryptic, but it doesn’t work because there exist routes that don’t have search or params.
In the example so far, HomePageRoute
doesn’t have params and UserRoute
doesn’t have search.
Conditionally Optional
To work around this, we need to tell Typescript that its okay if either of them don’t exist.
import { PartialGenerics } from "@tanstack/react-location";
type HasParams<TRoute extends PartialGenerics> = undefined extends TRoute["Params"]
? { params?: TRoute["Params"] }
: { params: TRoute["Params"] };
PartialGenerics
is a type provided by react-location
and it has optional params and search fields (hence the Partial prefix).
By using <TRoute extends PartialGenerics>
, we will fix the typescript error above because params and search will always exist.
In addition, we use a condition to check if the field is defined undefined extends TRoute["Params"]
.
This helps us make sure params is a required field if params is defined, or optional if it isn’t.
Fully Typed
Here is the function in full.
import { defaultStringifySearch, PartialGenerics } from "@tanstack/react-location";
type HasParams<TRoute extends PartialGenerics> = undefined extends TRoute["Params"]
? { params?: TRoute["Params"] }
: { params: TRoute["Params"] };
type HasSearch<TRoute extends PartialGenerics> = undefined extends TRoute["Search"]
? { search?: TRoute["Search"] }
: { search: TRoute["Search"] };
type BuildUrlParams<TPath extends Paths> = {
type?: "absolute" | "relative";
to: TPath;
} & HasParams<Routes[TPath]> &
HasSearch<Routes[TPath]>;
const buildUrl = <TPath extends Paths>(args: BuildUrlParams<TPath>) => {
const { params, to, search, type = "relative" } = args;
let url: string = to;
// replace parameters to the given value
if (params) {
for (const [key, value] of Object.entries(params)) {
url = url.replace(`:${key}`, value);
}
}
// use the default stringify function of react-location to build the search string
if (search) {
const queryString = defaultStringifySearch(search as any);
url = url + queryString;
}
// assuming this is an SPA, we can use window to build an absolute path
if (type === "absolute") {
url = window.location.origin + url;
}
return url;
};
That’s it! buildUrl
is now strongly typed, based on the paths and routes we have set up.
The internal logic of the function should be fairly self explanatory and isn’t the focus of this article.
Other Utilities
The other 2 utilities useNavigateApp
and AppLink
use buildUrl
internally.
They are purely written to be strongly typed versions of useNavigate
and Link
from react-location
.
Codesandbox
Try it out in the codesandbox.
Known Gaps
After looking in the codesandbox, you may have realized that my solution isn’t entirely strongly typed. I am not using the Path enum when declaring the routes object to pass to the Router.
const routes: Route[] = [
{
path: "/",
element: <HomePage />,
},
{
path: "/user",
element: <UserPageHeader />,
children: [
{
path: ":userId",
element: <UserPage />,
},
],
},
];
This was a deliberate decision, as trying to reuse our enums and types here made everything way more complex. I felt that in exchange for readability and maintainability, this is something I am willing to concede. After all, making a mistake in this routes object would have been immediately obvious during development.
Summary
In complex applications, I find that an “address book” of all routes is necessary. Spending extra effort to make it strongly typed not only helps devs in finding routes in the app, but also eliminates erroneous navigations.
It is also important to note that the router library you choose goes a long way in how complex this can be.
At the time of writing this, I found it far easier to do this with react-location
, as compared to react-router
.
In closing, I felt that this was a simple solution to a problem I’ve faced in past projects. However, I am aware that there may be gaps that I’ve yet to account for as the project I’m working on is still in its infancy.
If you have any opinions or alternate solutions, feel free to reach out to me. 😊