Record-based API Route-Handler pairing with Row Types

More than 1 year has passed since last update.

The other day, I was thinking of ways I could prevent a common mistake I make when registering routes for my vidtracker project: not registering a route handler. Even though I share the type definitions used for routes between my frontend and backend, it doesn't help if I forget to actually register a handler for the route.

Luckily, we have the technology to do this with the following:

1) Share the record label used between a record of my API Routes and Route Handlers. By using the same label, I know which is to be paired with which.
2) Use RowToList to convert my row types to type-level lists so I can iterate them. I also take advantage of the fact that the keys are sorted when converted to RowLists.
3) Use RowCons to constrain that the routes and handlers records both have an element for the given single labels.

My Route Types

First, I have my own kind RequestMethod, for which I have two data types:

foreign import kind RequestMethod
foreign import data GetRequest :: RequestMethod
foreign import data PostRequest :: RequestMethod

This is then incorporated into my Route data type:

data Route (method :: RequestMethod) req res (url :: Symbol) = Route

As you can see, Route is a phantom type where I have an associated method, a request type, response type, and a Symbol for the URL. I then have two convenience type aliases:

type GetRoute = Route GetRequest Void
type PostRoute = Route PostRequest

The first alias prevents me from accidentally using the req parameter to try to read the response body, preventing me from writing invalid code that tries to read the request body of a GET request. This is just one example of how phantom types can be incredibly useful for preventing mistakes and save you from having to do extra manual checking work (if a computer can be told to do it fairly easily, why not, right?).

The second is a simple alias allowing me to use all of the remaining parameters.

Then I have my route definitions in a record like so:

