DEV Community

Cover image for Type-Safe Usage of React Router
Danny Kim
Danny Kim

Posted on

Type-Safe Usage of React Router

This is my approach to implement strongly typed routing using React Router and TypeScript. So that if I try to create a <Link> to an unknown path, tsc can warn me appropriately. Of course there are other benefits of typed routes, but let's go over what's wrong with the current implementation first.

Problem

  1. react-router takes any plain string as a path. This makes it difficult to refactor routes when it is required to rename/delete/add routes. Also typos are hard to detect.
  2. Developers need to provide types for useParams hook (i.e. useParams<{ id: string }>). It has the same issue with refactoring. Developers need to update useParams hooks whenever there's a change in URL parameter names.

Solution (Walkthrough)

I ended up implementing something I am happy with. Example source code is available on a GitHub repo. I hope this can help others who desire typed routes. This post is mostly annotation of my implementation, so if you prefer reading source code directly, check out the GitHub repo.

src/hooks/paths.tsx

The single source of truth for available paths is defined in this module. If a route needs to be modified, this PATH_SPECS can be fixed, then TypeScript compiler will raise errors where type incompatibilities are found.

const PATHS = [
  '/',
  '/signup',
  '/login',
  '/post/:id',
  '/calendar/:year/:month',
] as const;
Enter fullscreen mode Exit fullscreen mode

Utility types can be derived from this readonly array of paths.

type ExtractRouteParams<T> = string extends T
    ? Record<string, string>
    : T extends `${infer _Start}:${infer Param}/${infer Rest}`
    ? { [k in Param | keyof ExtractRouteParams<Rest>]: string }
    : T extends `${infer _Start}:${infer Param}`
    ? { [k in Param]: string }
    : {};

export type Path = (typeof PATHS)[number];

// Object which has matching parameter keys for a path.
export type PathParams<P extends Path> = ExtractRouteParams<P>;
Enter fullscreen mode Exit fullscreen mode

Small amount of TypeScript magic is applied here, but the end result is quite simple. Note how PathParams type behaves.

  • PathParams<'/post/:id'> is { id: string }
  • PathParams<'/calendar/:year/:month'> is { year: string, month: string }
  • PathParams<'/'> is {}

From here, a type-safe utility function is written for building URL strings.

/**
 * Build an url with a path and its parameters.
 * @example
 * buildUrl(
 *   '/a/:first/:last',
 *   { first: 'p', last: 'q' },
 * ) // returns '/a/p/q'
 * @param path target path.
 * @param params parameters.
 */
export const buildUrl = <P extends Path>(
  path: P,
  params: PathParams<P>,
): string => {
  let ret: string = path;

  // Upcast `params` to be used in string replacement.
  const paramObj: { [i: string]: string } = params;

  for (const key of Object.keys(paramObj)) {
    ret = ret.replace(`:${key}`, paramObj[key]);
  }

  return ret;
};
Enter fullscreen mode Exit fullscreen mode

buildUrl function can be used like this:

buildUrl(
  '/post/:id',
  { id: 'abcd123' },
); // returns '/post/abcd123'
Enter fullscreen mode Exit fullscreen mode

buildUrl only takes a known path (from PATHS) as the first argument, therefore typo-proof. Sweet!

src/components/TypedLink

Now, let's look at TypedLink a type-safe alternative to Link.

import { Path, PathParams, buildUrl } from '../hooks/paths';
import React, { ComponentType, ReactNode } from 'react';

import { Link } from 'react-router-dom';

type TypedLinkProps<P extends Path> = {
  to: P,
  params: PathParams<P>,
  replace?: boolean,
  component?: ComponentType,
  children?: ReactNode,
};

/**
 * Type-safe version of `react-router-dom/Link`.
 */
export const TypedLink = <P extends Path>({
   to,
   params,
   replace,
   component,
   children,
}: TypedLinkProps<P>) => {
  return (
    <Link
      to={buildUrl(to, params)}
      replace={replace}
      component={component}
    >
      {children}
    </Link>
  );
}
Enter fullscreen mode Exit fullscreen mode

TypedLink can be used like this:

<TypedLink to='/post/:id' params={{ id: 'abcd123' }} />
Enter fullscreen mode Exit fullscreen mode

The to props of TypedLink only takes a known path, just like buildUrl.

src/components/TypedRedirect.tsx

TypedRedirect is implemented in same fashion as TypedLink.

import { Path, PathParams, buildUrl } from '../hooks/paths';

import React from 'react';
import { Redirect } from 'react-router-dom';

type TypedRedirectProps<P extends Path, Q extends Path> = {
  to: P,
  params: PathParams<P>,
  push?: boolean,
  from?: Q,
};

