DEV Community

loading...
Cover image for Freeing Free Monads with Free ADTs

Freeing Free Monads with Free ADTs

mikesol profile image Mike Solomon ・9 min read

I'm excited to announce a new open-source library! It's called purescript-freer-free, and it eliminates ~70% of the boilerplate when working with free monads in PureScript. It's a collaboration between myself and @paluh. The article below talks a bit about how the library works and hopefully gives some insights into free constructions and generics.

Free

Every claim I make below about category theory is hand-wavey. Not because I don't think you'd understand the non-hand-wavey argument, but because I barely understand this stuff in the first place!

In category theory, "free" constructions abound. A free X is an X that can be turned into any other X via a function. For example, the Free Monoid is the list. A list is itself a monoid because it has a append operation (list concatenation) and an identity element under this operation (the empty list). A list of strings (strings are monoids because they can be concatenated and there is an identity element aka the empty string "") can be turned into a string via the following function:

fold (<>) ["a","b","c"] -- "abc"
Enter fullscreen mode Exit fullscreen mode

Integers are monoids under addition with 0 as the identity. A list of integers can be turned into a sum via the following function

fold (+) [1,2,3] -- 6
Enter fullscreen mode Exit fullscreen mode

As promised, the free monoid is itself a monoid.

fold (<>) [[1],[2],[3]] -- [1,2,3]
Enter fullscreen mode Exit fullscreen mode

To understand why other monoids aren't free, ask yourself "What's a "logical" transformation to get from "abc" to 6?" Pretty hard to drum up one, huh? That's because neither String or Int is a free monoid. In fact, there is only ever one free construction in a category. In the category of monoids, the free monoid is THE free monoid.

Free algebraic data types

Free algebraic data types, or ADTs, are called generics. A generic is itself an ADT that can be turned into any other ADT in a given language. Let's see how they work in PureScript!

If I have the ADT Animal defined as follows:

data Animal a = Dog | Cat String a | Hamster (Boolean -> a)
Enter fullscreen mode Exit fullscreen mode

Then its generic representation would be:

type GenericAnimal a = Sum (Constructor "Dog" NoArgumenet)
  (Sum (Constructor "Cat"
       (Product (Argument String) (Argument a)))
       (Constructor "Hamster" (Argument (Boolean -> a))))

fluffy = Inr (Inl (Constructor (Product ((Argument "fluffy") (Argument unit))))) :: GenericAnimal Unit
Enter fullscreen mode Exit fullscreen mode

I can get from my GenericAnimal fluffy to her Animal version with a transformation function. However, if I have another ADT, say data Silly = Boo, I can't get back to Animal. There aren't enough ways to create a Silly to get back an Animal or vice versa.

The killer features of generics is that, when dealing with values, we can get back to the original from the generic. This is not true of strings. For example, if I give you "abc" and ask how I got there, one person may guess "a" <> "bc" and then other "abc" <> "" <> "" <> "". Both are correct. But if you give me a Cat "fluffy" 42 (ie in a pattern match) and ask me to make a generic value from this, I know the path.

In this way, generics are peculiar free constructions. Like other free constructions, they are structure-preserving. But unlike other free constructions, their targets (ADTs) also preserve structure at the value-level. So why are generics any "freer" than other ADTs? What makes generics the "free ADT" is that they are just right in Goldilocks-ian fashion. They have the minimum necessary components to describe all other ADTs. No more, no less.

Fear not, though - generics are not that different than their other free cousins. Generics actually do erase structure, but on the type level. As a reminder, when we do fold (<>) ["abc","d","ef"], we erase structure at the value-level by smooshing everything together. We can't get back the original elements. Generics do the same thing with types. The monster-type below:

Sum (Constructor "Dog" NoArgumenet)
  (Sum (Constructor "Cat"
       (Product (Argument String) (Argument a)))
       (Constructor "Hamster" (Argument (Boolean -> a))))
Enter fullscreen mode Exit fullscreen mode

gets translated to a type Animal a. Just like when we concatenate strings or sum up numbers, on the type-level, Animal is "smooshed". We have no way to go backwards in type-land to the generic. What makes generics remarkable is that generics have their dual life in the value-land that does allow for this back and forth.

Using generics to bring typeclasses to ADT constructors

If you can get to any ADT from a generic, it stands to reason that you can do operations on generics rather than doing them on individual ADTs. To do this, though, you need a typeclass that binds generics to their representations.

class Generic a rep | a -> rep where
  to :: rep -> a
  from :: a -> rep
Enter fullscreen mode Exit fullscreen mode

Now, I can do:

