An extensible-effects implementation for PureScript.
spago install run
- Module documentation is published on Pursuit.
Run
is an implementation of extensible, algebraic effects for PureScript.
This means we can write composable programs using normal PureScript data
types, and then provide interpreters for those data types when we actually
want to run them. Our effect descriptions naturally compose with others,
so we don't need to write a large encompassing data type, or explicitly
lift things through transformer stacks.
You should familiarize yourself with purescript-variant
before using Run
.
The Free
data type (found in Control.Monad.Free
) gives us a means to take
any Functor
, and get a Monad
instance out of it. This lets us turn fairly
simple data types into a composable DSL. Here's an example that defines a DSL
for string input and output:
data TalkF a
= Speak String a
| Listen (String -> a)
derive instance functorTalkF :: Functor TalkF
type Talk = Free TalkF
-- Boilerplate definitions for lifting our constructors
-- into the Free DSL.
speak :: String -> Talk Unit
speak str = liftF (Speak str unit)
listen :: Talk String
listen = liftF (Listen identity)
-- Now we can write programs using our DSL.
program :: Talk Unit
program = do
speak $ "Hello, what is your name?"
name <- listen
speak $ "Nice to meet you, " <> name
Note that this doesn't do anything yet. All we've done is define a data type, and we can write a monadic program with it, but that program still only exists as simple data. In order for it to do something, we'd need to provide an interpreter which pattern matches on the data types:
main :: Effect Unit
main = foldFree go program
where
go :: TalkF ~> Effect
go = case _ of
-- Just log any speak statement
Speak str next -> do
Console.log str
pure next
-- Reply to anything with "I am Groot", but maybe
-- we could also get input from a terminal.
Listen reply -> do
pure (reply "I am Groot")
Hello, what is your name?
Nice to meet you, I am Groot
Now say that we've written another orthogonal DSL:
type IsThereMore = Boolean
type Bill = Int
data DinnerF a
= Eat Food (IsThereMore -> a)
| CheckPlease (Bill -> a)
type Dinner = Free DinnerF
-- Insert boilerplate here
If we could somehow combine these two data types, we could have a lovely evening indeed. One option is to just define a new DSL which has the capabilities of both:
data LovelyEveningF a
= Dining (DinnerF a)
| Talking (TalkF a)
type LovelyEvening = Free LovelyEveningF
But now every time we want to use one DSL or another, we have to explicitly
lift them into LovelyEvening
using a natural transformation (~>
).
liftDinner :: Dinner ~> LovelyEvening
liftDinner = hoistFree Dining
liftTalk :: Talk ~> LovelyEvening
liftTalk = hoistFree Talking
dinnerTime :: LovelyEvening Unit
dinnerTime = do
liftTalk $ speak "I'm famished!"
isThereMore <- liftDinner $ eat Pizza
if isThereMore
then dinnerTime
else do
bill <- liftDinner checkPlease
liftTalk $ speak "Outrageous!"
We can create these sorts of sums in a general way with Coproduct
(Either
for Functor
s):
liftLeft :: forall f g. Free f ~> Free (Coproduct f g)
liftLeft = hoistFree left
liftRight :: forall f g. Free g ~> Free (Coproduct f g)
liftRight = hoistFree right
type LovelyEveningF = Coproduct TalkF DinnerF
type LovelyEvening = Free LovelyEveningF
dinnerTime :: LovelyEvening Unit
dinnerTime = do
liftLeft $ speak "I'm famished!"
isThereMore <- liftRight $ eat Pizza
if isThereMore
then dinnerTime
else do
bill <- liftRight checkPlease
liftLeft $ speak "Outrageous!"
This has saved us from having to define a new composite data type, but we
still have to manually lift everywhere. And what about if we want to add
more things to it? We'd need to use more and more Coproduct
s, which
quickly gets very tedious. What if we could instead use an extensible sum
type?
Variant
lets us encode polymorphic sum types using the row machinery in
PureScript. If we look at its big brother VariantF
(found in
Data.Functor.Variant
), we see that it gives us the same capability over
Functor
s and works like an extensible Coproduct
.
type TALK r = (talk :: TalkF | r)
_talk = Proxy :: Proxy "talk"
speak :: forall r. String -> Free (VariantF (TALK + r)) Unit
speak str = liftF (inj _talk (Speak str unit))
listen :: forall r. Free (VariantF (TALK + r)) String
listen = liftF (inj _talk (Listen identity))
---
type DINNER r = (dinner :: DinnerF | r)
_dinner = Proxy :: Proxy "dinner"
eat :: forall r. Food -> Free (VariantF (DINNER + r)) IsThereMore
eat food = liftF (inj _dinner (Eat food identity))
checkPlease :: forall r. Free (VariantF (DINNER + r)) Bill
checkPlease = liftF (inj _dinner (CheckPlease identity))
Now our DSLs can be used together without any extra lifting.
type LovelyEvening r = (DINNER + TALK + r)
dinnerTime :: forall r. Free (VariantF (LovelyEvening + r)) Unit
dinnerTime = do
speak "I'm famished!"
isThereMore <- eat Pizza
if isThereMore
then dinnerTime
else do
bill <- checkPlease
speak "Outrageous!"
This pattern is exactly the Run
data type:
newtype Run r a = Run (Free (VariantF r) a)
In fact, this library is just a combinator zoo for writing interpreters.
Lets review our simple TalkF
effect and example, now lifted into Run
instead of Free
:
data TalkF a
= Speak String a
| Listen (String -> a)
type TALK r = (talk :: TalkF | r)
_talk = Proxy :: Proxy "talk"
speak :: forall r. String -> Run (TALK + r) Unit
speak str = Run.lift _talk (Speak str unit)
listen :: forall r. Run (TALK + r) String
listen = Run.lift _talk (Listen identity)
program :: forall r. Run (TALK + r) Unit
program = do
speak $ "Hello, what is your name?"
name <- listen
speak $ "Nice to meet you, " <> name
Our original Free
based interpreter used foldFree
, and we can do the same
thing with Run
using interpret
or interpretRec
. The only difference is
that interpretRec
uses a MonadRec
constraint to ensure stack-safety. If
your base Monad
is stack-safe then you don't need it and should just use
interpret
.
Since we need to handle a VariantF
, we need to use the combinators from
purescript-variant
, which are re-exported by purescript-run
.
handleTalk :: TalkF ~> Effect
handleTalk = case _ of
Speak str next -> do
Console.log str
pure next
Listen reply -> do
pure (reply "I am Groot")
main = program # interpret (case_ # on _talk handleTalk)
Here we've used case_
, which is the combinator for exhaustive pattern
matching. If we use case_
, that means we have to provide a handler for
every effect. In this case we only have one effect, so it does the job.
Note: An alternative to on
chaining is to use onMatch
(or match
for
exhaustive matching) which uses record sugar. This has really nice syntax,
but inference around polymorphic members inside of the record can be finicky,
so you might need more annotations (or eta expansion) than if you had used
on
.
Let's try adding back in our other effect for a lovely evening:
type DINNER r = (dinner :: DinnerF | r)
_dinner :: Proxy :: Proxy "dinner"
eat :: forall r. Food -> Run (DINNER + r) IsThereMore
eat food = Run.lift _dinner (Eat food identity)
checkPlease :: forall r. Run (DINNER + r) Bill
checkPlease = Run.lift _dinner (CheckPlease identity)
type LovelyEvening r = (TALK + DINNER + r)
dinnerTime :: forall r. Run (LovelyEvening + r) Unit
dinnerTime = do
speak "I'm famished!"
isThereMore <- eat Pizza
if isThereMore
then dinnerTime
else do
bill <- checkPlease
speak "Outrageous!"
We could interpret both of these effects together in one go by providing
multiple handlers, but oftentimes we only want to handle them in isolation.
That is, we want to interpret one effect in terms of other effects at our
convenience. We can't use case_
then, because case_
must always handle
all effects. Instead we can use send
for unmatched cases.
-- This now interprets it back into `Run` but with the `EFFECT` effect.
handleTalk :: forall r. TalkF ~> Run (EFFECT + r)
handleTalk = case _ of
Speak str next -> do
-- `liftEffect` lifts native `Effect` effects into `Run`.
liftEffect $ Console.log str
pure next
Listen reply -> do
pure (reply "I am Groot")
runTalk
:: forall r
. Run (EFFECT + TALK + r)
~> Run (EFFECT + r)
runTalk = interpret (on _talk handleTalk send)
program2 :: forall r. Run (EFFECT + DINNER + r) Unit
program2 = dinnerTime # runTalk
We've interpreted the TALK
effect in terms of the native Effect
type, and so
it's no longer part of our set of Run
effects. Instead, it has been
replaced by EFFECT
. DINNER
has yet to be interpreted, and we can choose to
do that at a later time.
In fact, let's go ahead and do that, but we will interpret it in a completely
pure manner. We will need an internal accumulator for our interpreter, which
we can do with runAccumPure
.
type Tally = { stock :: Int, bill :: Bill }
-- We have internal state, which is our running tally of the bill.
handleDinner :: forall a. Tally -> DinnerF a -> Tuple Tally a
handleDinner tally = case _ of
Eat _ reply
-- If we have food, bill the customer
| tally.stock > 0 ->
let tally' = { stock: tally.stock - 1, bill: tally.bill + 1 }
in Tuple tally' (reply true)
| otherwise ->
Tuple tally (reply false)
-- Reply with the bill
CheckPlease reply ->
Tuple tally (reply tally.bill)
-- We eliminate the `DINNER` effect altogether, yielding the result
-- together with the final bill.
runDinnerPure :: forall r a. Tally -> Run (DINNER + r) a -> Run r (Tuple Bill a)
runDinnerPure = runAccumPure
(\tally -> on _dinner (Loop <<< handleDinner tally) Done)
(\tally a -> Tuple tally.bill a)
program3 :: forall r. Run (EFFECT + r) (Tuple Bill Unit)
program3 = program2 # runDinnerPure { stock: 10, bill: 0 }
Since both runPure
and runAccumPure
fully interpret their result without
running through some other Monad
or Run
effect, we need to preserve stack
safety using the Step
data type from Control.Monad.Rec.Class
. This is why
you see the Loop
and Done
constructors. Loop
is used in the case of a
match, and Done
is used in the default case.
Looking at the type of program3
, all we have left are raw Effect
effects.
Since Effect
and Aff
are the most likely target for effectful programs,
there are a few combinators for extracting them.
program4 :: Effect (Tuple Bill Unit)
program4 = runBaseEffect program3
Additionally there are also combinators for writing interpreters via
continuation passing (runCont
, runAccumCont
). This is useful if you want
to just use Effect
callbacks as your base instead of something like Aff
.
data LogF a = Log String a
derive instance functorLogF :: Functor LogF
type LOG r = (log :: LogF | r)
_log = Proxy :: Proxy "log"
log :: forall r. String -> Run (LOG + r) Unit
log str = Run.lift _log (Log str unit)
---
data SleepF a = Sleep Int a
derive instance functorSleepF :: Functor SleepF
type SLEEP r = (sleep :: SleepF | r)
_sleep = Proxy :: Proxy "sleep"
sleep :: forall r. Int -> Run (SLEEP + r) Unit
sleep ms = Run.lift _sleep (Sleep ms unit)
---
program :: forall r. Run (SLEEP + LOG + r) Unit
program = do
log "I guess I'll wait..."
sleep 3000
log "I can't wait any longer!"
program2 :: Effect Unit
program2 = program # runCont go done
where
go = match
{ log: \(Log str cb) -> Console.log str *> cb
, sleep: \(Sleep ms cb) -> void $ setTimeout ms cb
}
done _ = do
Console.log "Done!"
In this case, the functor component of our effects now has the Effect
continuation (or callback) embedded in it, and we just invoke it to run the
rest of the program.
Since the most common target for PureScript is JavaScript, stack-safety can
be a concern. Generally, evaluating synchronous Monadic programs is not stack
safe unless your particular Monad
of choice is designed around it. You
should use interpretRec
, runRec
, and runAccumRec
if you want to
guarantee stack safety in all cases, but this does come with some overhead.
Since Run
itself is stack-safe, it's OK to use interpret
, run
, and
runAccum
when interpreting an effect in terms of other Run
effects. Aff
is also designed to be stack safe. Effect
, however, is not stack safe, and you
should use the *Rec
variations. It's not possible to guarantee stack-safety
when using the *Cont
interpreters.