/**
 * Type-safe version of `react-router-dom/Redirect`.
 */
export const TypedRedirect = <P extends Path, Q extends Path>({
  to,
  params,
  push,
  from,
}: TypedRedirectProps<P, Q>) => {
  return (
    <Redirect
      to={buildUrl(to, params)}
      push={push}
      from={from}
    />
  );
};
Enter fullscreen mode Exit fullscreen mode

src/hooks/index.tsx

Instead of useParams which cannot infer the shape of params object, useTypedParams hook can be used. It can infer the type of params from path parameter.

/**
 * Type-safe version of `react-router-dom/useParams`.
 * @param path Path to match route.
 * @returns parameter object if route matches. `null` otherwise.
 */
export const useTypedParams = <P extends Path>(
  path: P
): PathParams<P> | null => {
  // `exact`, `sensitive` and `strict` options are set to true
  // to ensure type safety.
  const match = useRouteMatch({
    path,
    exact: true,
    sensitive: true,
    strict: true,
  });

  if (!match || !isParams(path, match.params)) {
    return null;
  }
  return match.params;
}
Enter fullscreen mode Exit fullscreen mode

Finally, useTypedSwitch allows type-safe <Switch> tree.

/**
 * A hook for defining route switch.
 * @param routes 
 * @param fallbackComponent 
 */
export const useTypedSwitch = (
  routes: ReadonlyArray<{ path: Path, component: ComponentType }>,
  fallbackComponent?: ComponentType,
): ComponentType => {
  const Fallback = fallbackComponent;
  return () => (
    <Switch>
      {routes.map(({ path, component: RouteComponent }, i) => (
        <Route exact strict sensitive path={path}>
          <RouteComponent />
        </Route>
      ))}
      {Fallback && <Fallback />}
    </Switch>
  );
}
Enter fullscreen mode Exit fullscreen mode

Here's how <Switch> is usually used:

// Traditional approach.
const App = () => (
  <BrowserRouter>
    <Switch>
      <Route exact path='/' component={Home} />
      <Route exact path='/user/:id' component={User} />
    </Switch>
  </BrowserRouter>
);
Enter fullscreen mode Exit fullscreen mode

The code above can be replaced with the following code.

const App = () => {
  const TypedSwitch = useTypedSwitch([
    { path: '/', component: Home },
    { path: '/user/:id', component: User },
  ]);

  return (
    <BrowserRouter>
      <TypedSwitch />
    </BrowserRouter>
  );
}
Enter fullscreen mode Exit fullscreen mode

Conclusion

Original Replaced
<Link to='/user/123' /> <TypedLink to='/user/:id' params={ id: '123' } />
<Redirect to='/user/123'> <TypedRedirect to='/user/:id' params={ id: '123' } />
useParams() useTypedParams('/user/:id')
<Switch> useTypedSwitch

Type-safe alternatives are slightly more verbose than the original syntax, but I believe this is better for overall integrity of a project.

  • Developers can make changes in routes without worrying about broken links (at least they don't break silently).
  • Nice autocompletion while editing code.

Top comments (5)

Collapse
 
sirmoustache profile image
SirMoustache

You can extract types from path with TypeScript template literal types
like:

type ExtractRouteParams<T> = string extends T
    ? Record<string, string>
    : T extends `${infer _Start}:${infer Param}/${infer Rest}`
    ? { [k in Param | keyof ExtractRouteParams<Rest>]: string }
    : T extends `${infer _Start}:${infer Param}`
    ? { [k in Param]: string }
    : {};

type Params = ExtractRouteParams<'/post/:id'>; // // type is {id: string}
type Params = ExtractRouteParams<'/calendar/:year/:month'>; // type is {year: string; month: string}
Enter fullscreen mode Exit fullscreen mode

Can read more about this here and here

Collapse
 
0916dhkim profile image
Danny Kim

The post & examples are updated, and many duplicates are removed. Your suggestion helped a lot.

Collapse
 
0916dhkim profile image
Danny Kim

Thank you for sharing this feature! It is shocking how this is possible in TS. I am so excited to update my example!

Collapse
 
ramseylove profile image
Ryan Valentine

Love this idea, and works great but cant figure out how to implement modals with this method. Any idea how I would also implement Modal routes that would keep background page location? I don't think a second switch works because the nature how switches only matches one route.

Similar to this example but i do not contextualize whether or not info is presented in a modal or not. Its always in a modal
v5.reactrouter.com/web/example/mod...

Collapse
 
saulpalv profile image
Saul Alonso Palazuelos • Edited

the router is a generic JSX so you can pass the route type as shown in the image

dev-to-uploads.s3.amazonaws.com/up...