Safe Routing in ReScript
Once you start using a language with a sound and expressive type system, you should push loosely typed entities to the edges of an application to leverage its advantages fully. One such entity is a URL. As you don’t let raw JSON leak into the internals of your app, the same way, raw URLs shouldn’t leak either.
What is the URL? URL is a stringily typed identifier that is highly flexible but not safe at all. Its primary purpose is identifying a user's current location in a web app.
Let’s break down what needs to be done to make URL-based routing safe:
- When a URL from a browser hits an application, it should be deserialized from a string into a safe domain-specific type.
- When the application navigates a user to a different view, the value of the next location should be serialized back into a string since this is what a browser expects.
As I mentioned in the beginning, this process is very similar to JSON handling. Let’s see how it might look in an actual app, like a blog.
Foreword
Before we start, here is the link to the example apps if you want to skim through them or poke around the implementation.
There are two key modules you should pay attention to:
Route.res
: application routesRouter.res
: router-specific code
Now, let’s dive into the details.
Url deserialization
This step is one of the very first steps performed at the start of an application. We need to know where a user is to kick off UI rendering. Once we figure it out, we can render an appropriate view or redirect the user elsewhere.
Since the current route might have only one value at the given moment in time, this is a perfect case for a variant:
type t =
| Main
| Posts
| Post({slug: string})
To convert a browser URL into this type, we will use a router that comes with @rescript/react
(honestly, this is all you need to handle routing in your web apps).
It provides a hook RescriptReactRouter.useUrl()
which returns the url
record:
type url = {
path: list<string>,
// ... other props like hash, search, etc.
}
Let’s implement a function Route.fromUrl
that takes a RescriptReactRouter.url
and returns an option<Route.t>
.
type t =
| Main
| Posts
| Post({slug: string})
let fromUrl = (url: RescriptReactRouter.url) =>
switch url.path {
| list{} => Main->Some
| list{"posts"} => Posts->Some
| list{"posts", slug} => Post({slug: slug})->Some
| _ => None // 404
}
Alright, now we can deserialize a URL that comes from a browser into our own safe type. We can wrap RescriptReactRouter.useUrl
hook into a thin application-specific hook that would produce the app route:
let useRouter = () => RescriptReactRouter.useUrl()->Route.fromUrl
And finally, we can render the app:
@react.component
let make = () => {
let route = Router.useRouter()
switch route {
| Some(Main) => <Main />
| Some(Posts) => <Posts />
| Some(Post({slug})) => <Post slug />
| None => <NotFound />
}
}
Navigation
Now, when UI is rendered, we should provide a way to navigate from one screen to another. In general, there are two ways navigation can be approached:
- Via HTML links.
- Programmatically, i.e., dispatching the next location after some side effects, such as login/logout.
Therefore, the task is reduced to the implementation of 2 handlers:
Router.Link
component that handles navigation via HTML links. It should accept the application-specific route and serialize it internally into a string to dispatch the next location to a browser when a user interacts with a link.Router.push
function that takes the application-specific route, serializes it, and dispatches the next location to a browser using theHistory
API (Router.replace
function can be implemented the same way if required).
I can offer two ways to solve this problem: one is more concise but with additional runtime overhead, and the other is faster performance-wise but requires an additional type. Let’s start with the former.
Using a single type for matching and navigation
If your app doesn't have a lot of links, this approach should be good to go. We already have Route.t
type implemented. To dispatch it to a browser, we need to implement a toString
function that would take Route.t
and return a URL string that a browser expects.
let toString = route =>
switch route {
| Main => "/"
| Posts => "/posts"
| Post({slug}) => `/posts/${slug}`
}
And this is all we need to implement the Router.Link
component and the Router.push
function.
let push = (route: Route.t) => route->Route.toString->RescriptReactRouter.push
module Link = {
@react.component
let make = (~route: Route.t, ~children) => {
let location = route->Route.toString
<a
href=location
onClick={event =>
// Simplified implementation of the handler
// See the example repository for the full version
event->ReactEvent.Mouse.preventDefault
location->RescriptReactRouter.push
}>
children
</a>
}
}
Note that both these functions accept Route.t
type instead of an arbitrary string
, which makes it impossible to dispatch a wrong URL through these interfaces.
A link in the app would look like this:
<Router.Link route=Posts>
{"Posts"->React.string}
</Router.Link>
This approach has one downside, though. If you render many links, you might notice runtime overhead since Route.t gets serialized into a string on each re-render. Of course, you can memoize things, but if perf is critical, you can completely eliminate runtime overhead by considering the second approach.
Using different types for matching and navigation
This approach is similar to the one we discussed in the “Safe Identifiers in ReScript” post. We can skip the serialization step and make it zero-cost. The trade-off is that we must introduce a new type and handle packing/unpacking.
type t'
external make: string => t' = "%identity"
external toString: t' => string = "%identity"
let main = "/"->make
let posts = "/posts"->make
let post = (~slug: string) => `/posts/${slug}`->make
Here, we introduced abstract type t'
. This is the type the Router.Link
component and the Router.push
function would accept instead of the Route.t
variant. And this is the only change to the Router
module implementation.
Inside the Route
module, we pack every URL string into Route.t'
, so only URLs defined in this module would be accepted for navigation. It doesn't have runtime cost since we used %identity
external to pack/unpack strings.
The finishing touch to make implementation completely safe is creating a Route.rei
interface file, in which we remove the make
function; thus, it won’t be exposed to the rest of the app, and it won’t be possible to create Route.t'
outside of the Route
module.
type t =
| Main
| Posts
| Post({slug: string})
let fromUrl: RescriptReactRouter.url => option<t>
type t'
// No `make` external here
external toString: t' => string = "%identity"
let main: t'
let posts: t'
let post: (~slug: string) => t'
Now you can render links as follows:
<Router.Link route=Route.posts>
{"Posts"->React.string}
</Router.Link>
And that's pretty much it. Happy & safe coding!