Making the React Router Type-Safe
Aug 07 2024 · 20 min
The React Router developed by Remix is a solid choice for declarative routing, which has proven to be a stable and reliable library over the years. The only problem with React Router, though, is that it does not natively support type-safe routing. With a bit of TypeScript magic, however, this can be made possible 💪.
Our goal is to write a wrapper around the Link
component from React Router, which extends the interface of the Link with the following props:
A to
prop that only accepts strings derived as logical paths from the Router and a params
prop that accepts dynamic path elements as key-value pairs.
The component should ultimately work like this:
// Correct:
<Link
to="/users/:userId/tasks/:taskId"
params={{ userId: 1, taskId: 2 }}
>
My Task
</Link>
// Incorrect:
<Link
to="/users/:userId/tasks/:taskId"
params={{ userId: 1, incorrectKey: 2 }} // <-- 'incorrectKey' does not exist in type '{ userId: string, taskId: string }'.
>
My Task
</Link>
Content:
- Introducing RouteTreeNode and Related Types
- Defining the Router Configuration
- Defining Type-Safe Paths
- The Link Component
- Conclusion
Introducing RouteTreeNode and Related Types
To begin with, we need to define a structure that allows us to add custom metadata to our routes and ensure type safety throughout our application. Here’s a detailed look at how we can achieve this:
The RouteHandle Interface
The RouteHandle
interface allows us to attach custom metadata to our routes. For instance, we can specify a pageTitle
.
export interface RouteHandle {
pageTitle: string;
}
Extending React Router Types
Next, we need to extend the existing types from React Router to incorporate our custom RouteHandle
and ensure type safety for nested routes.
IndexRouteTreeNode
Type
The IndexRouteTreeNode
type extends the IndexRouteObject
from React Router. We omit the default handle
property and replace it with our custom RouteHandle
.
export type IndexRouteTreeNode = Omit<IndexRouteObject, "handle"> & {
handle?: RouteHandle;
};
NonIndexRouteTreeNode
Type
Similarly, the NonIndexRouteTreeNode
type extends the NonIndexRouteObject
. We omit the children
and handle
properties, replacing them with our own structures to support nested routes and custom metadata.
export type NonIndexRouteTreeNode = Omit<
NonIndexRouteObject,
"children" | "handle"
> & { handle?: RouteHandle; children?: { [key: string]: RouteTreeNode } };
Combining into RouteTreeNode
Finally, we create a union type, RouteTreeNode
, which can be either an IndexRouteTreeNode
or a NonIndexRouteTreeNode
. This forms the basis of our type-safe route tree.
export type RouteTreeNode = IndexRouteTreeNode | NonIndexRouteTreeNode;
Defining the Router Configuration
We can now define a router object that serves as the blueprint for our app’s routes. This object is structured to accommodate nested routes and custom metadata, ensuring that each route is type-safe and easily navigable.
You will notice that it is almost identical to how you would define your routes for react-router, with the slight difference that now a route’s children are defined as an object instead of an array, as per the RouteTreeNode
type.
Make sure to mark the router as const
, as this will ensure we can correctly infer all route paths from the router.
const router = {
element: <RootLayout />,
errorElement: <AppErrorElement />,
children: {
users:
path: 'users',
children: {
overview: {
element: <UsersPage />,
index: true,
},
user: {
path: ':userId',
element: <UserPage />,
children: {
tasks: {
path: 'tasks',
element: <UserTasksPage />,
children: {
task: {
path: ':taskId',
element: <UserTaskPage />,
},
},
},
},
},
},
},
},
} as const satisfies RouteTreeNode;
Defining Type-Safe Paths
Now lets dive deeper to the center of our TypeScript magic: To achieve type-safe routing, we need to define a structure that captures all possible route paths while ensuring type safety. We’ll start by defining a utility type that constructs a union of all possible paths in our route tree.
Paths
Type
The Paths
type is a mapped type that iterates over the keys of the children
object and constructs a union type of all possible paths for each child. This ensures that our route paths are correctly inferred and type-safe.
type Paths<TChildren extends Record<string, RouteTreeNode>> = {
[Key in keyof TChildren]: TChildren[Key] extends RouteTreeNode
? TChildren[Key]["path"] extends string
?
| TChildren[Key]["path"]
| `${TChildren[Key]["path"]}/${NestedPaths<TChildren[Key]>}`
: never
: never;
}[keyof TChildren];
NestedPaths
Type
With the NestedPaths
type we recursively build a union of all possible nested paths (Paths
calls NestedPaths
which calls Paths
). This is crucial for handling deeply nested route structures.
export type NestedPaths<T extends RouteTreeNode> =
T["children"] extends Record<string, RouteTreeNode>
? Paths<T["children"]>
: never;
Combining into RouterPaths
Finally, we combine these types to create a RouterPaths
type that represents all possible paths in our route tree. This type can be used throughout your application to ensure that route paths are type-safe.
// --> "/users" | "/users/:userId" | "/users/:userId/tasks" | "/users/:userId/tasks/:taskId" | "/"
export type RouterPaths = `/${NestedPaths<RouteTree>}` | "/";
The Link Component
Now that we’ve built a type that creates a string union from all possible paths, we can start building our Link component. First, we need to define some types and utilities to build our link parameters while ensuring type safety.
Defining the ExtractRouteParams Type
The ExtractRouteParams
type is a powerful utility that parses a given route path and extracts its dynamic parameters as key-value pairs. This type uses TypeScript’s conditional types and template literal types to recursively iterate over the path segments, identifying parameters prefixed with a colon (:) and constructing an object type with these parameters as keys.
Here’s the breakdown of how it works:
- We start by checking if the input path (
TPath
) is a string. IfTPath
is a generic string, we return never, as we cannot infer specific parameters from a generic string. - If the path contains a parameter (denoted by
:${Param}
) followed by more path segments (/${Rest}
), we recursively extract parameters from the rest of the path and combine them into a single object type. - If the path ends with a parameter (
:${Param}
), we create an object with this parameter as a key. - If the path does not contain any parameters, we fall back to an empty object.
export type ExtractRouteParams<TPath extends string> = string extends TPath
? never
: TPath extends `${infer _Start}:${infer Param}/${infer Rest}`
? { [K in Param | keyof ExtractRouteParams<Rest>]: string }
: TPath extends `${infer _Start}:${infer Param}`
? { [K in Param]: string }
: object;
By leveraging this utility type, we can ensure that our route parameters are correctly inferred and type-safe, reducing the risk of runtime errors due to mismatched parameters.
Defining the RoutePathWithParams Type
The RoutePathWithParams
type combines a route path with its corresponding parameters. It uses the ExtractRouteParams
type to ensure that the params
prop matches the expected parameters for the given to
path. This type is crucial for creating type-safe routing components, such as a custom Link
component that enforces correct parameter usage.
export type RoutePathWithParams<TPath extends RouterPaths> = {
to: TPath;
params?: ExtractRouteParams<TPath>;
};
Building the Path Utility Function
To ensure type safety when constructing paths with parameters, we need a utility function that validates and builds the path string. Here’s how we can achieve this:
const isStringRecord = (obj: object): obj is Record<string, string> =>
Object.values(obj).every((value) => typeof value === "string");
export const buildPathWithParams = (routeParams: object, target: string) => {
// This type guard is needed, as routeParams has to fall back to object
// or type inference would not work
if (!isStringRecord(routeParams)) {
throw new Error(
"routeParams does not match the type Record<string, string>"
);
}
const paramKeys = Object.keys(routeParams);
const pathsSegments = target.split("/").map((segment) => {
if (!segment.startsWith(":")) return segment;
let segmentWithParamValue = segment;
paramKeys.forEach((key) => {
if (!segment.includes(key)) return;
segmentWithParamValue = segment.replace(`:${key}`, routeParams[key]);
});
return segmentWithParamValue;
});
return pathsSegments.join("/");
};
export const buildPath = <T extends RouterPaths>({
to,
params,
}: RoutePathWithParams<T>): string =>
params ? buildPathWithParams(params, to) : to;
In the buildPathWithParams
function, we ensure that the routeParams
object is of the correct type and then replace the dynamic segments in the target
path with the corresponding values from routeParams
. This guarantees that the constructed path is both valid and type-safe.
Defining the LinkProps Type
Now we are finally able to define the LinkProps
type. This type combines the properties from the standard RouterLinkProps
with our custom RoutePathWithParams
type. The latter ensures that the to
prop is a valid route path and that the params
prop matches the expected parameters for that path.
export type LinkProps<T extends RouterPaths> = RouterLinkProps &
RoutePathWithParams<T>;
Finalizing the Link Component
Lastly, we build our custom Link
component. This component takes in params
, to
, and any other props (...rest
). It utilizes our buildPath
utility function to construct the correct path string by replacing any dynamic segments in the to
path with their corresponding values from params
.
export const Link = <T extends RouterPaths>({
params,
to,
...rest
}: LinkProps<T>) => <RouterLink to={buildPath({ to, params })} {...rest} />;
Conclusion
By combining TypeScript’s template literal types, React Router’s powerful routing features, and a few utility types, we can create a fully type-safe routing system. This provides compile-time guarantees for correct paths and parameters in our application.
Happy coding! 🎉