PureScript for Front End Developers
If you follow me on Twitter you may have seen me joyfully proclaiming my newfound love for ML-style languages and strong, expressive typing. I'll be writing more in the days ahead about functional programming and static typing, and why I think it's the future of rapid user interface development. For now, though, I'd like to introduce you to my favorite functional language — PureScript. The core team recently released an excellent new version — 0.9.2, and now that the ecosystem is stabilizing it's the perfect time to get started!
I'll assume an intermediate knowledge of the current Node/JS ecosystem. My intention is for this to be a pragmatic introduction from the perspective of an industry professional aiming to use this technology or something very similar in production one day. I value PureScript based on how it will provide value to engineering teams and businesses building real products with real traffic.
Why not Elm? See my footnote!
What is PureScript?
PureScript is a language with syntax and features that are very similar to Haskell, but with some notable differences. It compiles down to readable and performant JavaScript, and it provides a rich Foreign Function Interface to interact directly with other JavaScript libraries. The compiler is built with Haskell and it currently targets Node, though there are currently experimental backends for C++11 and Erlang. It's compatible with tools like Browserify and Webpack so that it can execute in the browser. One big advantage over similar implementations is the lack of a bulky runtime.
The style of programming it empowers is based on maximizing functional purity, strictly limiting state and side effects, and embracing compatibility over standards. You can use the rich type system and testing tools to mathematically prove the correctness of your pure code, and strictly control your stateful code. Each side effect is a stateful sequence of monadic functions that yields a value, and asynchronous side effects with the Aff type make things like Ajax requests simple and concise.
Setting up all the things!
My local PureScript environment uses npm scripts and supports Babel for transpiling FFI modules. To get started, use a simple project skeleton with a starter package.json
, .babelrc
, and other typical Node/JS initial files.
Setting up npm
To kick things off you'll install PureScript, the popular build tool pulp
, an error formatting tool called purescript-psa
, and a development tool called pscid
.
$ npm install --save-dev purescript pulp purescript-psa pscid
This will install them in your local node_modules
folder for your project, and you'll work with them via npm scripts. Let's go ahead and set up those scripts now. Open up your package.json
file, and add a "scripts" block if it's not already there.
"scripts": {
"build": "pulp build"
},
The first one is simple. It merely calls the local pulp
in your node_modules
. Since we're managing these dependencies per-project, you don't have to have a global pulp
to call from the command line.
Next, you're going to want to run tests. Add the following to your "scripts" block:
"test": "pulp test"
This one is simple too, but with a curveball. Your tests are going to fail a lot, and that's not a bad thing — but npm gives you a great deal of debug information after each failure that may not be useful to you. In practice, npm test
will be used by your IDE and CI systems, but — as you’ll see below — npm run dev
is what you’ll use as you develop.
I'm a test-driven developer, so I like to keep my tests on watch so that they run as soon as a file changes. This is actually one of the things the pscid
tool provides for me. Set up a quick command you can use to get pscid
started:
"dev": "pscid --test --port 1701"
I said that I use Babel for my FFI modules, and here's how I hook that in:
"build:babel": "babel src -s -d lib"
When I actually have some FFI modules, I'll include build:babel
in my top-level build
command.
For the time being the package management choice of the PureScript community is Bower. This will likely be changing soon, but for now we install bower as an additional dev dependency, and then add the following "postinstall":
"postinstall": "bower install"
This is where I diverge from many PureScript developers I've observed so far. I value unit tests as a first-class development tool, so one of the first things I do in a new environment is learn how to run a unit test. My favorite tool right now is purescript-test-unit
by the intrepid Bodil Stokke.
$ bower install --save-dev purescript-test-unit
Setting up your editor
Now it's time to set your text editor of choice. My editor is Atom, and it has excellent IDE support for PureScript. If you use a different editor, check out the offerings on the editor and tool support wiki page.
For Atom, you’ll need to install a couple of packages:
$ apm install language-purescript ide-purescript
To enable ide-purescript, we have to install purescript
globally with npm install -g purescript
. I'd like to configure it to use my project-specific version, but I haven't been successful in doing so yet.
To unify your IDE and CLI builds, create a new "scripts" entry in my package.json
file.
"build:json": "pulp build --include lib:test --no-psa --json-errors",
The --include lib:test
portion ensures that I'll get hints in my editor when I'm working on my tests. The --json-errors
portion asks pulp
to output json instead of formatted text. The --no-psa
portion skips purescript-psa
if it's found, because the formatting doesn't work in json mode.
Finally, I open up the ide-purescript
settings page in Atom, and set the "Build command" to npm run build:json
. I make sure "Build on save" and "Use fast rebuild" are checked, and I'm good to go!
Unit testing
It's time to create your first PureScript file! Call it test/Main.purs
. Yep, that's right, not src/Main.purs
— the first PureScript file we're going to create today is test/Main.purs
because you're following the tutorial of a test-driven developer. 🤓
This file will contain your first module, Test.Main
. The main
function that we will define is executed automatically when you run pulp test
.
Art Vandelay, importer exporter
First we define our module and import a few things:
module Test.Main where
import Prelude
import Test.Unit (suite, test)
import Test.Unit.Assert (equal)
import Test.Unit.Main (runTest)
By default, your module will export all top-level definitions. You can limit that by using parentheses:
module Test.Main (main) where
The next import pulls in the Prelude, a minimal standard library of basic building blocks. I'm importing it without calling anything out in parentheses afterwards, which means that all exported definitions in the Prelude are automatically imported.
In the Test.Unit imports following, however, I only import the functions I'm using. I like to make all of my imports explicit, but I make an exception for the Prelude in the interest of convenience.
Function application
Now, let's write a Hello World suite:
main = runTest do
suite "Hello" do
test "World!" do
equal (1 + 1) 2
Okay. The imports weren't too bad, but to the typical front ender this looks crazy. 😳 What is this unconstrained madness? Where are the semicolons, curly braces, and functions with arguments?
We'll talk more about what this actually means behind the scenes, but what you need to know now is that PureScript is a whitespace-sensitive language. Hooray! I've missed that since my days of hacking on Django in Python! The do
blocks introduce a new sequence of function calls. Under the hood this is syntactic sugar around PureScript's use of monads to control side effects, since your test run is stateful and keeps track of which tests have passed or failed.
If you wanted to add a second test, you'd un-indent:
main = runTest do
suite "Hello" do
test "World" do
equal (1 + 1) 2
test "Far out!" do
equal (2 + 2) 4
The test itself actually is a function call:
equal (1 + 1) 2
If you were to convert this to plain JavaScript, it would read like this:
equal(1 + 1, 2)
The first word of the expression is the function name, equal
. This came from the Test.Unit.Assert
module that you imported above. A space separates the function name from its arguments, and a space separates each argument. So, in this case, the first argument is (1 + 1)
. This argument is applied to the function, and here's where some magic happens.
PureScript auto-curries every function. This means that each time you apply an argument to a function, it returns a new function that accepts the next argument. If there are no remaining arguments, it executes the function and returns the result. If you were to convert this to JavaScript, it would look like this:
const equal = result => expected => {
// ... here lies the implementation of the equal function
}
Or, in plain ES5:
function equal(result) {
return function(expected) {
// ... here lies the implementation of the equal function
}
}
One last curveball for now. The plus sign? It's a user-defined operator, established in the Prelude. PureScript gives you the ability to define an operator as an alias for a function. The +
operator is an alias for add
.
Okay, save your file. Are there build issues? In Atom, you will likely be warned with a yellow highlight that you have neglected to add a type signature to a top-level value, main
. This is okay, we're not going to worry about types yet. PureScript has a rich type inference system which makes many annotations optional (though it's often a best practice to annotate anyway). If you get other errors, make sure you've done an npm install
and move on to the next step anyway. It may be a bit easier to read the build errors in the CLI with purescript-psa
formatting them, and our test run will show them for us.
Next, run npm test
from the command line to run your tests. Your output should look something like this:
Engaging with the community
If your test run didn't go so smoothly, and you can't determine what's happening from the error message, PureScript has a great, active community! I see the core developers chatting most frequently in #purescript on Freenode (for which I recommend a free IRCCloud account), but there are also active chat rooms available on Gitter and Slack. I hang out on all three.
A module of your very own
Now it's time to create the first module of your brand new package. Let's do something practical, like hitting an API. Let's install purescript-affjax, a library aiming for pain-free AJAX.
$ bower install --save purescript-affjax
Create the file src/Main.purs
:
module Main where
import Prelude
import Control.Monad.Aff.Console (log)
import Control.Monad.Aff (launchAff)
import Network.HTTP.Affjax (get)
main = launchAff do
res <- get "http://jsonplaceholder.typicode.com/todos"
log $ "GET /api response: " <> res.response
The main
function of the src/Main.purs
module is the one that is executed when you use pulp run
from the command line:
Anatomy of an AJAX call
Let’s break apart the main
function to see what’s happening. First off, you have launchAff
. We can take a look at the source here and see that this function takes an Aff
yielding anything and returns an Eff
that yields a Canceler
. The comment above the function explains — launchAff
converts the computation to a synchronous Eff
effect and executes it. Since there’s no success callback, the value yielded from the end of the do
block is ignored and the canceler function becomes the new value that the monad yields upon completion. Use this when you want to execute an async effect, you don’t need any error handling beyond throw
, and you don’t care about the value yielded by the Aff
. This fits our situation nicely, since we merely want to log the response and then discard it.
Next you have the do
block, which in this case creates an Aff
monad because the compiler notices you’re using get
inside the block and infers the Aff
type.
The first statement in the do
block’s sequence calls the get
function, which you can find the source for here. The left-pointing arrow is part of our monadic do
notation, assigning the value yielded by the get
function to the local variable res
.
The second statement logs the response body to the console, using the log
function. The <>
operator is an alias for the append
function from the Prelude. Since it’s used as an infix operator, it applies the value to the left as the first argument to append
, and the value on the right as the second argument, which makes it equivalent to this:
append "GET /api response: " res.response
In JavaScript, it would look like this:
append('GET /api response: ', res.response)
If you want to use a regular function like this, rather than a user-defined operator, you merely need to surround it in backticks:
"GET /api response: " `append` res.response
What about the $
operator you see after log
? That’s a utility to help you get rid of unwieldy parentheses. The equivalent statement without the $
operator would look like this:
log ("GET /api response: " <> res.response)
The $
operator essentially says “evaluate everything to the right side first, and then apply that to the left”.
Time for some theory
Okay, let’s step away from practical application for today and fill in some of the details about what’s going on behind the scenes. Let’s start with types!
Okay, so what are these type things?
Types are a way to add detailed structure to your code and give the compiler deep static insights into how the code is used. Sound type systems allow developers to take tight control of runtime errors, or in some cases eliminate them entirely! They enable extremely productive development tooling through deep static analysis. They aid in refactoring with "breadcrumb" compiler errors that lead you through what is affected by your changes, and they enable powerful techniques like pattern matching and type class constraints. Perhaps more important than all of that, however, is the excellent readability that expressive types provide. If you invest the time to learn the concepts, you can often get a good idea of what functions do just by looking at their type signatures.
Types in PureScript enable abstractions based on category theory that focus heavily on compositional programming. Category theory is governed by mathematical laws which act as constraints. Those constraints allow us to infer abstract details about categories that follow those laws. Combined with concepts like the functor design pattern, this strategy allows for powerful interoperability that emphasizes compatibility over standards.
This is a deep subject that requires research and experimentation to fully understand, and I'm not going to pretend that I've completed that journey. I'd recommend diving in and starting to experiment with PureScript, and this understanding will start to build over time. Functional programmers often call this "building an intuition", because that intrinsic understanding is often far more powerful than what you can put into words.
How about side effects?
To understand side effects you must first understand purity. A pure function is one in which each possible argument maps to the same return value no matter when the function is called, and there is no observable effect to anything outside the function. You can think of it as a stateless function, with no concept of "before" and "after". No outside value like 'this', 'window', 'console', a global, or a variable in an outer closure can be used inside the function, because that would mean the function is observing or affecting values outside its own scope and time would begin to be a factor.
What if you need time to be a factor? What if you need something to have a state, or something to happen in a particular sequence? There are a variety of techniques for handling state through side-effects within PureScript, such as monads and applicative functors. The truth behind the "do" function's mystery is that PureScript uses it to represent monadic transformations, which happen in sequence and therefore need side effects to be stateful. This could be a side effect internal to PureScript, or it could be something external in the runtime environment. If you want to read or affect something in the native environment (the JavaScript runtime), you use the 'Eff' type. Interaction with this type is handled through a monadic interface, so you can use 'do' notation with it.
How does immutability factor in to all this? In JavaScript, non-primitive arguments are pass-by-reference. This means that mutating arguments leads to a change in that value outside the function, making it impure. You'll need to handle the side effect with an 'Eff'. You can represent the mutation of values fully encapsulated inside a function using things like monads.
Where can I find more info?
Well, that’s it for today! We’ve covered a lot, and if I did well then you probably have more questions than you do answers. Hopefully you’re encouraged to begin a journey with ML-style programming, and even if you don’t choose PureScript you can see the advantages of the approach.
If you’d like to know more, here are some good places to start:
- PureScript by Example — the definitive guide by the creator of PureScript, updated for 0.9.1!
- PureScript Wiki — detailed community documentation
- PureScript learning resources
- PureScript community resources
- Pursuit — library documentation and intelligent search
Good luck!
Footnote: What about Elm?
You may be wondering why I'm not writing about Elm right now. Elm is generating a lot of excitement in the React community right now, for good reason! It is introducing the delights of strong, expressive typing and ML-style syntax to many of us in front end engineering that had never even considered that path. I am personally very excited about Elm and want to see it grow, but it's not my favorite language right now because it is keenly focused on being a UI language that is very approachable to new developers. This is absolutely the right focus for Elm, but I'm looking for something a little more general purpose. To be specific, I prefer PureScript because of ad-hoc polymorphism through type classes, the Eff effect system, row polymorphism, and its low-level foreign function interface to JavaScript.