December 17, 2019

Asynchronous PureScript

One of my favorite features of PureScript is its ability to work with asynchronous effects. While learning the language, I struggled to find any beginner material that introduced the relevant topics and included small examples. This post hopes to fill that gap.

Getting Started with Aff

PureScript’s built-in type for effects is called Effect . However, this type represents synchronous effects. This means if we want expose asynchronous APIs to our users using Effect , we’re stuck with the callback-style. Here’s a contrived example that “slowly” adds two numbers together using Effect s:

module Sync where import Prelude import Effect ( Effect ) import Effect.Class.Console as Console import Effect.Timer ( setTimeout ) slowInt :: Int -> ( Int -> Effect Unit ) -> Effect Unit slowInt int cb = unit <$ setTimeout 1000 ( cb int ) slowAdd :: Int -> Int -> ( Int -> Effect Unit ) -> Effect Unit slowAdd a b cb = slowInt a \ slowA -> slowInt b \ slowB -> cb $ slowA + slowB main :: Effect Unit main = slowAdd 1 2 \ result -> Console . logShow result

Yuck. This is especially painful in slowAdd , where we’re composing the results of several callback-based functions. Luckily, there’s a better way – the Aff monad. Aff is the type that represents asynchronous effects in PureScript. Let’s get started with a very simple Aff program.

module Basics where import Prelude import Effect ( Effect ) import Effect.Aff ( Aff , launchAff_ ) import Effect.Class.Console as Console asyncInt :: Aff Int asyncInt = pure 1 main :: Effect Unit main = launchAff_ do result <- asyncInt Console . logShow result

First things first, we’re now using launchAff_ inside of our main function. Here’s the signature for this function:

launchAff_ :: forall a . Aff a -> Effect Unit

It takes any Aff and returns an Effect with no value. You can think about this as initializing Aff ’s scheduler and starting the event loop. Generally, we want all of our PureScript applications to launch an Aff at the very top-level and work completely inside Aff otherwise.

Next, we have the line that calls asyncInt . Note that this line uses the do notation. This works because Aff is a monad. Finally, the implementation of asyncInt simply creates an Aff from the value 1 by calling pure . It’s not really doing anything asynchronous, so technically I’m a liar.

Rewriting Our Effect Program

Now that we have a taste of Aff , let’s rewrite the above slowAdd program using Aff rather than Effect . We’ll only need one helper function – delay (the Aff version of setTimeout ).

module Async where import Prelude import Data.Time.Duration ( Milliseconds ( .. )) import Effect ( Effect ) import Effect.Aff ( Aff , delay , launchAff_ ) import Effect.Class.Console as Console slowInt :: Int -> Aff Int slowInt int = do delay $ Milliseconds 1000.0 pure int slowAdd :: Int -> Int -> Aff Int slowAdd a b = do slowA <- slowInt a slowB <- slowInt b pure $ slowA + slowB main :: Effect Unit main = launchAff_ do result <- slowAdd 1 2 Console . logShow result

I hope we can agree that this looks significantly nicer than the previous Effect -based version. It’s similar to the contrast between JavaScript code written with callbacks and promises. But the value here isn’t just aesthetics.

To demonstrate the power of Aff , let’s convert our example to fetch our two slow ints in parallel. Right now, we can run the program to see that we’re doing things in serial.

$ spago bundle-app -m Async [info] Build succeeded. [info] Bundle succeeded and output file to index.js $ time node index.js 3 node index.js 0.14s user 0.15s system 12% cpu 2.267 total

Note that this program is taking just over 2 seconds to run. We’d like to cut that time to just over one second. To do this, we’ll use the functions forkAff and joinFiber .

module Fork where import Prelude import Data.Time.Duration ( Milliseconds ( .. )) import Effect ( Effect ) import Effect.Aff ( Aff , delay , forkAff , joinFiber , launchAff_ ) import Effect.Class.Console as Console slowInt :: Int -> Aff Int slowInt int = do delay $ Milliseconds 1000.0 pure int slowAdd :: Int -> Int -> Aff Int slowAdd a b = do fiberA <- forkAff $ slowInt a fiberB <- forkAff $ slowInt b slowA <- joinFiber fiberA slowB <- joinFiber fiberB pure $ slowA + slowB main :: Effect Unit main = launchAff_ do result <- slowAdd 1 2 Console . logShow result

