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
-
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. - Developers need to provide types for
useParams
hook (i.e.useParams<{ id: string }>
). It has the same issue with refactoring. Developers need to updateuseParams
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;
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>;
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;
};
buildUrl
function can be used like this:
buildUrl(
'/post/:id',
{ id: 'abcd123' },
); // returns '/post/abcd123'
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>
);
}
TypedLink
can be used like this:
<TypedLink to='/post/:id' params={{ id: 'abcd123' }} />
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}
/>
);
};
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;
}
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>
);
}
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>
);
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>
);
}
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)
You can extract types from path with TypeScript template literal types
like:
Can read more about this here and here
The post & examples are updated, and many duplicates are removed. Your suggestion helped a lot.
Thank you for sharing this feature! It is shocking how this is possible in TS. I am so excited to update my example!
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...
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...