Polysemy - Part II - First example
This is part of a series on effect handling in Haskell using Polysemy
Setup
Let’s setup our project as documented in Polysemy readme beforehand (instructions are for Stack projects but I’m sure you will easily find the Cabal/Nix equivalent):
- Add polysemyandpolysemy-pluginto ourpackage.yamldependencies
- Add the following to ghc-options:
- -fplugin=Polysemy.Plugin- Add the following to default-extensions(we also addTemplateHaskellbecause we use it to reduce boilerplate):
- DataKinds
- FlexibleContexts
- GADTs
- LambdaCase
- PolyKinds
- RankNTypes
- ScopedTypeVariables
- TemplateHaskell
- TypeApplications
- TypeOperators
- TypeFamiliesLogging
A common need in any application is logging. Whether it’s technical logs to keep track of batch start/end (and result), audit logs about who did an admin action, or functional logs about a particular feature being used, it’s useful to find what happened in our beloved applications.
But logging is an effect! No matter if we send logs to standard output, a log file, or over the network, it has an effect on the world, other than mere processing.
Of course we could use a good ol’ IO and call it a day, but as explained in the previous post, IO is too coarse, we want more granularity. Instead, let’s create and use a Log effect!
Effect declaration
Let’s cut to the chase:
import Polysemy
data Log m a where
  LogInfo :: String -> Log m ()
makeSem ''LogThis is pretty dense already, let’s analyze bit by bit what’s going on!
data Log m a is our effect.
- Logis the effect name. This is the part that will appear in all our function signatures. Better pick a name that’s descriptive (and ideally not verbose) of the effect!
- mmust always be there (you can guess the- mstands for- Monadbut you don’t really need to know what it’s used for)
- ()is the return type of the action. A logging action returns nothing, so we stick to Unit (- ())
LogInfo :: String -> Log m () is a possible action that has the Log effect. As you may have guessed, it is an action that takes a String to log, and will log it! Note we can have several actions under the same effect, but let’s start with one.
makeSem ''Log uses Template Haskell to create the logInfo function (same name as the action, but with the first letter changed to lowercase). We do not technically need this, but it saves us writing uninteresting boilerplate, so let’s stick with it.
In case you are curious, let’s check the type of this logInfo function:
> :type logInfo
logInfo :: 
   (IfStuck 
      (IndexOf r (Found r Log)) 
      (IfStuck r (TypeError ...) 
      (Pure (TypeError ...))) NoErrorFcf, 
   Find r Log, IndexOf r (Found r Log) ~ Log) 
 => String -> Sem r ()You know what? Let’s pretend we never saw that. We don’t actually need to understand this (I don’t).
What if we display information about this function instead?
> :info logInfo
MemberWithError Log r => Text -> Sem r ()which reads as “Give me a Text and I’ll give you a Sem monad with at least the Log effect”.
And that’s it! We have declared our logging effect. Remember, with effects, we split effect declaration and effect interpretation. This piece of code in no way explains how one should log. That is the whole point!
Effect use
Now that we have created our logging effect, let’s use it in our business code! Let’s say we currently have this piece of code:
myBusinessFunction :: Integer -> Integer -> IO Integer
myBusinessFunction m n = do
  putStrLn $ "myBusinessFunction was called with parameters " <> show m <> 
             " and " <> show n
  let result = m + n
  putStrLn $ "myBusinessFunction result is " <> show result
  pure resultIO is too coarse, we want to replace its use with our shiny new effect. Fear not, my friend, this is as simple as:
myBusinessFunction :: Member Log r => Integer -> Integer -> Sem r Integer
myBusinessFunction m n = do
  logInfo $ "myBusinessFunction was called with parameters " <> show m <> 
            " and " <> show n
  let result = m + n
  logInfo $ "myBusinessFunction result is " <> show result
  pure resultThe main changes are:
