DEV Community

loading...

Grokking Free Monads

choc13 profile image Matt Thornton Updated on ・14 min read

In this post I’m going to try and demystify free monads and show you that they’re not some strange abstract creature, but in fact can be very useful for solving certain problems. Rather than focusing on the theory, our aim here will be to get a solid intuition about free monads, you'll then find learning the theory much easier. So in keeping with the rest of this series we’ll discover the free monad ourselves by solving a real software problem.

Pre-requisites

I try to keep these posts as independent from each other as possible, but in this case there's not much getting around the fact that you're probably going to need to have already grokked monads. If you haven't yet done so, then have a browse through Grokking Monads and once you're done you'll be all set to continue here.

The Scenario

Let's say we work at an e-commerce store and we need to implement a chargeUser function. This function should take a UserId and an amount. It should lookup the user's profile to get hold of the credit card, then it should charge the user's card the specified amount. If the user has an email address it should send them a receipt.

type EmailAddress = EmailAddress of string

type Email = 
    { To: EmailAddress
      Body: string }

type CreditCard =
    { Number: string
      Expiry: string
      Cvv: string }

type TransactionId = TransactionId of string

type UserId = UserId of string

type User =
    { Id: UserId
      CreditCard: CreditCard
      EmailAddress: EmailAddress option }

let chargeUser (amount: float) (userId: UserId): TransactionId =
    // TODO: Implement this as part of the domain model
Enter fullscreen mode Exit fullscreen mode

Our main aim in this post is to be able to write the chargeUser function in our domain model. By domain model, we're referring to the very thing we're writing our program for in the first place. In this case as we're an e-commerce store that means our domain model includes things like user profiles, products and orders.

Typically when we write our application we want to keep our domain model completely decoupled from any infrastructure or application layer code, because those things are the incidental complexity that we have to solve. Our domain model should be pure and abstract in the sense that if we were to use a different database or a different cloud provider, the domain model should be unaffected.

It's easy to write types in our domain layer to represent the objects in the model without introducing any unwanted coupling, but what about the functions like chargeUser? On the one hand we know it's going to need to call external services, so does that mean we should define it outside of the domain model where we have access to the database etc? On the other hand it's not uncommon to want to take decisions in functions like this, such as whether or not we should email the user a receipt, and that logic definitely feels like domain logic that we'd want to test independent of the database.

Functions as data

There are several ways to make domain operations pure and agnostic to any infrastructure concerns. We've touched on one before in Grokking the Reader Monad. One interesting way to do it though is to treat functions as if they were data.

What do we mean by functions as data? The best way to understand this is to see some code. Let's take the chargeUser function and write a data model to describe the operations it needs to perform.

type ChargeUserOperations =
    | LookupUser of (UserId -> User)
    | ChargeCreditCard of (float -> CreditCard -> TransactionId)
    | EmailReceipt of (Email -> unit)
Enter fullscreen mode Exit fullscreen mode

We've created a type called ChargeUserOperations that has a case for each of the operations we want to perform as part of chargeUser. Each case is parameterised by the function signature that we want it to have. So instead of being functions that we call, we've just got some abstract data representing the functions that we want to invoke and we'd like to use it like so.

let chargeUser amount userId: TransactionId =
    let user = LookupUser userId
    let transactionId = ChargeCreditCard amount user.CreditCard
    match user.EmailAddress with
    | Some emailAddress ->
        let email = 
          { To = emailAddress
            Body =  $"TransactionId {transactionId}" }
        EmailReceipt email
        return transactionId
    | None -> return transactionId
Enter fullscreen mode Exit fullscreen mode

Obviously, this isn't going to work. We can't simply write LookupUser userId and assign that to something of type User. For starters LookupUser is expecting a function as an argument, not a UserId. This idea of functions as data is an interesting one though, so let's see if we can find a way to make it work.

It doesn't really make sense to try and extract a return value from data. All we can really do with data is create it. So what about if we instead created each operation with another operation nested inside it, kind of like a callback that would take the output of the current computation and produce a new output. Something like this.