apiRoutes ::
  { files :: GetRoute (Array Path) "/api/files"
  , watched :: GetRoute (Array WatchedData) "/api/watched"
  , getIcons :: PostRoute GetIconsRequest Success "/api/get-icons"
  , update :: PostRoute FileData (Array WatchedData) "/api/update"
  , open :: PostRoute OpenRequest Success "/api/open"
  , remove :: PostRoute RemoveRequest Success "/api/remove"
apiRoutes =
  { files: Route
  , watched: Route
  , getIcons: Route
  , update: Route
  , open: Route
  , remove: Route

So while the runtime representation isn't very interesting, the static information we get from the phantom type parameters is more than enough for us to statically prepare code paths.

Iterating Routes and Handlers

So from (2) above, we can write our exposed function that works with any Monad:

registerRoutes :: forall routes handlers routesL handlersL m
   . RowToList routes routesL
  => RowToList handlers handlersL
  => Monad m
  => RoutesHandlers routesL handlersL routes handlers m
  => Record routes
  -> Record handlers
  -> m Unit
registerRoutes routes handlers =
    (RLProxy :: RLProxy routesL)
    (RLProxy :: RLProxy handlersL)

As usual, routes and handlers are the row type # Type from the routes and handlers records passed in, for which I create RowLists by using RowToList. Then I use the method for the implementation and pass in the records for my RoutesHandlers type class defined as such:

class RoutesHandlers
  (routesL :: RowList)
  (handlersL :: RowList)
  (routes :: # Type)
  (handlers :: # Type)
    registerRoutesImpl :: forall proxy
       . Monad m
      => proxy routesL
      -> proxy handlersL
      -> Record routes
      -> Record handlers
      -> m Unit

So our type class takes in the RowLists it can use to match instances and iterate with, the row types for the actual records, and our monad. The biggest reason we explicitly pass in the row types is because otherwise, I would have to remove fields from my records to pass them down, whereas I only need to get fields out of them. If this note doesn't make sense, come back to it after you've read through this blog post and some other RowToList resources.

So how do these get implemented?

Nil/Nil case

So I want to make sure that when I go through the routes and handlers, I should reach the Nil case on both at the same time. By defining a Nil Nil case and not Nil xs or xs Nil, I am able to enforce this (otherwise there is no matching instance for this case). And so in the case of Nil Nil, I need to do nothing, so the instance is defined as such:

instance routesHandlersNil :: RoutesHandlers Nil Nil trash1 trash2 m where
  registerRoutesImpl _ _ _ _ = pure unit

Cons name/Cons name case

So since the RowList is sorted by the label, we have guaranteed pairwise iteration, which is quite convenient for (1), that we use the same label. By writing our instance head to use the same variable name, we can make sure that the instance matches when the names are the same:

instance routesHandlersCons ::
  ( RoutesHandlers rTail hTail routes handlers m
  , IsSymbol name
  , RowCons name handler trash1 handlers
  , RowCons name route trash2 routes
  , RegisterHandler route handler m
  ) => RoutesHandlers
         (Cons name route rTail)
         (Cons name handler hTail)
         m where
  registerRoutesImpl _ _ routes handlers = do
    registerHandlerImpl (get nameP routes) (get nameP handlers)
    registerRoutesImpl (RLProxy :: RLProxy rTail) (RLProxy :: RLProxy hTail) routes handlers
      nameP = SProxy :: SProxy name

From top to bottom:

  • The tails of the routes and handlers RowLists, along with the original record row types and the Monad m are passed down to run through the rest of the list.
  • The name needs to be a Symbol for some of our operations below.
  • We constrain with RowCons that there is a field with a label name and type handler, added on to a trash1 row that we don't really care about, which forms the handlers row. Put another way, we ensure that name :: handler exists in our handlers row type.
  • The same is done with route and routes.
  • Then we use our other type class RegisterHandler, which provides registerHandlerImpl.

The body here does two things. First, it uses RegisterHandler to register the route and the handler that we're getting out of the records. Then, it uses RoutesHandlers to operate on the rest of the routes and handlers. Now to look more at RegisterHandler.

Registering our Handlers

For our type class here, we need a single route, its associated handler, and the monad that we're operating with.

class RegisterHandler route handler m
  | route -> handler m
    registerHandlerImpl :: route -> handler -> m Unit

I use the functional dependencies here to make sure that the correct instance is chosen based on the route. I do this because I need to handle the two cases I had above: the POST and GET cases.

As I wrote earlier, the handlers correspond to the route definition:

route: Route PostRequest FileData      (Array WatchedData) "/api/update"
handler:                 FileData -> m (Array WatchedData)

route: Route GetRequest Void (Array WatchedData) "/api/watched"
handler:                   m (Array WatchedData)

So with this information, we can write the instances accordingly.


For ease of use and implementation, I provide my handlers as functions for Aff, and use the purescript-express AppM for the monad returned.

instance registerHandlerPost ::
  ( IsSymbol url
  , ReadForeign req
  , WriteForeign res
  ) => RegisterHandler
         (Route PostRequest req res url)
         (req -> Aff (express :: EXPRESS | e) res)
         (AppM (express :: EXPRESS | e)) where
  registerHandlerImpl route handler =
    E.post route' handler'
      route' = reflectSymbol (SProxy :: SProxy url)
      handler' = do
        body <- getBody
        case runExcept (read body) of
          Right (r :: req) -> do
            response <- liftAff $ handler r
            sendJson $ write response
          Left e -> do
            setStatus 400
            sendJson $ write {error: show e}

So the constraints are the IsSymbol url again to reflect the URL to use to register the route, ReadForeign req for parsing the request JS object to the type I want to work with, and WriteForeign res for encoding the response type to the JS object expected.

Note that in the instance head, I am doing the matching that I wrote about again: the req and res types are in the corresponding positions such that the handler must be correctly typed and implemented for the route. This lets me avoid any problems with accidentally returning the wrong type from a given route.

The implementation, on the other hand, is all normal code you'd write for purescript-express.


With the POST instance written, the GET instance comes out about the same:

instance registerHandlerGet ::
  ( IsSymbol url
  , WriteForeign res
  ) => RegisterHandler
         (Route GetRequest Void res url)
         (Aff (express :: EXPRESS | e) res)
         (AppM (express :: EXPRESS | e)) where
  registerHandlerImpl route handler =
    E.get route' handler'
      route' = reflectSymbol (SProxy :: SProxy url)
      handler' = do
        response <- liftAff handler
        sendJson $ write response

In this case, as req does not exist for GET requests, the handler provided doesn't need to be anything more than Aff res.


And that's about it, really. The actual usage then looks like this:

routes config = do
  -- ...
    { files: getFiles config
    , watched: getWatchedData config
    , getIcons: getIconsData config
    , update: updateWatched config
    , open: openFile config
    , remove: removeFile config
  -- ...

And while we could fix this up in a variety of ways such as using ReaderT with this config for Aff or writing another fancy function to apply config to a heterogenous functions of config, really, we don't need to for now, since this is not really error-prone as you can't forget to apply config.

Now all the apiRoutes can be called from my client and the handlers are guaranteed to be implemented here.


People have been asking me for a while about how they might go about using row types and these strange RowCons and RowToList tools and such, but I hope this provides more examples of how some of this stuff works.

If you have any questions about this or anything else PureScript, please ask on the FP Slack #purescript or #purescript-beginners channel, on r/purescript, or me directly on Twitter@jusrin00!