DEV Community

loading...
Cover image for Piloting Puppeteer with PureScript - Part 3

Piloting Puppeteer with PureScript - Part 3

Mike Solomon
Making machines that make machines believe the machines they make are other machines.
・7 min read

In the previous article in this series, we saw how to use the PureScript FFI to make PureScript code interoperable with JS.

In this article, I'd like to talk more about the Aff monad and how we use the type system to make sure all roads lead to Aff.

The Aff monad, fibers, and asynchronous code

Asynchronous code is the bane of many-a-JS-programmer's existence. Race conditions, timeouts, forgetting to return await promise and instead returning promise, and other classic errors mar asynchronous JS code. Some libraries, like redux-saga, go a long way towards normalizing asynchronous practices, but they require significantly refactoring code so that all communication runs through a state machine.

Fibers fix that. A Fiber is an asynchronous computation that can be spawned, forked, suspended, joined, killed, cancelled, delayed attempted, and run in parallel. Aff supports all of these actions, making it a one-stop shop for asynchronous computation. As a result, all asynchronous computation flows through the Aff monad in PureScript, which can be used in any effectful context by causing launchAff_. Here's an example

main :: Effect Unit
main = do
  launchAff_ $ log ("hello world")
  launchAff_ do
    delay (Milliseconds 400)
    log ("goodbye world")
Enter fullscreen mode Exit fullscreen mode

Aff gives a programmer all the tools they need to orchestrate complex asynchronous workflows in addition to eliminating various common bugs in JS asynchronous code.

The road to Aff

As mentioned before, JavaScript has three main conventions for interacting with code asynchronously:

  1. Callbacks
  2. Promises
  3. async/await

The PureScript ecosystem has several libraries that make these conversions pretty painless. For example, let's say you have a JS FFI function (aka an Effect) that returns a promise:

exports.myPromise_ = function() { return Promise.resolve(true); }
Enter fullscreen mode Exit fullscreen mode

In PureScript, you can use toAffE from purescript-aff-promise to convert this to an Aff.

foreign import myPromise_ :: Effect (Promise Boolean)
myPromise = toAffE myPromise_ :: Aff Boolean
Enter fullscreen mode Exit fullscreen mode

While this suffices for simple code, it gets clunky when working with complex type signatures. For example, a common case is working with multi-argument functions.

foreign import myPromiseF_ :: Int -> Int -> Effect (Promise Boolean)
myPromiseF a b = toAffE (myPromiseF_ a b) :: Int -> Int -> Aff Boolean
Enter fullscreen mode Exit fullscreen mode

The issue here is that there is duplication in two places:

  1. We don't want to pass myPromiseF_ arguments if we know the order will be exactly the same as to myPromiseF.
  2. We don't want to rewrite the type of we know that all that changes is Aff.

Both of these issues are solved with type classes - a powerful feature of both Haskell and PureScript that leads to more concise, readable, "boring" and predictable code with less chance for bugs (ie swapping arguments or accidentally specializing a polymorphic function too early).

Type classes to the rescue!

A typeclass is collection of groups of types for which a set of functions produce a result based on properties of the input type group. In this article, we'd like to identify a collection of types that can be "lifted" into Aff-land with a function we'll called asAff. In the table below, a, b and c are wildcards for any type.


type as Aff
Aff a Aff a
Promise a Aff a
Effect (Promise a) Aff a
Effect a Aff a
a -> Promise b a -> Aff b
a -> b -> Promise c a -> b -> Aff c
a -> b -> Effect (Promise c) a -> b -> Aff c

So, for example, we'd like asAff applied to a function with type Int -> String -> Promise Boolean to return a function with type Int -> String -> Aff Boolean, etc.

First, let's define a typeclass Affable with a method asAff.

class Affable a b | a -> b where
  asAff :: a -> b
Enter fullscreen mode Exit fullscreen mode

You can think of typeclasses like the table above. They relate types. In this case, our table has two types, column a and column b. Column a has a many-to-one relationship to column b - in other words, b is a function of a. So we can write a -> b after the class definition and we call this a functional dependency. In addition to being a useful annotation, it helps the compiler resolve instances of typeclasses, as we'll see below.

Now, let's walk through the four three instances from our table.

instance affAffable :: Affable (Aff a) (Aff a) where
  asAff = identity

instance promiseAffable :: Affable (Promise a) (Aff a) where
  asAff = toAff

instance effectPromiseAffable :: Affable (Effect (Promise a)) (Aff a) where
  asAff = toAffE

instance effectAffable :: Affable (Effect a) (Aff a) where
  asAff = liftEffect
Enter fullscreen mode Exit fullscreen mode

One important thing to notice is the type variable a. This is what makes the definition of asAff consistent. If we just had Affable Effect Aff, it would only be allowable if the original class definition of asAff treated a as a type of kind Type -> Type. In fact, we could have done that! Let's look at an alternate world where we would have defined Affable using type constructors.

