Supercharged Types
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.