- the constraint Member Log rwhich tells thatrmust have at least theLogeffect (because we will use it in our implementation)
- the return type Sem r Integerwhich you can read as “A Polysemy monad with the list of effectsrand which returns anInteger”. And the only thing we know (and we need to know) is thatrhas theLogeffect. It may very well have a thousand other effects, or none, we don’t care in this business code. We declare the needed effects, not the exhaustive list of effects
- the use of logInfo(remember? It was generated thanks tomakeSem ''Login the effect declaration) to actually log stuff
This piece of code is much better. Now our business code better expresses its effects in the type signature (it logs, and cannot do anything else!), no longer has hardcoded the implementation (putStrLn), and we haven’t added any complexity to our code.
Now you might wonder “This is great, but at some point, somebody’s gotta do the actual logging with putStrLn!”.
Effect interpretation
This is where the real world catches on us. It’s time to explain how the Log effect must be interpreted in terms of putStrLn. Say the previous business code was consumed as such:
main :: IO ()
main = do
  m <- readLn :: IO Integer
  n <- readLn :: IO Integer
  result <- myBusinessFunction m n
  putStrLn $ "The business result is " <> show resultAfter the changes we did to myBusinessFunction this code no longer compiles, because myBusinessFunction works in the Sem monad while main works in the IO monad.
First, let’s write a function to interpret the Log effect in terms of IO:
logToIO :: Member (Embed IO) r => Sem (Log ': r) a -> Sem r a
logToIO = interpret (\(LogInfo stringToLog) -> embed $ putStrLn stringToLog)There’s a lot going on! Don’t panic, as impressive as it may look the first time, you will soon get used to it.
- the Member (Embed IO) rconstraint meansrmust have the ability to doIO. Ideally we would writeMember IO rbut sinceIOis not a Polysemy effect, we need to wrap it as an effect thanks toEmbed. Note we require theIOeffect because we want our main application to log usingputStrLn. In our tests, we will write another interpreter with a pure function, thus we will not need to require theIOeffect
- the Sem (Log ': r) a -> Sem r atype signature can be read as “I take aSemmonad which has any effect and theLogeffect, and return the sameSemmonad without thatLogeffect”, effectively meaning we are interpreting (destroying/consuming) theLogeffect. Note, in more recent versions ofPolysemy(unfortunately not yet available on Stackage), this type signature can be replaced withInterpreterFor Log r, which makes the function intention even clearer!
- note that the implementation does not explicitly mention the input argument (called pointfree style), this is how interpreters usually look
- interpretmeans what follows will be an interpreter
- since all we know about ris that it has theLogeffect, we need to interpret only its actions (LogInfo)
- the (LogInfo stringToLog)pattern matching lets us capture the string to log when the action to interpret isLogInfo
- putStrLn stringToLogis the actual logging, however since its type is- IO (), we need to wrap it back into our- Semmonad, thanks to- embed
Again, this usually is the toughest part to grasp. Don’t worry if it takes time to sink in. Wash, rinse, repeat.
Now we are able to convert a Sem monad with the Log effect to a Sem monad with the Embed IO effect. The last piece of the puzzle we need is to convert a Sem monad with IO to a good ol’ IO. Thankfully Polysemy already provides such a function, namely runM.
Let’s head back to our main function and explain to the compiler (and the reader) how one is supposed to interpret those hippie effects back into motherland IO:
main :: IO ()
main = do
  m <- readLn :: IO Integer
  n <- readLn :: IO Integer
  result <- runM . logToIO $ myBusinessFunction m n
  putStrLn $ "The business result is " <> show resultThat’s it, our code was successfully migrated from monolithic effect IO to fine-grained Log effect! The additional noise is negligible and the benefit is already interesting, but the benefits increase tenfold in “real” applications with several effects, several actions per effect, several business functions calling each other, reinterpretations, and tests.
In my next blog post, I explain how to write tests for business functions with Polysemy effects.
You can find the full code example on my Github repo.