/purescript-freer-free

Boilerplate-eliminator when working with free monads.

Primary LanguagePureScript

purescript-freer-free

(free) boilerplate for working with free monads.

We've found that this repo is only useful if you're working with free monads that have more than ~5 constructors in the underlying ADT. For smaller ADTs, boilerplate is fine.

Motivation

The following example, copied from purescript-run, shows how free monads are often constructed.

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

Those boilerplate definitions are no fun. Let's fix that.

With purescript-freer-free, we can do.

module Test.Simple where

import Prelude
import Control.Monad.Free (Free, liftF)
import Control.Monad.Freer.Free (Constructors, constructors)
import Data.Generic.Rep (class Generic)

data Talk a
  = Speak String a
  | Listen (String -> a)

derive instance functorTalk :: Functor Talk

derive instance genericTalk :: Generic (Talk a) _

f :: Constructors Talk (Free Talk)
f = constructors (liftF :: Talk ~> Free Talk)

program :: Free Talk Unit
program = do
  f.speak "Hello, what is your name?"
  name <- f.listen
  f.speak ("Nice to meet you, " <> name)

Voilà, no more boilerplate.

Note that constructors takes an arbitrary natural transformation, which allows us to turn our functors into any ol' functor we please.

Interpreters

What's the fun of a free monad if you can't interpret it? The first interpreter uses traditional case matching, and the second uses interpreters from this library.

i0 :: Talk ~> Maybe
i0 = case _ of
  Speak s a -> pure a
  Listen a -> a <$> pure "hello"

i1 :: Talk ~> Maybe
i1 =
  interpreter
    { speak: const $ pure unit
    , listen: pure "hello"
    }

They seem pretty similar, but the second is an extensible record, which means you can compose interpreters. It also frees you up from passing through arguments in the pattern matching.

Variants

Going back to the purescript-run example, let's imagine that we want to change our free monad to use variants.

data TalkF a
  = Speak String a
  | Listen (String -> a)

derive instance functorTalkF :: Functor TalkF

type TALK
  = FProxy TalkF

_talk = SProxy :: SProxy "talk"

speak :: forall r. String -> Free (VariantF ( talk :: TALK | r )) Unit
speak str = liftF (inj _talk (Speak str unit))

listen :: forall r. Free (VariantF ( talk :: TALK | r )) String
listen = liftF (inj _talk (Listen identity))

data Food
  = Hummus
  | Falafel
  | Haloumi

type IsThereMore
  = Boolean

type Bill
  = Int

data DinnerF a
  = Eat Food (IsThereMore -> a)
  | CheckPlease (Bill -> a)

derive instance functorDinnerF :: Functor DinnerF

type DINNER
  = FProxy DinnerF

_dinner = SProxy :: SProxy "dinner"

eat :: forall r. Food -> Free (VariantF ( dinner :: DINNER | r )) IsThereMore
eat food = liftF (inj _dinner (Eat food identity))

checkPlease :: forall r. Free (VariantF ( dinner :: DINNER | r )) Bill
checkPlease = liftF (inj _dinner (CheckPlease identity))

type LovelyEvening r
  = ( dinner :: DINNER, talk :: TALK | r )

dinnerTime :: forall r. Free (VariantF (LovelyEvening r)) Unit
dinnerTime = do
  speak "I'm famished!"
  isThereMore <- eat Hummus
  if isThereMore then
    dinnerTime
  else do
    bill <- checkPlease
    speak "Outrageous!"

Using freer, all we have to do is change the transformation function passed to freer. The rest of the code can stay the same.

data TalkF a
  = Speak String a
  | Listen (String -> a)

derive instance functorTalkF :: Functor TalkF

derive instance genericTalkF :: Generic (TalkF a) _

type TALK
  = FProxy TalkF

_talk = SProxy :: SProxy "talk"

type TalkV r
  = (VariantF ( talkFProxy TalkF | r ))

f :: forall r. Constructors TalkF (Free (TalkV r))
f = constructors (liftF <<< inj _talk :: TalkF ~> (Free (TalkV r)))

data Food
  = Hummus
  | Falafel
  | Haloumi

type IsThereMore
  = Boolean

type Bill
  = Int

data DinnerF a
  = Eat Food (IsThereMore -> a)
  | CheckPlease (Bill -> a)

type DINNER
  = FProxy DinnerF

derive instance functorDinnerF :: Functor DinnerF

derive instance genericDinnerF :: Generic (DinnerF a) _

_dinner = SProxy :: SProxy "dinner"

type DinnerV r
  = (VariantF ( dinnerFProxy DinnerF | r ))

d :: forall r. Constructors DinnerF (Free (DinnerV r))
d = constructors (liftF <<< inj _dinner :: DinnerF ~> (Free (DinnerV r)))

type LovelyEvening r
  = ( dinner :: DINNER, talk :: TALK | r )

dinnerTime :: forall r. Free (VariantF (LovelyEvening r)) Unit
dinnerTime = do
  f.speak "I'm famished!"
  isThereMore <- d.eat Hummus
  if isThereMore then
    dinnerTime
  else do
    bill <- d.checkPlease
    f.speak "Outrageous!"

How this helps

There are three ways this library has helped us at Meeshkan:

  • When we modify our variants can change the signature of helper functions like f without changing helper methods.
  • As actions in free monads spill into the 10s, this reduces the size of a file substantially.
  • We are able to read the program in terms of the underlying functor (aka nicer colors in the IDE).