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.
tsximport * 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:
tsxconstNav :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:
tsxconstApp :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:
tsxconstSearch :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").
tsximport * 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:
tsximport * 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:
tsimport * 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:
tsimport * 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 ("/"):
tsimport * 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 Matchs 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:
tsparseRoute ("/");parseRoute ("/search/dogs%20and%20cats");parseRoute ("/foo");
Using fp-ts-routing in React
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:
tsxconstuseRoute = () => {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.
Using fp-ts-routing in Express
Like React, Express also uses path-to-regexp:
tsximport * 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:
tsximport * 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.