Let’s look at the signatures for forkAff and joinFiber .

forkAff :: forall a . Aff a -> Aff ( Fiber a ) joinFiber :: forall a . Fiber a -> Aff a

The forkAff function takes an Aff and gives us back a Fiber wrapped in an Aff . We can think of a Fiber as a green thread. Forking a Fiber yields control back to our code and the Aff goes on running inside of the Fiber . Once we’re ready to use the result, we can call joinFiber which takes a Fiber and gives us back an Aff . Let’s compile and run the code above to show that we’ve actually parallelized our code.

$ spago bundle-app -m Fork [info] Build succeeded. [info] Bundle succeeded and output file to index.js $ time node index.js 3 node index.js 0.14s user 0.15s system 22% cpu 1.272 total

Cleaner Parallel Code

We can simplify our parallel example even further because Aff has an instance of the Parallel typeclass. This means we can use the parSequence function. I’ll spare you the signature – just think of this function as executing a collection of Aff s in parallel and returning an Aff of the results.

module Parallel where import Prelude import Control.Parallel ( parSequence ) import Data.Foldable ( sum ) import Data.Time.Duration ( Milliseconds ( .. )) import Effect ( Effect ) import Effect.Aff ( Aff , delay , launchAff_ ) import Effect.Class.Console as Console slowInt :: Int -> Aff Int slowInt int = do delay $ Milliseconds 1000.0 pure int slowAdd :: Int -> Int -> Aff Int slowAdd a b = do results <- parSequence [ slowInt a , slowInt b ] pure $ sum results main :: Effect Unit main = launchAff_ do result <- slowAdd 1 2 Console . logShow result

Again, we confirm it runs in parallel:

$ spago bundle-app -m Parallel [info] Build succeeded. [info] Bundle succeeded and output file to index.js $ time node index.js 3 node index.js 0.14s user 0.15s system 23% cpu 1.276 total

You can find more fun parallel functions in the purescript-parallel package.

Combining Aff with Other Monads

Last but not least, Aff can be combined with other monads using monad transformers. As stated above, we’re generally running our PureScript programs inside of Aff . This means we want Aff to be at the bottom of our monad transformer stack.

Let’s create an example that slowly adds two numbers together, but throws an error if either of the numbers is greater than 10.

module Stack where import Prelude import Control.Monad.Except ( ExceptT , runExceptT , throwError ) import Control.Parallel ( parSequence ) import Data.Foldable ( sum ) import Data.Time.Duration ( Milliseconds ( .. )) import Effect ( Effect ) import Effect.Aff ( Aff , delay , launchAff_ ) import Effect.Aff.Class ( liftAff ) import Effect.Class.Console as Console type ErrorAff a = ExceptT String Aff a slowInt :: Int -> ErrorAff Int slowInt int = do liftAff $ delay $ Milliseconds 1000.0 if int > 10 then throwError "too big" else pure int slowAdd :: Int -> Int -> ErrorAff Int slowAdd a b = do results <- parSequence [ slowInt a , slowInt b ] pure $ sum results main :: Effect Unit main = launchAff_ do result1 <- runExceptT $ slowAdd 1 2 Console . logShow result1 result2 <- runExceptT $ slowAdd 10 11 Console . logShow result2

Describing monad transformers is beyond the scope of this post, but you can see that Aff works quite nicely inside of monad stacks. We’re using the provided function liftAff to “lift” the results of plain old Aff functions into our stack ( delay returns an Aff Unit ).

Something I find particularly nice about the above code is that parSequence continues to work with no changes even though we’ve updated slowInt to return our type alias ErrorAff rather than Aff .

Here’s the result of running our new program:

$ spago run -m Stack [info] Build succeeded. (Right 3) (Left "too big")

Aff is Awesome