In my previous article Better developper experience with full typesafe helper functions we saw that to creating helper functions help us to have full typesafety.
And that’s a principle that is used in most of the Tanstack libraries: @tanstack/table, @tanstack/form thanks to hook helper.
But is it the same for @tanstack/router?
When I looked at the documentation on November 2022, it used an helper function!
A createReactRouter
function was exported that exposes all usefull component/hook that are full typesafe based on the routes project configuration:
Link
useRoute
Example of code:
import {
Outlet,
RouterProvider,
createReactRouter,
createRouteConfig,
} from '@tanstack/react-router'
const routeConfig = createRouteConfig().createChildren((createRoute) => [
createRoute({
path: '/',
component: Index,
}),
createRoute({
path: 'about',
component: About,
}),
])
const router = createReactRouter({ routeConfig })
function App() {
return (
<>
<RouterProvider router={router}>
<div>
<router.Link to="/">Home</router.Link>{' '}
<router.Link to="/about">About</router.Link>
</div>
<hr />
<Outlet />
</RouterProvider>
</>
)
}
But if you look at the doc right now, this is not the same API. Now you can directly import Link
from @tanstack/router
and it’s typesafe!
How does it work?
I won’t make a debate on which one you have to use but will concentrate on a major difference between them.
What happens if you declare two time a same type/interface?
When dealing with type, you will have an error:
// Error: Duplicate identifier 'Person'
type Person = {
firstname: string;
};
// Error: Duplicate identifier 'Person'
type Person = {
lastname: string;
};
In other way if you do the same with interface, it will work. This is called Declaration merging , Typescript compiler merge all definitions in one interface:
interface Person {
firstname: string;
}
interface Person {
lastname: string;
}
// Person is { firstname: string; lastname: string}
const person: Person = {
firstname: 'Bob',
lastname: 'TheSponge',
};
The @tanstack/router
API is based on Declaration mergin.
You have to override an interface Register exposed by the library:
import {
Outlet,
RouterProvider,
Link,
Router,
Route,
RootRoute,
} from '@tanstack/react-router'
import { TanStackRouterDevtools } from '@tanstack/router-devtools'
const rootRoute = new RootRoute()
const indexRoute = new Route({
getParentRoute: () => rootRoute,
path: '/',
component: function Index() {
return (
<div>
Home
</div>
)
},
})
const aboutRoute = new Route({
getParentRoute: () => rootRoute,
path: '/about',
component: function About() {
return <div>About</div>
},
})
const routeTree = rootRoute.addChildren([indexRoute, aboutRoute])
const router = new Router({ routeTree })
// Here is one of the smartest thing I saw <3
declare module '@tanstack/react-router' {
interface Register {
router: typeof router
}
}
And the interface in the library is:
// Make sure to export it
export interface Register {}
Yep no implementation because it’s to the project to define it.
There is a nice trick to get the user routes defintion from Register
thanks to infer
and extends
:
type RegisteredRouter = Register extends {
router: infer TRouter extends AnyRouter;
}
? TRouter
: AnyRouter;
It’s time to get our hands dirty. Here I am going to do a simple implementation to show you what was describes in the previous parts.
The type of Route
is pretty simple:
type Route<TPath extends string> = {
path: TPath;
component: FunctionComponent;
};
type Routes<TPath extends string> = Route<TPath>[];
We make a utility function to create routes:
function createRoutes<TPath extends string>(
routes: Routes<TPath>,
) {
// Here we could override routes with lib stuff
// But here for simplicity, do nothing
return routes;
}
And it’s now time to do the magic trick:
// Make sure to export the interface!
export interface Register {}
type AnyRoutes = Routes<string>;
type RegisteredRoutes = Register extends {
routes: infer Routes extends AnyRoutes;
}
? Routes
: AnyRoutes;
And the fully type safe Link
component becomes:
type LinkProps = {
to: RegisteredRoutes[number]["path"];
children: ReactNode;
};
function Link(props: LinkProps) {
const { to, children } = props;
return (
<a
href={to}
onClick={(e) => {
e.preventDefault();
history.pushState(undefined, "", to);
}}
>
{children}
</a>
);
}
Let’s create some routes to see that the Link
component is completely type safe:
import { createRoutes, Link } from "@rainbow/router";
const routes = createRoutes([
{
path: "/",
component: () => <h1>Home page</h1>,
},
{
path: "/about",
component: () => <h1>About page</h1>,
},
]);
// Let's type `@rainbow/router` with the implementation of our routes
declare module "@rainbow/router" {
interface Register {
routes: typeof routes;
}
}
function App() {
return (
<Link to="/about">Go to about page</Link>
// ^? "/" | "/about"
);
}
@tanstack/router
made a revolution in the router world, I am looking forward to see the response from the Remix team to see if they are going to make
react-router
full typesafe.
Of course this trick works for library that are project wide, I mean for a form library it wouldn’t be the best solution because you would probably have more than 1 form.
If you are working in a monorepo with multiple applications , don’t be afraid the configuration of routes won’t be shared. As Tanner Linslay said:
Declaration merging works on a TS config level.
So you could even have different typed router in a same application if you put multiple tsconfig.json
, for example I made this application code .
🔥 Tanstack Router .
💻 Simple Typesafe Router repository .
🎥 The Future of Routing w/ Tanner Linsley by Ryan Carniato.
📝 Type vs Interface: Which Should You Use? by Matt Pocock.
You can find me on Twitter if you want to comment this post or just contact me. Feel free to buy me a coffee if you like the content and encourage me.