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
polysemy
andpolysemy-plugin
to ourpackage.yaml
dependencies
- Add the following to
ghc-options
:
- -fplugin=Polysemy.Plugin
- Add the following to
default-extensions
(we also addTemplateHaskell
because we use it to reduce boilerplate):
- DataKinds
- FlexibleContexts
- GADTs
- LambdaCase
- PolyKinds
- RankNTypes
- ScopedTypeVariables
- TemplateHaskell
- TypeApplications
- TypeOperators
- TypeFamilies
Logging
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 ()
'Log makeSem '
This is pretty dense already, let’s analyze bit by bit what’s going on!
data Log m a
is our effect.
Log
is 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!m
must always be there (you can guess them
stands forMonad
but 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
= do
myBusinessFunction m n putStrLn $ "myBusinessFunction was called with parameters " <> show m <>
" and " <> show n
let result = m + n
putStrLn $ "myBusinessFunction result is " <> show result
pure result
IO
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
= do
myBusinessFunction m n $ "myBusinessFunction was called with parameters " <> show m <>
logInfo " and " <> show n
let result = m + n
$ "myBusinessFunction result is " <> show result
logInfo pure result
The main changes are:
- the constraint
Member Log r
which tells thatr
must have at least theLog
effect (because we will use it in our implementation) - the return type
Sem r Integer
which you can read as “A Polysemy monad with the list of effectsr
and which returns anInteger
”. And the only thing we know (and we need to know) is thatr
has theLog
effect. 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 ''Log
in 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 ()
= do
main <- readLn :: IO Integer
m <- readLn :: IO Integer
n <- myBusinessFunction m n
result putStrLn $ "The business result is " <> show result
After 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
= interpret (\(LogInfo stringToLog) -> embed $ putStrLn stringToLog) logToIO
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) r
constraint meansr
must have the ability to doIO
. Ideally we would writeMember IO r
but sinceIO
is not a Polysemy effect, we need to wrap it as an effect thanks toEmbed
. Note we require theIO
effect 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 theIO
effect - the
Sem (Log ': r) a -> Sem r a
type signature can be read as “I take aSem
monad which has any effect and theLog
effect, and return the sameSem
monad without thatLog
effect”, effectively meaning we are interpreting (destroying/consuming) theLog
effect. 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
interpret
means what follows will be an interpreter- since all we know about
r
is that it has theLog
effect, 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 stringToLog
is the actual logging, however since its type isIO ()
, we need to wrap it back into ourSem
monad, thanks toembed
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 ()
= do
main <- readLn :: IO Integer
m <- readLn :: IO Integer
n <- runM . logToIO $ myBusinessFunction m n
result putStrLn $ "The business result is " <> show result
That’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.