Typesafe Frontend Routing in Rust/Leptos
Presumed audience background: familiarity with web development, and Rust.
TL;DR and take-away
I prototyped a type-safe routing solution for leptos, the full-stack Rust web framework. The approach comprises:
- a DSL for defining routes parsed with a macro,
- magic handler functions,
- a macro to construct typesafe URLs.
If a better alternative for typed routing wasn't available, I personally would think the benefits of type safety outweigh the downsides of the approach (ie, consequences of macro and trait magic). I'm keen to see how leptos-routable will shape up, because it also takes aim at type-safe routing in leptos. And I suspect it will end up as the better approach.
You can take a look at how it looks in an actual codebase here.
What do I mean by "typesafe routing"?
In a web app, one usually has a bunch of routes (let's use react / react-router as an accessible example):
And one links to them elsewhere in the app:
Also, one can access the teamId
"route param" in the Team
component by doing something like:
As is, the above doesn't provide type safety that prevents:
- Construction of invalid URLs (when, for example, code constructing URLs becoming stale after restructuring routes).
- Accessing non-existent route params.
react-router has a solution to this via code generation. tanstack/router
, a more modern routing solution in TS-land, was built with type-safety front of mind.
I'm working on a web app using the leptos full-stack framework in Rust. And the builtin routing solution has no type-safety facilities. So I took a shot at prototyping one.
An explainer on outlets/layouts
In addition to the two goals of type-safe routing above, I also wanted my prototype to tackle:
- Type-safe passing of arguments to outlets.
Going back to the react-router example from up top, notice how the URL /dashboard/teams/:teamId
would match both the <Dashboard />
element specified on line 2 above, and the <Team />
element specified on line 4? How that plays out is that the Dashboard
component would have to be defined in the following manner:
react-router would then render the <Team />
element at the spot that <Outlet />
was invoked in the structure that Dashboard
returns. Dashboard
is often referred to as the layout component in this scenario.
What if one wanted to pass an argument to the Team
component from Dashboard
? In react-router, one would have to do:
// And then:
One can do something similar in leptos via contexts (with a higher degree of type-safety). But for a while, I thought a bug meant I couldn't apply a similar solution for my own specific use-case. But it turns out that I was holding it wrong. In any case, I ended up working towards the passing of arguments to outlets as a third goal for my routing prototype.
What does the "type-safe routing for leptos" prototype look like?
Defining routes
One defines routes like so (note: zwang is just the name of the routing solution I came up with):
zwang_routes!
Constructing type-checked URLs
let owner_name = "google";
let repo_name = "material-design-icons";
zwang_url!
Type-safe access to route params
The ParamsOwnerNameRepoName
is a struct created by the zwang_routes!
macro. The rule of how the name is constructed is that the name of every route param available is pascal-cased, alphabetized, and then concatenated (your IDE will helpfully show it in autocomplete).
If, for instance, we tried to access the issue_number
route param at IssueList
(which doesn't make sense since none of the routes that IssueList
gets rendered at include such a param, we'd get a compiler error):
Passing arguments to outlets
See the lines that say will_pass: RepositoryId
in the route specification above? They indicate to the zwang_routes!
macro that the component will pass an argument of said type. Therefore, the layout component can take an argument like:
And then the outlet component can receive that argument as follows:
We make use of axum-style "magic handler functions", and so a component that we hook up in our routes can decide to take as few or as many of the "magic arguments" that we talked about above. For example:
Fundamental shortcomings of approach
Given the approach I outlined above, I think the following are fundamental limitations unlikely to be improved with further work:
- Macro magic: In general, I'm given to like complex macros like
zwang_routes!
only if I wrote them in the first place, because it's often only then that I transparently understand how they work. - Trait magic: The axum-style magic functions work with trait magic. And debugging why trait-level logic is rejecting whatever you're trying to pass it is challenging enough that axum came up with a macro annotation to help with it.