On navigating safely

Signpost

Routers have become extremely versatile over the years. A typical router can:

  • code-split / lazy-load chunks based on the current route (in cooperation with your bundler of choice)
  • preload stuff in the background
  • resolve dependencies
  • and a lot more...

What you're about to read isn't concerned with any of that. Instead, it concentrates solely on the aspect of type safety and the routing ergonomics that comes with it. It explores common issues related to type-safe routing in TypeScript applications, examines how different libraries address (or don't) them, and takes a stab at porting some of the good bits to a React Router based setup.

The landscape

Let's envision a simple web-based book reader. What might the routes in such an application look like?

  • All books
  • A specific book
  • Chapter list of a specific book
  • A specific chapter of a specific book
  • Book author

Translated to URLs:

  • '/'
  • '/book/:bookId'
  • '/book/:bookId/chapters'
  • '/book/:bookId/chapters/:chapterId'
  • '/book/:bookId/author'

To complete the example, let's also create a simple navigation API. These vary from framework to framework, yet it's a rather safe bet that we'll get a component that outputs HTMLAnchorElement and a function for navigating programmatically:

import { Link, navigate } from 'my-router';
 
<Link to='/book/1'>Moby-Dick</Link>
 
navigate('/book/1/author');

On one hand, we can call it a day, close the laptop, and go home. On the other hand... let's think about what can potentially go wrong:

  • Our paths are represented as strings. When encoding data as string, one pays a tax; it's good to be aware of that.
  • One can use arbitrary string where a path is expected.
  • A path can get misspelled at any point.
  • A parameter can easily get omitted.
  • Parameters can accidentally switch places.
  • Paths are scattered throughout the codebase.

Can we do better? We can do better, and the areas of potential improvement are plentiful. Take a couple of minutes to think of what would make your router experience better when it comes to path handling. Consider all the little things that boost confidence (it compiles === it works), improve refactoring capabilities, increase readability, etc. Once done, compile a list of requirements. Here's mine:

  1. Type-safe paths. Whatever the implementation is, I want the TypeScript compiler to make it impossible (or nearly impossible) to use a value outside of the set of valid paths. I accept the fact that type assertions and any have the power of bypassing even the strongest type constraints.
  2. Minimal repetition. This can also be expressed as the presence of some abstraction. An abstraction that makes the API surface easy to understand, freeing one from having to craft imperative descriptions of what needs to happen.
  3. Named parameters. While other languages come with support for both named and positional parameters, TypeScript only offers the latter. Why does that even matter? Routes, especially nested ones, are highly likely to require two or more parameters, which in turn requires us to populate two or more slots to properly materialise the path. Whether we populate these slots in the correct order, of course, depends on our diligence. But wouldn't it be nice if the router itself were helpful in that regard?
  4. No ambiguity. Having previously configured the following routes: '/a' and '/:a', which specific route is one navigating to upon clicking the '/a' link?

Fortunately, these observations are not solely mine, and there are indeed several implementations in the wild that meet (to some extent) these requirements. There are also some that do not. Let's examine a couple:


ℹ️ The article references specific versions of 3rd-party libraries (available at the time of writing):

  • typescript@5.2.2
  • react@18.2.0
  • next@14.0.4
  • @tanstack/react-router@1.15.17
  • react-router-dom@6.21.1

Next.js

Next.js router uses the file system as its source of truth - all the required information is gathered at build time. In order to make this work (properly infer path segments, parameters, etc) one must adhere to a specific convention when naming files/directories, for example:

└── src
    └── app
        ├── page.tsx
        └── [param]
            └── page.tsx

will make the router infer the following paths:

  • '/'
  • '/:param'

One could assume that utilizing the compilation process in routing opens up a range of possibilities and would not be mistaken. Thing is is that Next.js does not fully leverage these possibilities yet. The only thing we get is an obscure, experimental setting called typedRoutes.

When typedRoutes is set to true, the following things happen:

  • APIs such as <Link /> and router.push now use a union type of app paths instead of string
  • We receive instant feedback from the TypeScript compiler; it throws a compilation error when an invalid path is encountered
  • Build script feedback is provided; we can no longer build the app if an invalid path has been used

The implementation of typedRoutes has a few holes though:

  • Path autocompletion also includes API routes (?)
  • It only affects the string syntax, thus it is only partially type-safe; the object syntax remains intact (pathname remains a string).

Next.js also fails to shine brightly within the scope of our previously defined requirements. Unless one builds their own abstraction atop it, routing devolves into a festival of string:

<Link href={`/segmentA/${paramA}/segmentB/${paramB}`}>
  Somewhere
</Link>

Since one is forced to stitch the paths together themselves, the type safety aspect of Next.js often falls short. Nothing (neither the TypeScript compiler, nor the build script) will prevent one from navigating to a non-existent '/foo' path when another path with a dynamic segment exists at the same level.

TanStack Router

TanStack Router takes a very peculiar approach. Its answer to the question "File-based or code-based routing?" is "Hold my beer". And boy does it nail both.

Depending on one's preference, routes can be configured:

  • Using files. Next.js-style, one might say, but flattened (routes are inferred from file names). In order to make this work, we must either install a dedicated CLI (comes with generate and watch commands) or a Vite plugin.
  • Using code. A more "traditional" style, where the routes are defined programatically.

Having chosen one of the above, it now only takes:

  • Internalising that the route type safety depends on a clever use of declaration merging,
  • Coming to peace with a few not immediately obvious design decisions (yes, I'm thinking of you, getParentRoute),
  • Accepting the fact that the type definitions are mostly unreadable,

and the magic starts happening 🪄.

TanStack Router ticks nearly all the boxes. Routes are type-safe - every new route registration amends the list of available path values, making it nearly (more details on this later) impossible to make a mistake. Parameters are automatically inferred for dynamic routes; the TS compiler won't stop yelling until one provides values for all of them. That's done using object syntax, making it very convenient. Furthermore, the issue of route ambiguity does not exist.

Not everything, however, is perfect:

  1. Repetition. Whether navigating via <Link /> or programatically, we need to provide the whole path. Unlike in Next.js, one does not interpolate paths manually - the path contains parameter names. While this helps determining the exact route, it feels a bit redundant.
  2. Broad vs. narrow types. One must ensure that the type of path is not accidentally inferred as string, as TanStack Router will gladly accept it, even if it's invalid:
let path = '/no-such-path'; // Inferred as `string`
 
<Link to={path} /> // Compiles just fine 😱

All things considered, it's still a solid and versatile router. Now, onto the last contender...

React Router

While its name suggests it's the go-to router in React applications, it's actually far from being that. In regard to our requirements, well, React Router meets none:

  • Paths are represented as string. Neither there's any type narrowing, nor the parameters are inferred. Zero, zip, zilch, nada.
  • Just like in Next.js, one has to manage paths on their own, which without a proper abstraction can get really problematic.
  • There's no way (again, without a proper abstraction in place) to reliably tell if one's navigating to a fixed or dynamic route.

As far as type safety is concerned, React Router is a product of the pre-TypeScript era.

How about we fix it?

Type-safe React Router

Let's start with the basics: type-safe paths. Some time ago, React Router switched from a component-based to a data-based approach for configuring routes, which, in the context of type safety, is good news - one can use that data to infer paths.

Here's what Next.js and TanStack Router are doing (more or less):

export const routes = [
  {
    path: "/",
    children: [
      {
        path: "book/:bookId",
        children: [
          {
            path: "chapters",
            children: [
              {
                path: ":chapterId",
              },
            ],
          },
          {
            path: "author",
          },
        ],
      },
    ],
  },
] as const satisfies RouteObject[];
 
type Paths<TRoute, TPath extends string = ""> = TRoute extends [
  infer THead,
  ...infer TTail,
]
  ? Paths<THead, TPath> | Paths<TTail, TPath>
  : TRoute extends NonIndexRouteObject
  ? PathJoin<
      TPath,
      TRoute["path"] extends string ? TRoute["path"] : ""
    > extends infer P extends string
    ? Paths<TRoute["children"], P> | P
    : never
  : TRoute extends IndexRouteObject
  ? TPath
  : never;
 
type Test = Paths<typeof routes>; // "/" | "/book/:bookId" | "/book/:bookId/chapters" | "/book/:bookId/chapters/:chapterId" | "/book/:bookId/author"

View source

Most likely, there exists a universe where this is sufficient. In our universe, it only satisfies a single requirement. Let's further improve it.

We've already established there must be a better way than having to provide the entire path string inline. If you haven't yet noticed, I've been desperately trying to dismiss it as too imperative. What would a declarative counterpart look like? Let's seek for the answer in the realm of object-oriented programming:

/* Implementation details */
abstract class Animal {
  abstract speak(): string;
}
 
class Dog extends Animal {
  override speak() {
    return "woof, woof";
  }
}
 
/* A lovely API surface */
const buddy = new Dog();
 
buddy.speak();

View source

What if I told you (yep, a cheesy Matrix meme reference) similar ergonomics can be had with React Router, with just a few tweaks? Consider the following code snippet:

<Link to={paths.books()}>All books</Link>
 
<Link to={paths.book({ bookId: '1' })}>Moby-Dick</Link>
 
navigate(paths.author({ bookId: '1' }));

Not bad, right? But that's not all:

  • The paths object does not require maintenance - it's auto-generated from the router configuration. Not only are the paths now type-safe, but the repetition is also eliminated.
  • All parameter names are inferred and made required when calling paths.<slug>(). The object syntax also makes parameter handling very explicit.
  • Neither Link nor navigate will accept anything other than a return type of paths.<slug>(). Goodbye, ambiguity.

First of all, let's transform our paths into path functions. A keen eye may have already noticed that the function names must have been derived from somewhere. But from where exactly? Out of the box, React Router's route configuration doesn't provide anything convenient:

We must, of course, amend these types slightly.

import {
  type IndexRouteObject as OrigIndexRouteObject,
  type NonIndexRouteObject as OrigNonIndexRouteObject,
} from "react-router-dom";
 
type TypedOmit<T, K extends keyof T> = Omit<T, K>;
 
type IndexRouteObject = OrigIndexRouteObject & {
  slug: string;
};
 
type NonIndexRouteObject = TypedOmit<OrigNonIndexRouteObject, "children"> & {
  slug: string;
  children?: RouteObject[];
};
 
type RouteObject = IndexRouteObject | NonIndexRouteObject;

View source

Every route object (index and non-index) now comes with a slug property which will be used for the corresponding path function name. At this point our routes look like this:

const routes = [
  {
    slug: 'books',
    path: '/',
    children: [
      {
        slug: 'book',
        path: 'book/:bookId',
        children: [
          {
            slug: 'chapters',
            path: 'chapters',
            children: [
              {
                slug: 'chapter',
                path: ':chapterId',
              },
            ],
          },
          {
            slug: 'author',
            path: 'author',
          },
        ],
      },
    ],
  },
] as const satisfies RouteObject[];

View source

There's one more decision to make before we proceed to inferring the path functions: what should these functions actually return?

  • string? Nope, that's too vague.
  • Paths<typeof routes>? Better, but still not ideal. Keep in mind that it's still a string. One could craft a similar string inline.

We need a stronger constraint - enter nominal typing.

type AppPath = Brand<string, 'AppPath'>;

View source

Now, to the main course.

type ParamFromChunk<T extends string> = T extends `:${infer P}` ? P : never;
 
type ParamsFromChunks<T extends string[], P = never> = T extends [
  infer THead extends string,
  ...infer TTail extends string[],
]
  ? ParamsFromChunks<TTail, P | ParamFromChunk<THead>>
  : P;
 
type ParamsFromPath<T extends string> = ParamsFromChunks<Split<T, '/'>>;
 
type PathFn<TPath extends string> =
  ParamsFromPath<TPath> extends infer P extends string | never
    ? IsNever<P> extends false
      ? (params: Record<P, string>) => AppPath
      : () => AppPath
    : never;
 
type PathFnFor<S extends string, P extends string> = {
  [slug in S]: PathFn<P>;
};
 
type PathFns<TRoute, TPath extends string = ''> = TRoute extends [
  infer THead,
  ...rest: infer TTail,
]
  ? PathFns<THead, TPath> & PathFns<TTail, TPath>
  : TRoute extends NonIndexRouteObject
  ? PathJoin<
      TPath,
      TRoute['path'] extends string ? TRoute['path'] : ''
    > extends infer P extends string
    ? PathFns<TRoute['children'], P> & PathFnFor<TRoute['slug'], P>
    : never
  : TRoute extends IndexRouteObject
  ? PathFnFor<TRoute['slug'], TPath>
  : unknown;

View source

The PathFns type is simply an evolution of the Paths type. Instead of a string union, it will produce an intersection type, containing functions whose names correspond to route slugs.

declare const paths: PathFns<typeof routes>;
 
const books = paths.books(); // `AppPath`
const author = paths.author({ bookId: '1' }); // `AppPath`

View source

Now, what do we specifically need the AppPath type for? We want to ensure that the only way to generate a valid path is by calling paths.<slug()>. In order for that to work, we need the router APIs to accept AppPath rather than string. The following two specifically:

Let's do it.

import type { ForwardRefExoticComponent, RefAttributes } from 'react';
import {
  type NavigateOptions,
  type LinkProps as OrigLinkProps,
  Link as OrigLink,
  useNavigate as origUseNavigate,
} from 'react-router-dom';
 
type Brand<T, B extends string> = T & { __brand: B };
 
type AppPath = Brand<string, 'AppPath'>;
 
type TypedOmit<T, K extends keyof T> = Omit<T, K>;
 
type Path = {
  pathname: AppPath;
  search: string;
  hash: string;
};
 
type To = AppPath | Partial<Path>;
 
type LinkProps = TypedOmit<OrigLinkProps, 'to'> & {
  to: To;
};
 
const Link: ForwardRefExoticComponent<
  LinkProps & RefAttributes<HTMLAnchorElement>
> = OrigLink as any;
 
interface NavigateFunction {
  (to: To, options?: NavigateOptions): void;
  (delta: number): void;
}
 
const useNavigate: () => NavigateFunction = origUseNavigate as any;

View source

From here, it only takes creating a function that actually materialises the shape described by the type PathFns and we're done 🎉.

declare const makePaths: <T extends RouteObject[]>(routes: T) => PathFns<T>;

But... are we, really? Not quite. There's one subtle detail that we haven't yet taken into consideration. The route object we've been working with so far had the following structure:

{
  slug: 'author',
  path: 'author',
}

whereas, realistically, we'll be dealing with the following one:

{
  slug: 'author',
  path: 'author',
  component: <Author />,
}

Consider the following import chain:

  • paths.ts imports routes.tsx
  • routes.tsx imports Author.tsx
  • Author.tsx imports paths.ts

You just got yourself an unresolvable circular dependency 💥. This is likely to cause the bundler to explode right in your face.

Solution: Do not use router configuration as input for makePaths; instead, utilize a derived, stripped-down version of it - one that does not import application code. The only fields required for generating paths are slug and path; everything else is mere noise. We need a way to consume TypeScript code, transform it, and output the result. In the TypeScript world, the most convenient tool for that is... the TypeScript compiler itself. More specifically, the Compiler API.

While using the compiler for transforming code sounds scary, it really isn't! You'll find a very basic implementation in the...

Example repository

The complete code from this article can be found in the following Github repository: https://github.com/rkrupinski/type-safe-react-router

Highlights:

  • Implementation details are hidden in the router directory, which is then aliased as type-safe-react-router. One might also consider preventing direct imports from react-router-dom via an ESLint rule.
  • The codegen script consumes routes.tsx and outputs paths.ts. The script artifact is included in the repository for demonstration purposes.
  • Given the article solely focuses on type safety, the makePaths function is left for the reader to implement on their own 💪.

Photo by Alexander Schimmeck on Unsplash.