Type-safe routing in React with `fp-ts-routing` (part 1)
Introduction
Among the most popular "routing" libraries in the JavaScript ecosystem are React Router and Express. Both of these libraries gained popularity before the TypeScript boom, and subsequently type safety is more of an afterthought. This poses the question: what would a routing library look like if it was designed with type safety in mind?
React Router and Express are built on top of path-to-regexp
, a library that makes it easy to define routes as "pathname pattern strings", for example "/search/:query"
. In TypeScript we want to extract a type to describe the "params" that appear within one of these pathname pattern strings. For example, in the pattern "/search/:query"
we want to extract the type { query: string }
. This is possible using template literal types, however this TypeScript feature is not powerful enough to support the full range of syntax that may be allowed in a pathname pattern string such as optional params.
There is also another big problem with path-to-regexp
: despite the fact that the library's name mentions "path", this library only really helps to match against the pathname rather than the full path. The full path may include query parameters (aka search parameters), and we would like to match against these as well because they often form part of the definition for a route. For example, if we have a "search" route, we might have some query parameters that should be used to filter the search results.
fp-ts-routing
solves both of these problems.
In this first part of this two-part blog post series I'm going to demonstrate how to create a simple React application that uses fp-ts-routing
. To showcase the issues concerning type safety I will start with an example application that uses React Router, and then I will rewrite the same example application to use fp-ts-routing
.
In part 2 I will demonstrate how to handle query parameters as well as diving in to some of the more advanced features of fp-ts-routing
such as the type
function and custom io-ts
types.
React Router
Our example application will have two routes, "home" and "search".
We start by defining a pathname pattern for each route.
tsx
import * asReact from "react";import * asReactDOM from "react-dom";import * asReactRouterDOM from "react-router-dom";constpaths = {Home : "/",Search : "/search/:query",};
To build a link for each route, we can pass the route's pathname pattern to React Router's generatePath
function:
tsx
constNav :React .FC = () => (<nav ><ul ><li ><ReactRouterDOM .Link to ={paths .Home }>Home</ReactRouterDOM .Link ></li ><li ><ReactRouterDOM .Link to ={ReactRouterDOM .generatePath (paths .Search , {query : "dogs and cats",})}>Search</ReactRouterDOM .Link ></li ><li ><ReactRouterDOM .Link to ="/abcdef">Invalid link (to test "not found")</ReactRouterDOM .Link ></li ></ul ></nav >);
To render a component when the URL matches one of our routes, we can use React Router's Route
component:
tsx
constApp :React .FC = () => (<><Nav /><hr /><ReactRouterDOM .Routes ><ReactRouterDOM .Route path ={paths .Home }element ={<Home />} /><ReactRouterDOM .Route path ={paths .Search }element ={<Search />} /><ReactRouterDOM .Route path ="*"element ={<div >Not found</div >} /></ReactRouterDOM .Routes ></>);
Inside of the Search
component, we can read the params for the search route using React Router's useParams
hook:
tsx
constSearch :React .FC = () => {const {query } =ReactRouterDOM .useParams ();return (<div ><h1 >Search</h1 ><dl ><dt >Query</dt ><dd >{query }</dd ></dl ></div >);};
This works but unfortunately it's not type-safe. The param query
has type string | undefined
but it should have type string
because it must exist in order for the search route's pathname pattern to match and for Route
to render this component. Moreover, if we updated the pathname pattern to change the name of the query
param or even remove it, this code would not generate a type error, because useParams
doesn't know the names of the params which appear inside the pathname pattern for this route. This means it's very likely that we would forget to apply the same change inside the component.
tsx
// ❌ No error! ☹️const {i ,may ,not ,exist } =ReactRouterDOM .useParams ();
fp-ts-routing
To introduce fp-ts-routing
, let's migrate our pathname pattern for the search route ("/search/:query"
).
tsx
import * asP from "fp-ts-routing";// Equivalent to `/search/:query` in `path-to-regexp`.constsearchMatch =P .lit ("search").then (P .str ("query")).then (P .end );
In fp-ts-routing
we define each part (or "component") of the path using functions:
lit
(short for literal): takes any string to be matched exactlystr
(short for string): takes a parameter name and it will match any non-empty string
To join the parts together we can use the then
method.
If we inspect the type of searchMatch
we can see it has successfully inferred the type of our params:
tsx
import * asP from "fp-ts-routing";// Equivalent to `/search/:query` in `path-to-regexp`.constsearchMatch =P .lit ("search").then (P .str ("query")).then (P .end );
In fp-ts-routing
, a Match
is an object that contains two properties: parser
and formatter
.
A Parser
parses a string into a params object, if the string matches. For example:
ts
import * asP from "fp-ts-routing";import * asO from "fp-ts/Option";constsearchMatch =P .lit ("search").then (P .str ("query")).then (P .end );constparseRoute = (path : string) => {constroute =P .Route .parse (path );returnP .parse (searchMatch .parser .map (O .some ),route ,O .none );};parseRoute ("/search/dogs%20and%20cats");parseRoute ("/foo");
A Formatter
converts the other way—it formats a params object into a string. For example:
ts
import * asP from "fp-ts-routing";constsearchMatch =P .lit ("search").then (P .str ("query")).then (P .end );P .format (searchMatch .formatter , {query : "dogs and cats " });
Now let's migrate our pathname pattern for the home route ("/"
):
ts
import * asP from "fp-ts-routing";// Equivalent to `/` in `path-to-regexp`.consthomeMatch =P .end ;// Equivalent to `/search/:query` in `path-to-regexp`.constsearchMatch =P .lit ("search").then (P .str ("query")).then (P .end );
Defining the router
Now we have defined Match
s for all of our routes, we need to define a router so we can parse any path string to the closest matching route. Firstly, we need to define a tagged union to represent a parsed route.
ts
// @filename: Route.tsexport typeHome = {};export typeSearch = {query : string;};// @filename: RouteUnion.tsimport * asRoute from "./Route";export typeRouteUnion =| ({_tag : "Home" } &Route .Home )| ({_tag : "Search" } &Route .Search );export constHome = ():RouteUnion => ({_tag : "Home" });export constSearch = (value :Route .Search ):RouteUnion => ({_tag : "Search",...value ,});
To create our router, we need to lift each route's parser to our tagged union type and then we can use alt
to compose them all together.
The router is just another parser which parses a string into our tagged union type, RouteUnion
.
ts
// @filename: Router.tsimport * asP from "fp-ts-routing";import * asO from "fp-ts/Option";import * asRoute from "./Route";import * asRouteUnion from "./RouteUnion";// Equivalent to `/` in `path-to-regexp`.export consthomeMatch :P .Match <Route .Home > =P .end ;// Equivalent to `/search/:query` in `path-to-regexp`.export constsearchMatch :P .Match <Route .Search > =P .lit ("search").then (P .str ("query")).then (P .end );constrouter :P .Parser <RouteUnion .RouteUnion > =P .zero <RouteUnion .RouteUnion >().alt (homeMatch .parser .map (RouteUnion .Home )).alt (searchMatch .parser .map (RouteUnion .Search ));export constparseRoute = (path : string):O .Option <RouteUnion .RouteUnion > => {constroute =P .Route .parse (path );returnP .parse (router .map (O .some ),route ,O .none );};
Example usage:
ts
parseRoute ("/");parseRoute ("/search/dogs%20and%20cats");parseRoute ("/foo");
fp-ts-routing
in React
Using To build a link for each route, we no longer need to use React Router's generatePath
function. Instead, we can use our formatters to create them:
tsx
// @filename: main.tsximport * asP from "fp-ts-routing";import {pipe } from "fp-ts/function";import * asO from "fp-ts/Option";import * asHistory from "history";import * asReact from "react";import * asReactRouterDOM from "react-router-dom";import * asRoute from "./Route";import * asRouter from "./Router";import * asRouteUnion from "./RouteUnion";constNav :React .FC = () => (<nav ><ul ><li ><ReactRouterDOM .Link to ={P .format (Router .homeMatch .formatter , {})}>Home</ReactRouterDOM .Link ></li ><li ><ReactRouterDOM .Link to ={P .format (Router .searchMatch .formatter , {query : "dogs and cats",})}>Search</ReactRouterDOM .Link ></li ><li ><ReactRouterDOM .Link to ="/abcdef">Invalid link (to test "not found")</ReactRouterDOM .Link ></li ></ul ></nav >);
We no longer need to use React Router's Route
component either—we can just use our router:
tsx
constuseRoute = () => {const {pathname ,search } =ReactRouterDOM .useLocation ();constpath =History .createPath ({pathname ,search });constrouteOption =Router .parseRoute (path );returnrouteOption ;};constHome :React .FC <Route .Home > = () => (<div ><h1 >Home</h1 ></div >);constSearch :React .FC <Route .Search > = ({query }) => (<div ><h1 >Search</h1 ><dl ><dt >Query</dt ><dd >{query }</dd ></dl ></div >);constRouteComponent :React .FC <{route :RouteUnion .RouteUnion }> = ({route ,}) => {switch (route ._tag ) {case "Home":return <Home />;case "Search":return <Search {...route } />;}};constApp :React .FC = () => {constrouteOption =useRoute ();return (<><Nav /><hr />{pipe (routeOption ,O .fold (() => <div >Not found</div >,(route ) => <RouteComponent route ={route } />))}</>);};
Unlike our original example which used pathname patterns and React Router's Route
component, this version is type-safe. The Search
component receives the parsed params as props, directly from the route parser.
Whilst the routing is now all handled by fp-ts-routing
, you may have noticed that we are still using React Router, specifically the Link
component and the useLocation
hook. It would be trivial to roll our own versions of Link
and useLocation
but with tree shaking I don't think there's any harm in continuing to use React Router for this. In any case, if you're curious how this might work, see this demo.
fp-ts-routing
in Express
Using Like React, Express also uses path-to-regexp
:
tsx
import * asExpress from "express";constapp =Express .default ();// Pathname pattern string here (passed to `path-to-regexp` under the hood)app .get ("/search/:query", (req ,res ,next ) => {res .send (`Search query: ${req .params .query }`);});app .listen (3000);
Instead of passing pathname patterns to Express, we can just pass *
to catch all requests and then handle the routing ourselves inside of the request handler:
tsx
import * asExpress from "express";import * asP from "fp-ts-routing";import {pipe } from "fp-ts/function";import * asO from "fp-ts/Option";constapp =Express .default ();constsearchMatch =P .lit ("search").then (P .str ("query")).then (P .end );constparseRoute = (path : string) => {constroute =P .Route .parse (path );returnP .parse (searchMatch .parser .map (O .some ),route ,O .none );};app .get ("*", (req ,res ) => {pipe (req .originalUrl ,parseRoute ,O .fold (() => {res .status (404);res .send ("Not found");},({query }) => {res .send (`Search query: ${query }`);}));});app .listen (3000);
To be continued
That's all for part 1! Part 2 coming soon.