I was recently working on a small little project - a client API for the ListenBrainz project. Most of the details aren’t particularly interesting - it’s just a HTTP client library to a REST-like API with JSON. For the implementation, I let Servant and aeson do most of the heavy lifting, but I got stuck when considering what final API to give to my users.
Obviously, interacting with ListenBrainz requires some sort of IO so whatever API I will be offering has to live within some sort of monad. Currently, there are three major options:
Supply an API targetting a concrete monad stack.
Under this option, our API would have types such as
submitListens :: ... -> M () getListens :: ... -> M Listens
M is some particular monad (or monad transformer).
Supply an API using type classes
This is the
mtl approach. Rather than choosing which monad my users have to work in, my API can be polymorphic over monads that support accessing the ListenBrainz API. This means my API is more like:
submitListens :: MonadListenBrainz m => ... -> m () getListens :: MonadListenBrainz m => ... -> m Listens
Use an extensible effects framework.
Extensible effects are a fairly new entry, that are something of a mix of the above options. We target a family of concrete monads -
Eff - but the extensible effects framework lets our effect (querying ListenBrainz) seamlessly compose with other effects. Using
freer-effects, our API would be:
submitListens :: Member ListenBrainzAPICall effects => ... -> Eff effects () getListens :: Member ListenBrainzAPICall effects => ... -> Eff effects Listens
So, which do we choose? Evaluating the options, I have some concerns.
For option one, we impose pain on all our users who want to use a different monad stack. It’s unlikely that your application is going to be written solely to query ListenBrainz, which means client code becomes littered with
lift. You may write that off as syntactic, but there is another problem - we have committed to an interpretation strategy. Rather than describing API calls, my library now skips directly to prescribing how to run API calls. However, it’s entirely possible that you want to intercept these calls - maybe introducing a caching layer or additional logging. Your only option is to duplicate my API into your own project and wrap each function call and then change your program to use your API rather than mine. Essentially, the program itself is no longer a first class value that you can transform.
Extensible effects gives us a solution to both of the above. The use of the
Member type class automatically reshuffles effects so that multiple effects can be combined without syntatic overhead, and we only commit to an interpretation strategy when we actually run the program.
Eff is essentially a free monad, which captures the syntax tree of effects, rather than the result of their execution.
Sounds good, but extensible effects come with (at least) two problems that make me hesistant: they are experimental and esoteric, and it’s unclear that they are performant. By using only extensible effects, I am forcing an extensible effects framework on my users, and I’d rather not dictate that. Of course, extensible effects can be composed with traditional monad transformers, but I’ve still imposed an unnecessary burden on my users.
So, what do we do? Well, as Old El Paso has taught us: why don’t we have both?
It’s trivial to actually support both a monad transformer stack and extensible effects by using an
mtl type class. As I argue in Monad transformers, free monads, mtl, laws and a new approach, I think the best pattern for an
mtl class is to be a monad homomorphism from a program description, and often a free monad is a fine choice to lift:
class Monad m => MonadListenBrainz m where liftListenBrainz :: Free f a -> m a
But what about
f? As observed earlier, extensible effects are basically free monads, so we can actually share the same implementation. For
freer-effects, we might describe the ListenBrainz API with a GADT such as:
data ListenBrainzAPICall returns where GetListens :: ... -> ListenBrainzAPICall Listens SubmitListens :: ... -> ListenBrainzAPICall ()
However, this isn’t a functor - it’s just a normal data type. In order for
Free f a to actually be a monad, we need
f to be a functor. We could rewrite
ListenBrainzAPICall into a functor, but it’s even easier to just fabricate a functor for free - and that’s exactly what
Coyoneda will do. Thus our
mtl type class becomes:
class Monad m => MonadListenBrainz m where liftListenBrainz :: Free (Coyoneda ListenBrainzAPICall) a -> m a
We can now provide an implementation in terms of a monad transformer:
instance Monad m => MonadListenBrainz (ListenBrainzT m) = liftListenBrainz f . lowerCoyoneda . hoistCoyoneda go) iterM (join where go :: ListenBrainzAPICall a -> ListenBrainzT m a
or extensible effects:
instance Member ListenBrainzAPICall effs => MonadListenBrainz (Eff effs) where = iterM (join . lowerCoyoneda . hoistCoyoneda send) f liftListenBrainz f
or maybe directly to a free monad for later inspection:
instance MonadListenBrainz (Free (Coyoneda ListenBrainzAPICall)) where = id liftListenBrainz
For the actual implementation of performing the API call, I work with a concrete monad transformer stack:
performAPICall :: Manager -> ListenBrainzAPICall a -> IO (Either ServantError a)
which both my extensible effects “run” function calls, or the
go function in the
iterM call for
In conclusion, I’m able to offer my users a choice of either:
All without extra syntatic burden, a complicated type class, or duplicating the implementation.
You can see the final implementation of
The ReaderT design pattern has been mentioned recently, so where does this fit in? There are two options if we wanted to follow this pattern:
Managerin our environment, and commit to using this. This has all the problems of providing a concrete monad transformer stack - we are committing to an interpretation.
I don’t feel like the ReaderT design pattern offers anything that isn’t already dealt with above.
You can contact me via email at email@example.com or tweet to me @acid2. I share almost all of my work at GitHub. This post is licensed under a Creative Commons Attribution-NonCommercial-NoDerivs 3.0 Unported License.