class Affable' a b | a -> b where
  asAff' :: forall x. a x -> b x
Enter fullscreen mode Exit fullscreen mode

This is also called a natural transformation between a and b, and we could have written it using PureScript's natural transformation operator ~> like so:

class Affable' a b | a -> b where
  asAff' :: a ~> b
Enter fullscreen mode Exit fullscreen mode

Had we done this, the instances would have looked like:

instance affAffable' :: Affable' Aff Aff where
  asAff = identity
-- etc.
Enter fullscreen mode Exit fullscreen mode

So if we could have done that, you're probably wondering why didn't we. Good question! Natural transformations only work when the transformation is universal, meaning it could work for any type. If it only works for some types, we are out of luck.

To build up the bottom part of the table (the one with functions), we no longer can use a type constructor of type Type -> Type. Let's take, for example, Function a (Promise b) and Function a (Aff b). What would a natural transformation be like between those two? Type constructors like Function a and Promise resemble functions a great deal - they act on types as functions act on values - so we could think of it as "composing" Function a (which is of type Type -> Type, as Function is of type Type -> Type -> Type) and Promise a la Function a <<< Promise. This in fact is a natural transformation, as typelevel-composition exists in theory, but the PureScript compiler (and most compilers) can't figure this out (yet). This is because the relationships between types are expressed exclusively through typeclasses, so if two types can be composed, they would have to either implement a typeclass called Compose or be part of a Compose data structure that was constrained by other typeclasses. purescript-typelevel-eval does exactly this.

The main reason we do not use natural transformations here, however, is because they fail to hold when working with functions in this context. Natural transformations can be thought of containers of types that are ignorant of the carrier type. For example, a natural transformation of type List ~> Maybe does not care if it is a list of integers, strings, or cars. However, what we actually want is not to enumerate all possible type constructors in the covariant position of the function (ie Function a <<< Promise, Function a <<< Effect, etc) but rather to make the more general statement that we are going from Function a b to Function a c here, where b and c have a specific constraint: they must have an Affable relationship. Thus, we've peeked inside the function to the covariant types b and c, destroying naturality but gaining a really elegant definition, as we'll see below.

As a motivating example, let's start with Function x (Aff y) as our a and Function x (Aff y) as our b. As with Aff x and Aff y, we'd imagine some sort of identity transformation that simply passes through the Aff y. In the case of Function x (Promise y) as our a, we want the toAff transformation. So let's constrain the element in the covariant (rightmost) position of the function to behave as it would in Affable.

instance fnAffable :: Affable b c => Affable (Function a b) (Function a c) where
  asAff = (<<<) asAff
Enter fullscreen mode Exit fullscreen mode

Here, we use composition to postpend the asAff operation. It's the same exactly thing as writing asAff a x = asAff (a x).

The nice thing about this is, for free, we've gotten all our functions on the table above. Meaning functions of 2, 3, 4, 42, 101, and n arguments all work the same. To see why, remember that we've now defined Function a b as Affable if b is Affable. So what could b be? Any Affable, including Function c d if d is Affable. But what could d be? Why Function x y if y is Affable. But what could y be? You get the idea. We have Function a (Function c (Function x y)) or, to use the more common infix operator, a -> c -> x -> y where y is Affable.

Affable functions

Now, just by using a single function, we can take something of the signature ie Browser -> Page -> Promise a and get a Browser -> Page -> Aff a automatically.

foreign import freeResources_ :: Browser -> Page -> Promise Unit
freeResources = asAff freeResources_ :: Browser -> Page -> AffUnit
Enter fullscreen mode Exit fullscreen mode

What about those function signatures? Is there some way we can prevent copying them? You bet! Affable works on types exactly like asAff works on functions. So we can create a type synonym, call it Affize, that works like so:

type Affize a
  = forall b. Affable a b => b
Enter fullscreen mode Exit fullscreen mode

And now we can do:

type FreeResources_ = Browser -> Page -> Promise Unit
foreign import freeResources_ :: FreeResources_
freeResources = asAff freeResources_ :: Affize FreeResources_ 
Enter fullscreen mode Exit fullscreen mode

And voila! Concurrent code in the Aff monad for free using recursion on the function and type level thanks to typeclasses.

Conclusion

Asynchronous code is inherently fragile and prone to errors. Having a consistent pattern for managing asynchronicity (Aff) as well an easy way to marshal functions into the asynchronous context (Affable) along with helpers to create type annotations (Affize) help reduce a host of bugs in the domain of race conditions, deadlocks and error handling. I hope you'll get the chance to try PureScript, Aff, and Meeshkan (where all this ingests millions of daily events!). Enjoy!

Discussion (0)