DEV Community

Konstantin Podyganov
Konstantin Podyganov

Posted on

I love TypeScript! React Router Navigation with Type Safety

I've been using react-router-dom for years now, and while it's great, there’s always room for improvement, especially in terms of stability and type safety. Let’s go through an evolution of how we can make navigation more robust using TypeScript.

Basic Implementation

A simple react-router-dom setup looks like this:

const router = createBrowserRouter([
  { path: "/login", element: <LoginPage /> },
  { path: "/order/:id", element: <OrderPage /> },
  // etc...
]);
Enter fullscreen mode Exit fullscreen mode

We provide the router through a provider, and then use useNavigate as usual to navigate between pages:

export const LoginPage = () => {
  const navigate = useNavigate();

  const handleNavigateOrder = (id: string) => {
    navigate(`/order/${id}`);
  };

  return <>...</>;
};
Enter fullscreen mode Exit fullscreen mode

This works fine in most cases, but what if we want more stability and type safety?


Moving Routes to Constants

A good first step is to extract route strings into constants. We create a constants/routes.ts file:

export const APP_ROUTES = {
  LOGIN: "/login",
  ORDER: "/order",
} as const;
Enter fullscreen mode Exit fullscreen mode

Why as const? This ensures that APP_ROUTES is treated as a readonly object. Unlike using an enum, which compiles to an object at runtime, as const keeps APP_ROUTES immutable. This approach avoids unnecessary runtime overhead but also providing type safety.

Now, we use it in our navigation function:

const handleNavigateOrder = (id: string) => {
  navigate(`${APP_ROUTES.ORDER}/${id}`);
};
Enter fullscreen mode Exit fullscreen mode

This is better, but something still feels off. Manually formatting the string every time? There must be a better way!


Introducing a Custom Hook for Navigation

Let’s create a useAppNavigate hook that automatically builds paths from parameters.

const buildPath = (path: string, params?: Record<string, string>): string => {
  if (!params) return path;
  return path.replace(/:(\w+)/g, (_, key) => params[key] || '');
};

export const useAppNavigate = () => {
  const navigate = useNavigate();

  return (path: string, params?: Record<string, string>) => {
    navigate(buildPath(path, params));
  };
};
Enter fullscreen mode Exit fullscreen mode

This buildPath function takes a base path and a params object, replacing :param placeholders with actual values. Now we can use our hook like this:

export const LoginPage = () => {
  const navigate = useAppNavigate();

  const handleNavigateOrder = (id: string) => {
    navigate(APP_ROUTES.ORDER, { id });
  };

  return <>...</>;
};
Enter fullscreen mode Exit fullscreen mode

Improving Type Safety

But wait! What if we misspell a parameter? The function will silently fail. Time to actually use TypeScript like a not normal person.

First, let’s ensure only valid route keys can be used:

export const useAppNavigate = () => {
  const navigate = useNavigate();

  return (path: keyof typeof ROUTES, params?: Record<string, string>) => {
    navigate(buildPath(ROUTES[path], params));
  };
};
Enter fullscreen mode Exit fullscreen mode

Next, let’s define a RouteParams type that maps route keys to their expected parameters:

type RouteParams = {
  LOGIN: never;
  ORDER: { id: string };
  VIEW_ORDER: { id: string };
  CATALOG: never;
};
Enter fullscreen mode Exit fullscreen mode

Now, our useAppNavigate function becomes:

export const useAppNavigate = () => {
  const navigate = useNavigate();

  return <T extends keyof RouteParams>(path: T, params?: RouteParams[T]) => {
    navigate(buildPath(ROUTES[path], params));
  };
};
Enter fullscreen mode Exit fullscreen mode

Automatically Extracting Route Parameters

Let’s take it a step further and automatically extract parameters from route strings. Look at this monstruosity... But don't worry, I'm good at explaining things and put some comments for you

type ExtractParams<T extends string> =
  // Check if the route has multiple parameters separated by `/`
  T extends `${string}/:${infer Param}/${infer Rest}`
    ? { 
        // Recursively extract all parameters
        [K in Param | keyof ExtractParams<Rest>]: string 
      }
    // If there's only one parameter left to extract
    : T extends `${string}/:${infer Param}`
    ? { 
        // Capture it as a string key
        [K in Param]: string 
      }
    // If no parameters exist, return never
    : never;
Enter fullscreen mode Exit fullscreen mode

So now we can extract parameters from route paths dynamically. Also we need redefine RouteParams like this:

type RouteParams = {
  [K in keyof typeof ROUTES]: ExtractParams<(typeof ROUTES)[K]>;
};
Enter fullscreen mode Exit fullscreen mode

And the final version of useAppNavigate:

const buildPath = <T extends keyof RouteParams>(
  path: (typeof ROUTES)[T],
  params?: RouteParams[T]
): string => {
  if (!params) return path;
  return path.replace(/:(\w+)/g, (_, key) => {
    return params[key as keyof RouteParams[T]] as string;
  });
};

export const useAppNavigate = () => {
  const navigate = useNavigate();

  const handleNavigate = <T extends keyof RouteParams>(
    path: (typeof ROUTES)[T],
    params?: RouteParams[T]
  ) => {
    navigate(buildPath(path, params));
  };

  return handleNavigate;
};
Enter fullscreen mode Exit fullscreen mode

Now, we have fully type-safe, parameterized navigation! 🎉

Make sure to keep your ROUTES in a separate file so typescript won't yell at you because of a unsupported exports or something...

That's all for today, take this snippet and refactor your navigation if you want. Thanks for reading!

Top comments (1)

Collapse
 
programmerraja profile image
Boopathi

Nice article! Using TypeScript to enforce type safety in navigation is a smart move. The approach of extracting parameters dynamically from routes with ExtractParams is pretty clever.