home

Typesafe Routing with React-Router: Advanced TypeScript

August 25, 2024

TLDR; Use generatePath from "react-router-dom" to build URLs with type-safety. Link to TypeScript Playground

We use URLs in our code 1) to define routes and 2) to redirect to them. If we only had URLs without any dynamic parameters, then we could, in-theory, just store them as constants.

The route definition (ex: /users/:userId) is not dynamic, and therefore can be stored as a constant. When redirecting, we just need to build the URL from the definition by replacing the parameters with provided values, in a type-safe way. In short, we need to go from /users/:userId to /users/1, while TypeScript holds our hand.

Defining Constants

// src/constants.ts
export const Routes = {
    USERS: "/users",
    USER_DETAILS: "/users/:userId",
} as const;

// Utility type
export type Routes = (typeof Routes)[keyof typeof Routes]; // "/users" | "/users/:userId"

Routes will hold the route definitions, which we can directly use in the <Route /> component. We will be using it later as types to our function’s argument.

Notice the use of as const here. The constant assertion is used to ensure that the object is inferred “literally”. This means that the type Routes will be equal to "/users" | "/users/:userId" instead of string.

Next, we need the function that will help us go from /users/:userId to /users/1.

Introducing buildPath

Here’s what buildPath does:

// function buildPath(path, params?);
const url = buildPath("/users/:userId", { userId: "1" }); // Ok -> url = "/users/1"
const url = buildPath("/users/:userId", { id: "1" }); // TypeError: expected { userId: string }
const url = buildPath("/users"); // Ok -> url = "/users"

Ignoring the type-safe aspect of this function, below is one way to implement it.

function buildPath(path: string, params?: Record<string, string>) {
    return path.replace(/:([^\/]+)/g, (_, key) => {
        if (params[key] === undefined) {
            throw new Error(`Missing parameter: ${key}`);
        }
        return params[key];
    });
}

It looks for the :<key> pattern and replaces it with params[key] (if found). This function checks for missing parameters at runtime. But we can do better.

Sprinkling TypeScript Magic ✨

This is where we get to have fun. Essentially we need a type, that gives us the parameters as union of literals.

Take a look at this - it’s all we really need.

type PathParam<Path extends string> = Path extends `${infer L}/${infer R}`
    ? PathParam<L> | PathParam<R>
    : Path extends `:${infer Param}`
      ? Param
      : never;
PathParam<"/a/:b"> = "b";
PathParam<"/:a/:b"> = "a" | "b";
PathParam<"/a"> = never;

Here’s a diagram of how the PathParam type works. The result is a union of all the leaf nodes

Diagram explaining working of PathParam type

We are essentially performing a divide-and-conquer algorithm using pattern matching!
In case you are not familiar with never, extends or infer keywords, check out this guide for a brief summary or Matt Pocock for amazing videos covering advanced TypeScript concepts.

Almost There

Let’s use PathParam with buildPath to achieve what we set out to do.

type Params<Path extends string> = { [key in PathParam<Path>]?: string };
// Params<"/:a/:b"> = { a?: string, b?: string }

function buildPath<P extends string>(path: P, params?: Params<P>): string {
    return path.replace(/:([^\/]+)/g, (_, key) => {
        if (params[key] === undefined) {
            throw new Error(`Missing parameter: ${key}`);
        }
        return params[key];
    });
}

This works now! You can play with it on TypeScript Playground

const url = buildPath("/users/:id/:name", { id: "1", name: "sahaj" }); // Ok -> url = "/users/1/sahaj"
const url = buildPath("/users/:userId", { id: "1" }); // TypeError: expected { userId: string }

This function is already good enough to be used as-is. Up until now whatever we did is framework agnostic. The following part is totally optional and is just one way of using this concept with React-Router.

Enter "react-router-dom"

So here’s the good news: all the hardwork we did above is already done by the generatePath function from "react-router-dom". Let’s use it instead of our suboptimal implementation. We will also narrow the type of path argument from string to Routes (the constant we defined earlier)

import { type Routes } from "./constants";
import { generatePath } from "react-router-dom";

function buildPath<P extends Routes>(...args: Parameters<typeof generatePath<P>>): string {
    return generatePath(...args);
}

Note: Parameters is a utility class in TypeScript that allows us to get the typeof parameters of a function in form of a tuple.

We can also do something similar and create a wrapper on the <Route /> component which only accepts paths from Routes constant. However, I’m not a fan of adding JS code only for type-safety, so I’ll be skipping that part. In the case of buildPath it is justified as I also don’t like to use utility functions directly from the library and prefer thin wrappers.

Conclusion

We now have a type-safe way of building paths to navigate to pages which are known to our application. TypeScript is amazing.

import { useNavigate } from "react-router-dom";
import { buildPath } from "./utils";

export const SomePage = () => {
    const { navigate } = useNavigate();

    function redirect() {
        navigate(buildPath(Routes.USER_DETAILS, { userId: "1" }));
    }

    return <button onClick={redirect}>Navigate to User Details</button>;
};

We can always improve it by adding support for queryParamets, support for numeric parameters and so on, but the general concept remain the same. Hope you enjoyed!