type ChargeUserOperation =
    | LookupUser of (UserId * (User -> ChargeUserOperation)
    | ChargeCreditCard of (float * CreditCard * (TransactionId -> ChargeUserOperation)
    | EmailReceipt of (Email * (unit -> ChargeUserOperation)
Enter fullscreen mode Exit fullscreen mode

We've made a couple of changes here. Firstly each operation is now parameterised by a tuple instead of a function. We can think of the tuple as the list of arguments to the function. Secondly, the final argument in the tuple is our callback. What that’s saying is that when you create an operation, you should tell it which operation you'd like to perform next that needs the result of this one. Let's give this new format a try.

let chargeUser (amount: float) (userId: UserId): TransactionId =
    LookupUser(
        userId,
        (fun user ->
            ChargeCreditCard(
                (amount, user.CreditCard),
                (fun transactionId ->
                    match user.EmailAddress with
                    | Some emailAddress ->
                        EmailReceipt(
                            { To = emailAddress
                              Body = $"TransactionId {transactionId}" },
                            (fun _ -> // Hmmm, how do we get out of this?)
                        )
                    | None -> // Hmmm, how do we get out of this?)
            ))
    )
Enter fullscreen mode Exit fullscreen mode

Ok, it's getting better. We can see that this data structure is capturing the abstract logic of what the chargeUser function needs to do, without actually depending on any particular implementation. The only snag is we don't have a way to return a value at the end. Each of our operations has been defined such that it needs to be passed another callback, so how do we signal that we should actually just return a value?

What we need is a case in ChargeUserOperation that doesn't require a callback, one that just "returns" a value. Let's call it Return. We also need to make ChargeUserOperation generic on the return type to encapsulate the fact that each operation returns some value, but that the values returned by each operation might differ.

type ChargeUserOperation<'next> =
    | LookupUser of (UserId * (User -> ChargeUserOperation<'next>))
    | ChargeCreditCard of (float * CreditCard * (TransactionId -> ChargeUserOperation<'next>))
    | EmailReceipt of (Email * (unit -> ChargeUserOperation<'next>))
    | Return of 'next
Enter fullscreen mode Exit fullscreen mode

We've chosen the name 'next for the generic parameter to signify the fact that it's the value returned by the "next" computation in the chain. In the case of Return then it's just immediately "returned". We're now finally in a position to write chargeUser.

let chargeUser (amount: float) (userId: UserId): ChargeUserOperation<TransactionId> =
    LookupUser(
        userId,
        (fun user ->
            ChargeCreditCard(
                (amount, user.CreditCard),
                (fun transactionId ->
                    match user.EmailAddress with
                    | Some emailAddress ->
                        EmailReceipt(
                            { To = emailAddress
                              Body = $"TransactionId {transactionId}" },
                            (fun _ -> Return transactionId)
                        )
                    | None -> Return transactionId)
            ))
    )
Enter fullscreen mode Exit fullscreen mode

That's it! We've captured the logic of chargeUser in a completely abstract data structure. We know that it's got no dependence on any infrastructure because we fabricated it purely out of data types. We've taken our domain modelling to the next level, by modelling its computations as data too! ✅

One thing to note is that chargeUser now returns ChargeUserOperation<TransactionId>. This might seem weird, but we can think of it this way; chargeUser is now a function that produces a data structure which represents the the domain operation of charging and user and returning the TransactionId.

If you've grokked it this far, then you've made the fundamental mental leap; the fact that we're just representing a computation as data. The rest of this post is just going to be dedicated to cleaning this up to make it easier to read and write chargeUser. Things might get a bit abstract, but just keep in mind the fact that all we're doing is trying to build this data structure to represent our computation.

Flattening the pyramid ⏪

One problem with chargeUser in its current form is that we're back in nested callback hell, (a.k.a the Pyramid of Doom. We already know that monads are useful at flattening nested computations, so let's see if we can make ChargeUserOperation a monad.

The recipe for making something a monad is to implement bind for that type. We start by defining the types for the function signature and use that to guide us. In this case the signature is.

('a -> ChargeUserOperation<'b>) -> ChargeUserOperation<'a> -> ChargeUserOperation<'b>
Enter fullscreen mode Exit fullscreen mode

So we're going to have to unwrap the ChargeUserOperation to get at the value 'a and then apply that the to the function we've been passed to generate a ChargeUserOperation<'b>. Let's get stuck in.

let bind (f: 'a -> ChargeUserOperation<'b>) (a: ChargeUserOperation<'a>) =
    match a with
    | LookupUser (userId, next) -> ??
    | ChargeCreditCard (amount, card, next) -> ??
    | EmailReceipt (unit, next) -> ??
    | Return x -> f x
Enter fullscreen mode Exit fullscreen mode

As usual we've used a pattern match to unwrap the ChargeUserOperation in order to get at the inner value. In the case of Return it's a straight forward case of just calling f on the value x. But what about for those other operations? We don't have a value of type 'a to hand, so how can we invoke f?

Well what we do have to hand is next which is capable of producing a new ChargeUserOperation when supplied with a value. So what we can do is call that and recursively pass this new ChargeUserOperation to bind. The idea being that by recursively calling bind we'll eventually hit the Return case, at which point we can successfully extract the value and call f on it.

module ChargeUserOperation =
    let rec bind (f: 'a -> ChargeUserOperation<'b>) (a: ChargeUserOperation<'a>) =
        match a with
        | LookupUser (userId, next) -> LookupUser(userId, (fun user -> bind f (next user)))
        | ChargeCreditCard (amount, card, next) ->
            ChargeCreditCard(amount, card, (fun transactionId -> bind f (next transactionId)))
        | EmailReceipt (email, next) ->
            EmailReceipt(email, (fun () -> bind f (next())))
        | Return x -> f x
Enter fullscreen mode Exit fullscreen mode

This might be a bit mind bending, but another way to view it is that we're just doing exactly the same callback nesting that we were forced to do by hand when we previously wrote chargeUser. Except now we've hidden the act of nesting these operations inside the bind function.

Each call to bind introduces another layer of nesting and pushes the Return down inside this new layer. For example if we had written LookupUser(userId, Return) |> bind (fun user -> ChargeCreditCard(amount, user.CreditCard, Return)) it would be equivalent to writing it in nested form like LookupUser(userId, (fun user -> ChargeCreditCard(amount, user.CreditCard, Return)).

With that we can easily write a computation expression called chargeUserOperation and use it to flatten that pyramid in chargeUser.

type ChargeUserOperationBuilder() =
    member _.Bind(a, f) = ChargeUserOperation.bind f a
    member x.Combine(a, b) = x.Bind(a, (fun () -> b))
    member _.Return(x) = Return x
    member _.ReturnFrom(x) = x
    member _.Zero() = Return()

let chargeUserOperation = ChargeUserOperationBuilder()

let chargeUser (amount: float) (userId: UserId) =
    chargeUserOperation {
        let! user = LookupUser(userId, Return)
        let! transactionId = ChargeCreditCard((amount, user.CreditCard), Return)

        match user.EmailAddress with
        | Some emailAddress ->
            let email =
                { To = emailAddress
                  Body = $"TransactionId {transactionId}" }

            do! EmailReceipt(email, Return)
            return transactionId
        | None -> return transactionId
    }
Enter fullscreen mode Exit fullscreen mode

If do! is unfamiliar then it’s basically just let! except it ignores the result. Which we don’t care about when sending them email because it returns unit anyway.

Making data look like functions 🥸

The function is looking pretty nice now, but it's perhaps a bit unnatural to have to write LookupUser(userId, Return) instead of just lookupUser userId. It's also a bit annoying to have to constantly keep writing Return as the final argument to the ChargeUserOperation case constructors. Well it's easy to fix that, we can just write a "smart constructor" for each case that hides that detail away.

let lookupUser userId = LookupUser(userId, Return)

let chargeCreditCard amount card = ChargeCreditCard(amount, card, Return)

let emailReceipt email = EmailReceipt(email, Return)

let chargeUser (amount: float) (userId: UserId) =
    chargeUserWorkflow {
        let! user = lookupUser userId
        let! transactionId = chargeCreditCard amount user.CreditCard

        match user.EmailAdress with
        | Some emailAddress ->
            do!
                emailReceipt
                    { To = emailAddress
                      Body = $"TransactionId {transactionId}" }

            return transactionId

        | None -> return transactionId
    }
Enter fullscreen mode Exit fullscreen mode

🔥 Nice! Now the function perfectly expresses the logic of our operation. It looks just like a regular monadic function, except under the hood it's actually building up an abstract data structure that represents our desired computation, rather than invoking any real calls to real infrastructure.

Factoring out a functor

Our chargeUser function is looking pretty good now, but there's some optimisations we can make to the definition of ChargeUserOperation. Let's consider what would happen if we wanted to write a different computation. We'd have to write a data type with a case for each operation we want to support, plus a case for Return and then finally implement bind for it. Wouldn't it be nice if we could implement bind once for any computation type?

Let's take a look at the definition of bind for ChargeUserOperation again and see if we can refactor it to something a bit more generic.

let rec bind (f: 'a -> ChargeUserOperation<'b>) (a: ChargeUserOperation<'a>) =
    match a with
    | LookupUser (userId, next) -> LookupUser(userId, (fun user -> bind f (next user)))
    | ChargeCreditCard (amount, card, next) ->
        ChargeCreditCard(amount, card, (fun transactionId -> bind f (next transactionId)))
    | EmailReceipt (email, next) ->
        EmailReceipt(email, (fun () -> bind f (next ())))
    | Return x -> f x
Enter fullscreen mode Exit fullscreen mode

If we mandate that each operation must be of the form Operation of ('inputs * (‘output -> Operation<'next>) then they only differ by parameter types, which we could make generic. How should we do this for ChargeCreditCard though, because that currently has two inputs. Well we can combine the inputs into a single tuple like this ChargeCreditCard of ((float * CreditCard) * (TransactionId -> ChargeUserOperation<'next>)).

The form of bind for each operation is now identical, specifically it is Operation(inputs, next) -> Operation(inputs, (fun output -> bind f (next output)). So really, we actually only have two cases to consider, either it's an Operation or it's a Return. So let's create a type called Computation that encapsulates that.

type Computation<'op, 'next> =
    | Operation of 'op
    | Return of 'next
Enter fullscreen mode Exit fullscreen mode

Which we can write bind for to turn it into a monad.

let rec inline bind (f: 'a -> Computation< ^op, 'b >) (a: Computation< ^op, 'a >) =
    match a with
    | Operation op -> Operation(op |> map (bind f))
    | Return x -> f x
Enter fullscreen mode Exit fullscreen mode

The trick to making this work in the Operation case is to note that we require each Operation to be mappable. That is, we require it to be a functor. Mapping an operation is just a case of applying the function to the return value to transform it into something else. So by recursively calling bind f, as we did when writing for this ChargeUserOperation, we eventually hit the Return case, get access to the return value and just apply the current op to it by calling map.

So now when we're writing our operations we've reduced the task from having to implement bind to instead having to implement map, which is an easier task. For example we can express ChargeUserOperation like this.

type ChargeUserOperation<'next> =
    | LookupUser of UserId * (User -> 'next)
    | ChargeCreditCard of (float * CreditCard) * (TransactionId -> 'next)
    | EmailReceipt of Email * (unit -> 'next)
    static member Map(op, f) =
        match op with
        | LookupUser (x, next) -> LookupUser(x, next >> f)
        | ChargeCreditCard (x, next) -> ChargeCreditCard(x, next >> f)
        | EmailReceipt (x, next) -> EmailReceipt(x, next >> f)
Enter fullscreen mode Exit fullscreen mode

Unfortunately, we can't eliminate any more boilerplate beyond here in F#. In other languages like Haskell it is possible to automatically derive the Map function for the operation functors, but in F# using FSharpPlus the best we can do today is write the static member Map ourselves. FSharpPlus then provides us the map function which will automatically pick the correct one by calling this static member Map when mapping an instance of ChargeUserOperation through the use of statically resolved type parameters.

We just have one final change to make to the smart constructors. Now that ChargeUserOperation is now just a functor, we need to lift them up into the Computation monad by wrapping them in an Operation.

let lookupUser userId = LookupUser(userId, Return) |> Operation

let chargeCreditCard amount card =
    ChargeCreditCard((amount, card), Return) |> Operation

let emailReceipt email =
    EmailReceipt(email, Return) |> Operation

let chargeUser (amount: float) (userId: UserId) =
    computation {
        let! user = lookupUser userId
        let! transactionId = chargeCreditCard amount user.CreditCard
        match user.EmailAddress with
        | Some emailAddress ->
            do!
                emailReceipt
                    { To = emailAddress
                      Body = $"TransactionId {transactionId}" }

            return transactionId
        | None -> return transactionId
    }
Enter fullscreen mode Exit fullscreen mode

You just discovered the Free Monad 🥳

The data type we called Computation is usually called Free, the Operation case is often called Roll and the Return case is often called Pure. Other than that though we've discovered the basis of the free monad. It's just a data type and associated bind function that fundamentally describes sequential computations.

If you're a C# developer and you're familiar with LINQ then this might seem familiar to you. LINQ provides a way to build up a computation and defer its evaluation until sometime later. It's what allows LINQ to run in different environments, such as in a DB, because people are able to write interprets for it that turn the LINQ statements into SQL etc on the database server.

Should I use free monads 🤔

You might be wondering whether to use free monads in F# in your project. On the one hand they provide an excellent means of abstraction when it comes to defining computations in a domain model. They're also a joy to test because we can just interpret them as pure data and verify that for a given set of inputs we have produced the right data structure and hence computation; no more mocking 🙌.

Another plus is that with free monads we've actually achieved what object oriented programmers would call the interface segregation principle. Each computation only has access to the operations it needs to do its work. No more injecting wide interfaces into domain handlers and then having to write tests that verify we didn't call the wrong operation; it's literally impossible under this design!

On the other hand it seems to be pushing F# to the limits as it technically requires features like higher-kinded types, which F# doesn't technically support. So we have to resort to making heavy use of statically resolved type parameters to make it work. You might also find them to be quite abstract, although I hope that this post has at least helped to make their usage seem more intuitive, even if the internal implementation is still quite abstract.

On balance I don't think there's a one-size-fits-all answer here. You're going to have to weigh up the pros and cons for your project and team and decide whether this level of purity is worth it in order to warrant overcoming the initial learning curve and potentially cryptic compiler errors when things don't line up.

If you're thinking of taking the plunge and giving them a try then I would recommend using FSharpPlus which has done all the hard work of defining the free monad machinery for you. Also see the appendix at the end for a full example using FSharpPlus.

What did we learn 🧑‍🎓

The name free monad, might be cryptic and even misleading at first, but the concept is relatively straight forward. Free monads are just a data structure that represents a chain of computations that should be run sequentially. By building a data structure we're able to leave it up to someone else to come along and interpret it in anyway they see fit. They’re “free” to do it how they need to providing they respect the ordering of the computations in the data structure we have handed to them.

A free monad is just a way for us to describe our computation in very abstract terms. We're placing the fewest restrictions possible on what the computation has to do and making no assumptions about how it should be done. We've completely decoupled the "what" from the "how", which is one of the fundamental pillars of good Domain Driven Design, because it means that the domain model is a pure abstract representation of the problem at hand unburdened by the details of how it is hosted.

Next time ⏭

We've covered a lot in this post but we haven't talked about how we actually go about running these computations. So far we've just built some abstract representations of them in data. Next time we'll see how we can actually interpret them to do some real work.

Appendix

If you want to see a complete, top-to-bottom, example of writing a free monadic workflow using FSharpPlus, then I've included one in the section below.


#r "nuget: FSharpPlus"

open FSharpPlus
open FSharpPlus.Data

type ChargeUserOperation<'next> =
    | LookupUser of UserId * (User -> 'next)
    | ChargeCreditCard of (float * CreditCard) * (TransactionId -> 'next)
    | EmailReceipt of TransactionId * (TransactionId -> 'next)
    static member Map(op, f) =
        match op with
        | LookupUser (x, next) -> LookupUser(x, next >> f)
        | ChargeCreditCard (x, next) -> ChargeCreditCard(x, next >> f)
        | EmailReceipt (x, next) -> EmailReceipt(x, next >> f)

let lookupUser userId = LookupUser(userId, id) |> Free.liftF

let chargeCreditCard amount card =
    ChargeCreditCard((amount, card), id) |> Free.liftF

let emailReceipt email =
    EmailReceipt(email, id) |> Free.liftF

let chargeUser (amount: float) (userId: UserId) =
    monad {
        let! user = lookupUser userId
        let! transactionId = chargeCreditCard amount user.CreditCard
        match user.EmailAddress with
        | Some emailAddress ->
            do!
                emailReceipt
                    { To = emailAddress
                      Body = $"TransactionId {transactionId}" }

            return transactionId
        | None -> return transactionId
    }
Enter fullscreen mode Exit fullscreen mode

When writing the smart constructors here, e.g lookUser we pass the identity function, id, as the second argument. The reason for this is because Free.liftF maps the functor with Pure and then lifts it up with Roll. So by using id and then writing Free.liftF we end up with the desired Roll (LookupUser(userId, Pure)). The other way to think of id here is that the default "callback" when creating an operation is to just return the value produced by this computation and not do anything else.

Discussion (0)

Forem Open with the Forem app