Back to list

July 28, 2023

Creating typesafe React router

Building a typesafe React router to catch parameter mismatches at compile-time rather than runtime.

  • react
  • typescript

As the title says I decided to create typesafe React router. The implementation was for one of my work projects that could not use any of available React routers like React Router. The article will mostly focus on creating of simpler version of the router I created for the project, because some features were specific to the project.

Outline

  1. Why would anyone want this?
  2. How to achieve typesafety when router parameters are in string format.
  3. Implementation

Let’s dive in.

Why would anyone want this?

There is more then one reason to why would anyone want typesafe router. For me there are 2 big ones.

  1. Safety
  2. Testability

Safety

Most libraries like React Router pass route parameters using a hook. For example using the useParams hook.

// /profile/:userId
function ProfilePage() {
// Get the userId param from the URL.
let { userId } = useParams();
// ...
}

Did you spot the problem with this snipped? The userId parameter is type of any. Of course you could cast the type to some custom type, but what if someone changes the name of the parameter in the route definition to user-id, below code will not raise an compile time error, it will just fail in browser with some ugly React error.

interface ProfilePageParams {
userId: number;
}
//...
let { userId } = useParams() as ProfilePageParams;
//...

Testability

Above examples have one big problem. If you would place this React component somewhere outside the router, it would fail at runtime and you would not have a simple way to place it for example to storybook or to write unit test for the component. Some routing libraries provide way to mock the hook. React Router has plugin for storybook that fixes this issue, but still it’s some workaround.

What if we could ignore some global state that is grabbed using hook and write the route page component as any other component.

interface ProfilePageParams {
userId: number;
}
// Instead of this
function ProfilePage() {
// Get the userId param from the URL.
let { userId } = useParams() as ProfilePageParams;
// ...
}
// Something like this
function ProfilePage({ userId }: ProfilePageParams) { // Get the userId param from props.
// ...
}

Now we can pass usedId in properties and our component does not care if its in some router, storybook or just rendered somewhere alone without any connection to the underlining router.

How to achieve typesafety when router parameters are in string format.

At first we have to define what is the format for router parameters. The most common way to indicate part of url is parameter is using colon, like this :userId. There can be more than one parameters in url or even none at all. Some valid examples are as follows.

  • /foo/:fizz
  • /abc/:def/:ghi
  • /:xy
  • /:a/:b/buzz/:c

Luckily TypeScript’s type system is turing complete and values in TypesScript are valid types. Using this features we can write TypeScript type that will parse path of the route into object type that will be passed into the page component.

type PathParams<T extends string, Acc extends object = {}> =
T extends `${infer Prefix}/:${infer Param}/${infer Suffix}`
? PathParams<`/${Suffix}`, Acc & { [K in Param]: string }>
: T extends `${infer Prefix}/:${infer Param}`
? Acc & { [K in Param]: string }
: Acc;
type ParsePath<T extends string> = PathParams<T>;

I will explain the code above.

  • First the typ tries to match our string literal using pattern Prefix/:Param/Suffix where we continue using the suffix for future matching. If its successfully matches It starts to parse again, but using the suffix as path parameter and the constructed object with new key and merged with old object.
  • If this fails to parse it tries to parse path ending with parameter using pattern Prefix/:Param. If this is successful it returns merged object with new keys, because there is no suffix to be parsed more.
  • If any of these fails its jumps to return the accumulator.

Using the ParsePath path we can parse any path I wrote at the start of this section.

type Params1 = ParsePath<"/foo/:fizz">; // {fizz: string}
type Params2 = ParsePath<"/abc/:def/:ghi">; // {def: string, ghi: string}
type Params3 = ParsePath<"/:a/:b/buzz/:c">; // {a: string, b: string, c: string}

Implementation

I would like to more focus on the type part and less on the implementation of browser history and parsing of the path into actual objects so we will be using 2 npm packages history and path-to-regexp.

DISCLAIMER: This is not optimal way to create client side routing, I made it simpler for sake of this blog post.

There will be 3 components in our router implementation.

  • Router - will decide with route to render
  • Route - holds route page and path of the route
  • Link - has same interface as anchor element but works for client side navigation

