Typesafe Frontend Routing in Rust/Leptos

2025-01-20

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:

  1. a DSL for defining routes parsed with a macro,
  2. magic handler functions,
  3. 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):

<Routes>
  <Route path="dashboard" element={<Dashboard />}>
    <Route index element={<Home />} />
    <Route path="teams/:teamId" element={<Team />} />
  </Route>
</Routes>

And one links to them elsewhere in the app:

<Link to={`/dashboard/teams/${myTeamId}`}>Dashboard</Link>

Also, one can access the teamId "route param" in the Team component by doing something like:

function Team() {
  let teamId = useParams().teamId; // Will have `string | undefined` type.
}

As is, the above doesn't provide type safety that prevents:

  1. Construction of invalid URLs (when, for example, code constructing URLs becoming stale after restructuring routes).
  2. 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:

  1. 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:

function Dashboard() {
    return (
        <div>
            {
                // Stuff that's common to everything that gets rendered
                // under /dashboard/ 

            }
            <Outlet />
        </div>
    )
}

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:

function Dashboard() {
    return (
        <div>
            {
                // Stuff that's common to everything that gets rendered
                // under /dashboard/ 
            }
            <Outlet context=someContextValue />
        </div>
    )
}

// And then:
function Team() {
    // The type here would be `unknown | undefined` unless specifies
    // the type like `useOutletContext<OutletContextType>()`. In that
    // case, it'd be up to the user (and not the type-checker) to
    // ensure that `someContextValue` actually has the
    // `OutletContextType` type.
    let outletContext = useOutletContext();
}

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! {{
    fallback: NotFound,
    view: Sidebar,
    children: [
        {
            path: "/auth",
            view: Auth
        },
        {
            path: "/{owner_name}",
            layout: Sidebar,
            children: [
                {
                    path: "/{repo_name}",
                    layout: RepositoryPage,
                    view: IssuesList,
                    will_pass: RepositoryId,
                    children: [
                        {
                            path: "/pulls",
                            view: PullRequestsTab
                        },
                        {
                            path: "/issues",
                            will_pass: RepositoryId,
                            view: IssuesList,
                            children: [
                                {
                                    path: "/new",
                                    view: NewIssue
                                },
                                {
                                    path: "/{issue_number}",
                                    view: OneIssue
                                },
                            ]
                        },
                    ]
                }
            ]
        }
    ]
}}

Constructing type-checked URLs

let owner_name = "google";
let repo_name = "material-design-icons";
zwang_url!("/owner_name={google}/repo_name={repo_name}/issues/issue_number=1")

Type-safe access to route params

fn IssuesList(
    params: RouteParams<ParamsOwnerNameRepoName>,
) -> impl IntoView {
 // ...
} 

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):

fn IssuesList(
    params: RouteParams<ParamsIssueNumberOwnerNameRepoName>,
) -> impl IntoView {
 // ...
} 

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:

pub fn RepositoryPage(
    outlet: Outlet<RepositoryId, impl IntoView + 'static>,
) -> impl IntoView {
    let repository_id = get_repository_id(); // or something.
    view! { // the leptos macro for "JSX-in-rust"
        <div>
            {outlet.call(repository_id)}
        </div>
    }
}

And then the outlet component can receive that argument as follows:

pub fn IssuesList(repository_id: ArgFromLayout<RepositoryId>) -> impl IntoView {
  // ...
}

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:

pub fn IssuesList(
    params: RouteParams<ParamsOwnerNameRepoName>,
    repository_id: ArgFromLayout<Signal<RepositoryId>>,
) -> impl IntoView {
    // ...
}

Fundamental shortcomings of approach

Given the approach I outlined above, I think the following are fundamental limitations unlikely to be improved with further work:

  1. 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.
  2. 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.