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")
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:
- Callbacks
- Promises
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); }
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
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
The issue here is that there is duplication in two places:
- We don't want to pass
myPromiseF_
arguments if we know the order will be exactly the same as tomyPromiseF
. - 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
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
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
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
Had we done this, the instances would have looked like:
instance affAffable' :: Affable' Aff Aff where
asAff = identity
-- etc.
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
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
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
And now we can do:
type FreeResources_ = Browser -> Page -> Promise Unit
foreign import freeResources_ :: FreeResources_
freeResources = asAff freeResources_ :: Affize FreeResources_
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!
Top comments (0)