instance genericAnimal :: Generic (Animal a) (Sum (Constructor "Dog" NoArgumenet)
  (Sum (Constructor "Cat"
       (Product (Argument String) (Argument a)))
       (Constructor "Hamster" (Argument (Boolean -> a)))))
Enter fullscreen mode Exit fullscreen mode

Because that's no fun to write, the team that works on the PureScript compiler provides the ability to derive this type automatically, so you just have to write:

derive instance genericAnimal :: Generic (Animal a) _
Enter fullscreen mode Exit fullscreen mode

and it will fill in the blank.

Zooming out for a second, typeclasses are just spreadsheets with variables (like a and rep) as the columns and instances as the rows. Using variables, we can even have an instance of a typeclass that describes many "rows" in one fell swoop. Using this strategy, we can create typeclasses that operate on all generics that share a certain property and then call to on them, then we can operate on their representations.

As an example, let's say that we want to take all ADTs of the form Foo a = Foo a and get back a Foo Unit. Let's call that operation wham. We can do:

class Wham f where
  wham :: forall name. (Generic (f Unit) (Constructor name (Argument Unit))) => f Unit

instance whamWham :: Wham f where
  wham = to (Constructor (Argument unit))

data Foo a
  = Foo a

derive instance gFoo :: Generic (Foo a) _

fooWham = wham :: Foo Unit
Enter fullscreen mode Exit fullscreen mode

So that's a lot of code for something that could have just been:

bam :: forall f. (forall a. a -> f a) -> f Unit
bam = (#) unit

fooBam = bam Foo :: Foo Unit
Enter fullscreen mode Exit fullscreen mode

But the advantage of the first approach is that we have an indelible, immutable, compiler-enforced relationship defined by the typeclass. That means that every ADT with the same structure belongs to Wham.

From ADT constructors to free monads

The free monad is a monad that can be turned into any other monad via a natural transformation. It follows the same pattern as the free monoid (list) and free ADT (generic) I described above.

Free monads, to be "free", are the minimal-viable-monad. They have no side effects. But a monad without side effects is like a trip to Finland without seeing Kuopio - what's the point? Free monads are useful when we want to model a program without specifying how the side effects will be executed, and if we're talking about a program, we're talking about something concrete. It "does stuff." So really, free monads are not that free in their application: they only make sense in that they are linked to a finite, circumscribed collection of actions, AKA a program.

ADTs are a great way to model the actions that comprise our monadic context. For example, at Meeshkan, we use the following ADT to describe how we fetch assets and upload them to AWS S3:

data S3SquirrelProgramF a
  = GetETagForResource String (Maybe String -> a)
  | GetS3InfoFromDBOnCacheHit String (Maybe String) (Maybe S3Info -> a)
  | DownloadResourceToFile String String (Unit -> a)
  | ReadFileToBuffer String (Buffer -> a)
  | GenerateUUID (String -> a)
  | UploadObjectToS3 String String Buffer (Unit -> a)
  | WriteEtagAndS3InfoToDb String (Maybe String) String String (Either Error Unit -> a)
  | FreeLog String (Unit -> a)
  | FreeThrow Error (Unit -> a)
Enter fullscreen mode Exit fullscreen mode

Those are the elements of the program, and we mix and match them as we please. The way we transform those into a free monad is using purescript-free. In the "bad old days" (meaning a couple days ago, before purescript-freer-free was released) we'd use a function from purescript-free called liftF to turn the ADT above into a monad.

getETagForResource :: String -> Free S3SquirrelProgramF String
getETagForResource url = liftF $ GetETagForResource url identity

freeLog :: String -> Free S3SquirrelProgramF Unit
freeLog msg = liftF $ GetETagForResource msg unit

Enter fullscreen mode Exit fullscreen mode

we can now write a program that gets our etag and logs it.

program = do
  etag <- getETagForResource
  freeLog (show etag)
Enter fullscreen mode Exit fullscreen mode

And later we can provide an implementation, for example mocks in a test or a production connection to AWS S3.

There are many great articles on why Free Monads are the bee's knees, notably by Gabriel Gonzalez here and here. At the end of this article, I'll give a couple of reasons that I like to use them. But they are also super annoying to work with - you have to write all those functions! And even worse, you have to do it twice! Once to make the free monad, and once to transform it into the execution monad (ie Aff or IO or Identity or whatever). That's what purescript-freer-free fixes.

The basic strategy of purescript-freer-free is exactly what I showed above. If you know that every element of a free monad's underlying ADT implements Generic in a predictable way (ie how the PS compiler does it) and has branches that end with either a or (b -> a), you can use generics, like we did with wham, to create both the constructors and the interpreter. In the Meeshkan codebase, that looks like this:

data S3SquirrelProgramF a
  = GetETagForResource String (Maybe String -> a)
  | GetS3InfoFromDBOnCacheHit String (Maybe String) (Maybe S3Info -> a)
  | DownloadResourceToFile String String (Unit -> a)
  | ReadFileToBuffer String (Buffer -> a)
  | GenerateUUID (String -> a)
  | UploadObjectToS3 String String Buffer (Unit -> a)
  | WriteEtagAndS3InfoToDb String (Maybe String) String String (Either Error Unit -> a)
  | FreeLog String (Unit -> a)
  | FreeThrow Error (Unit -> a)

derive instance functorS3SquirrelProgramF :: Functor S3SquirrelProgramF

derive instance genericS3SquirrelProgramF :: Generic (S3SquirrelProgramF a) _

type S3SquirrelProgram
  = Free S3SquirrelProgramF

f :: Constructors S3SquirrelProgramF S3SquirrelProgram
f = constructors (liftF :: S3SquirrelProgramF ~> S3SquirrelProgram)
Enter fullscreen mode Exit fullscreen mode

The function constructors attaches free monads to an extensible record that can be used in a do block:

program = do
  f.downloadResourceToFile "https://foo.com/a.txt" "a.txt"
  buf <- f.readFileToBuffer "a.txt"
  f.uploadObjectToS3 "bucket" "key" buf
Enter fullscreen mode Exit fullscreen mode

and then for the interpreters.

s3SquirrelInterpreter :: RDSEnv -> S3SquirrelProgramF ~> Aff
s3SquirrelInterpreter rdsEnv =
  interpreter
    { getETagForResource: getETagForResourceAff
    , getS3InfoFromDBOnCacheHit: getS3InfoFromDBOnCacheHitAff rdsEnv
    , downloadResourceToFile: downloadResourceToFileAff
    , readFileToBuffer: readFileToBufferAff
    , generateUUID: generateUUIDAff
    , freeLog: liftEffect <<< Log.info
    , freeThrow: (throwError :: Error -> Aff Unit)
    , uploadObjectToS3: uploadObjectToS3Aff
    , writeEtagAndS3InfoToDb: writeEtagAndS3InfoToDbAff rdsEnv
    }
Enter fullscreen mode Exit fullscreen mode

Where each value in the record is the effectful version of the underlying ADT's constructor. After refactoring using purescript-freer-free, we were able to shave off ~300 lines of code from ~5 free monads, plus avoid annoying mistakes that come from the incorrect passing of destructured arguments in a pattern match to a delegate function.

Postlude

Most people I know that enjoy writing code in an FP-style don't like working with free monads. This used to be understandable because of the boilerplate, but now y'all have no excuse :-P No, I kid, I kid, there are lots of good reasons not to use free monads. The two I hear most often are.

Use a reader monad + an effectful context like Effect

Totally works, and often times it's a coin-flip with free monads. The reason I've started avoiding this is because I'll often forget to mock stuff out as I'm building up the effectful monad, then testing becomes too much of a pain, and then I'll forget what's mocked and what's not. Free monads force you to explicitly declare all side effects, which in addition to helping with mocking has the advantage of being nice documentation for your colleagues.

Use typeclasses

Also totally works.

class Program m where
  doStuff :: String -> m Unit
  doMoreStuff :: String -> m Number

instance affProgram :: Program Aff where
  doStuff = const $ pure unit
  doMoreStuff = const $ pure 42.0
Enter fullscreen mode Exit fullscreen mode

This fixes the mocking problem in that all dependencies are explicitly defined in the typeclass. The issue here is that if you want more than one possible implementation in the Aff monad, or if you want to mix and match different implementations, it gets tricky. You need to bring in newtypes and class constraints to get it to work out, which makes the code potentially harder to follow.

Parting shot

I find that free monads allow for better testability and easier swapping of implementations in certain situations. That said, we also have services at Meeshkan that are so tightly coupled to the Aff monad that making a free monad just takes too much time, so we roll with Aff and write a good end-to-end test.

Bottom line - decide for yourself! In addition to the shiny new purescript-freer-free by your's truly and @paluh, there are some other great libraries for working with free monads:

I hope you enjoy working with purescript-freer-free! We use it at Meeshkan because we take mocking and testing super-seriously so that you can take it slightly-less-seriously. If you wanna see how, sign up on https://app.meeshkan.com!

Discussion (0)

pic
Editor guide