Router

Router will accept fallback, fallback will used if any of the routes are not suitable, and children which will be Route components.

Here is first part of our Router, where we defined interface of our Router.

interface RouterProps {
fallback: JSX.Element;
children: JSX.Element[] | JSX.Element | undefined; // So the Router can be empty | have one Route | multiple Routes
}
function Router({fallback, children}: RouterProps) {
// ...
}

Next we need to normalize children and because we don’t want to normalize this every time there is change in URL we will wrap it in useMemo. In this step we will create the match function that will then be used to match against change in URL.

// ...
function createMatchFunction(route: JSX.Element) {
if(route.props === undefined) return undefined;
if(typeof route.props.to !== "string") return undefined;
const path: string = route.props.to;
return match(path, { decode: decodeURIComponent }); // match is imported from path-to-regexp npm package
}
function Router({fallback, children}: RouterProps) {
const routes: {component: JSX.Element, match: MatchFunction}[] = useMemo(() => {
if(Array.isArray(children)) {
const routes = [];
for (const child of children) {
const match = createMatchFunction(child);
if(match) routes.push({component: child, match});
}
return routes;
}
if(children !== undefined) {
const match = createMatchFunction(children);
return match ? [{component: children, match: match}] : [];
}
return [];
}, [children]);
// ...
}

It looks like a lot happened above, but we only created singular array with components and their corresponding match functions. Now that we have normalized routes we can add history and actual searching of right route.

// ...
export const history = createBrowserHistory(); // create browser history
export const RouteCtx = createContext({}); // create route ctx to store current route params
function Router({fallback, children}: Router) {
const [location, setLocation] = useState(history.location); // use current location as initial value
const routes = useMemo(() => /* normalization of children */);
useEffect(() => {
return history.listen((update) => { // listen to history changes and update location accordingly
setLocation(update.location);
});
}, []);
for (const route of routes) { // Do linear search for right route
const match = route.match(location.pathname);
if(match === false) continue;
return (
<RouteCtx.Provider value={match.params}> // save route parameters into ctx
{route.component}
</RouteCtx.Provider>
);
}
return fallback; // Fallback if no correct route was found
}

Above we created React to pass URL props to the Route component, this is very similar how React Router does it, but developer who would be using this does not have to call the hook manually, because the parameters will be injected into the page component by the Route component.

Route

Route will accept 2 values.

  • to - path of the route
  • component - component to render if path matches
interface RouteProps<P extends string> {
to: P;
component: (props: ParsePath<P>) => any;
}
function Route<P extends string>({component}: RouteProps<P>) {
const params = useContext(RouteCtx) as any; // here we cast it as any to satisfy compiler.
const Component = component;
return <Component {...props} />; // render component with our props.
}

We can see Route has only 3 jobs.

  1. Hold path of the route
  2. Render the component with parameters from context
  3. Act as guard during compilation for us to pass right component with right path

In link we will use history object we created in Router to push wanted url into browser.

interface LinkProps extends React.ComponentProps<'a'> {}
export default function Link({children, href, ...rest}: LinkProps) {
return <a href={href} onClick={(e) => {
e.preventDefault(); // prevent browser level navigation
history.push(href || ""); // navigate
}} {...rest}>{children}</a>
}

If we put everything together we can use it like this. As you can see TypeScript raises error when we use Foo in route with path /foo/:a because it has different signature.

function App() {
return (
<Router fallback={<div>404 not found</div>}>
<Route to="/" component={Home} />
<Route to="/about" component={About} />
<Route to="/blog/:slug" component={Blog} />
{ /* ERROR: (props: {b: string}) => any is not assignable to (props: {a: string}) */ }
<Route to="/foo/:a" component={Foo} />
</Router>
);
}
interface BlogProps {
slug: string;
}
function Blog({slug}: BlogProps) { /* ... */ }
interface FooProps {
b: string;
}
function Foo({b}: FooProps) { /* ... */ }

That’s all for this post, of course there could be some more typesafety features, for example path-to-regexp supports regex patterns on path params /foo/:page(about|contact|fizz). This could be done again with the type literal parsing.

Full source code from this blog post with an example usage can be found on github.