Once you start using a language with sound and expressive type system, to fully leverage its advantages you should push loosely typed entities to the edges of application. One such entity is the URL. As you don’t let raw JSON leak into internals of your app, the same way, raw URLs shouldn’t leak either.
What is the URL? URL is stringily typed identifier which is extremely flexible but not safe at all. Its main purpose is to identify the current location of a user in a web app.
Let’s break down what actually needs to be done to make URL-based routing safe:
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.
Before we get started, here is the link to the example apps, in case you want to skim through it or poke around implementation.
There are 2 key modules you should pay attention to:
Route.res
: application routesRouter.res
: router specific codeNow, let’s dive into details.
This step is one of the very first steps performed on an application start. To start rendering UI, we need to know where a user is. Once we figured it, we can either render an appropriate view or redirect the user somewhere else.
Since current route might have only one value at the given moment in time, this is a perfect case for a variant:
To convert 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 url
record:
Let’s implement a function Route.fromUrl
that takes RescriptReactRouter.url
and returns option<Route.t>
.
Alright, now we can deserialize 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 app route:
And finally, we can render the app:
Now when we rendered UI, we should provide a way to navigate from one screen to another. In general, there are 2 ways navigation can be approached:
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 History
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 another one is faster performance-wise but requires additional type. Let’s start with the former.
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 be able to dispatch it to a browser, we need to implement toString
function that would take Route.t
and return a url string that browser expects.
And this is all we need to implement Router.Link
component and Router.push
function.
Note that both these functions accept Route.t
type instead of arbitrary string
, which makes it impossible to dispatch a wrong url through these interfaces.
A link in the app would look like this:
This approach has one downside though. If you render a lot of links, you might notice runtime overhead since on each re-render Route.t
gets serialized into a string. Of course, you can try to memoize things, but if perf is critical, you can get rid of runtime overhead completely by considering the second approach.
This approach is similar to the one we discussed in “Safe Identifiers” 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.
Here, we introduced abstract type t'
. This is the type that would be accepted by Router.Link
component and Router.push
function instead of Route.t
variant. And this is the only change to 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.
Finishing touch to make implementation completely safe is creating Route.rei
interface file, in which we remove 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.
Now you can render links as following:
And that's pretty much it. Happy & safe coding!