So I've been messing with Purescript recently. Purescript is a purely functional language that compiles to Javascript. It includes support for things like object literals that make Javascript interop pain-free. It also has mature tooling, making getting started quite simple.

It also has Row Polymorphism. I don't want to exagerate but this is a dream come true when it comes to encoding of best practices into types. For all you Node.js servers out there, here's something to help greatly reduce bugs!

Let's walk through a tiny example. I'm not going to explain much about the type mechanisms themselves, but will try to give you a taste of what is possible:

foreign import data DB :: !

type User = { username :: String, email :: String, uid :: String}

foreign import createUser :: forall e. User -> Eff (write::DB | e) User
foreign import lookupUser :: forall e. String -> Eff (read::DB | e) (Maybe User)

(foreign import means the functions are defined in native Javascript, à la extern in C)

In the type of createUser you have forall e. Eff (write::DB | e) User. This is us saying that createUser writes to the DB, and gives us a User . For people with Haskell experience, Eff is kinda like IO, things with side effects end up in Eff.

The type of lookupUser is similar:

forall e. String -> Eff (read::DB | e) (Maybe User)

Give a String (here, a username), and return an action that will read to the DB and return a User if it was found.


Now to put some of this in action. Here's a simple method that will create a user and then look it up. Maybe you use it for a simple integration test. Maybe you use it as a contrived example:

createThenLookupUser u = do
    createdUser <- createUser u
    lookupUser createdUser.username

So the infered type of this is a bit funky because of our User type, but here's the equivalent type:

createThenLookupUser:: forall e.
                       User -> Eff (read::DB, write::DB | e) (Maybe User)

Check that out! The type inferencer figured out that createThenLookupUser:

  • reads the DB (read::DB)
  • writes to the DB (write::DB)

Row polymorphism here is used to encode effects. We can combine actions within Eff, and the type system will accumulate the effects and keep track of them.


So now that we have these types, how do they help us?

Now for the good bit. I'm going to write a tiny web app that lets us signup by creating a User

First some simple abstractions:

data Method = GET | POST

-- web request
type Request = {
  body :: String,
  header :: String,
  method :: Method
 }

-- response
type Response = {
  body :: String,
  status :: Int
}

OK, so we're going to write an endpoint to register to our service! You'll pass in the username in the header, and your email in the body.

signupPage :: forall e. Request -> Eff (read::DB, write::DB | e) Response
signupPage req = do
  mUser <- lookupUser req.header
  case mUser of
      Just user -> return {
        body : "User with this username already exists!",
        status : 400
      }
      Nothing -> do
        createUser {
          username : req.header,
          email: req.body,
          uid: "999" -- TODO make more unique
        }
        return {
          body : "Created User with name " ++ req.header,
          status: 200
        }

So this signup workflow is pretty straightforward:

  • The type mentions that this function does DB reads and DB writes (you can leave it out and Purescript will infer this)
  • We first try to lookup if there's a user with the username already
  • if so, we return a 400 response (Username already exists)
  • if not, we create the user and return a 200

Awesome. This is the kind of thing people write in the Real World(TM).


OK! Let's write a router!

People always tell me that GETs should be idempotent. Now, this doesn't strictly mean that GETs can't commit changes. But if we were to, say, not allow changes on GETs that would get us idempotency. And it's not bad advice in general.

data Route =
    GetRoute String (forall e. Request -> Eff (read::DB | e) Response)
  | PostRoute String (forall e. Request -> Eff (read::DB,write::DB | e) Response)

So here we've defined a GetRoute to only accept Eff actions that read our database. However, PostRoute will accept Eff actions that read or write to our Database.

Let's write our main routes. Well, our one route, to /signup:

routes :: Array Route
routes = [
   GetRoute "/signup"  signupPage
]

So here the only route is making a GET request to /signup will hit the previous signup endpoint.

But if you try to compile this:

Could not match type
  ( write :: DB, read :: DB | _0)
with type
  ( read :: DB | e0)

Uhoh! signupPage totally writes to the DB! Luckily, our restrictions placed on GetRoute do not let me make my signup endpoint be a GET, and a type error was triggered

If I change my route to use a PostRoute, though, I would be allowed to write to the DB, because of the types of PostRoute and signupPage:

signupPage :: forall e. Request -> Eff (read::DB, write::DB | e) Response

PostRoute String (forall e. Request -> Eff (read::DB,write::DB | e) Response)

GetRoute String (forall e. Request -> Eff (read::DB | e) Response)
-- GetRoute's 2nd param is missing write::DB
-- so cannot accept actions with writes

We can then configure our routes and avoid a type error

routes = [
  PostRoute "/signup"  signupPage      
]

So all of this works through Purescript's row polymorphism and Eff, the extensible effect monad, which is much better explained through the excellent Purescript By Example.

The ability to track which effects are happening in which functions lets you encode so many best practices.

Some examples:

  • Don't contact external services inside a request cycle
  • Don't make any DB queries that could be unbounded in size inside a request cycle
  • Make sure requests go through your auth subsystem
  • Track locks on unique resources
  • Don't call functions that are considered long-running unless explicitly acknowledging so

Because the universe of statically verifiable constraints gets so much bigger with this, you can venture into API design that might be risky otherwise. And, of course, make your code less buggy in general.