/purescript-run

An extensible-effects implementation

Primary LanguagePureScriptMIT LicenseMIT

purescript-run

Latest release Build status

An extensible-effects implementation for PureScript.

Install

spago install run

Documentation

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.

Free DSLs

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 Functors):

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 Coproducts, 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 Functors 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.

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.

Stack-safety

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.