3 Approaches to Monadic API Design in Haskell
Designing a good API for a library is a challenging problem. In Haskell
getting the mix right between specialised and generic can be tricky. For
example, just look at the
controversy
around FTP,
where the functions that used to defined for List
s, were lifted to the
Foldable
and Traversable
typeclasses. When designing a monadic API
the right choice becomes even less obvious. In this post I will discuss
3 general approaches, along with their associated pros and cons.
For simplicity, and because it is most common, we will assume that the
base monad is IO
.
1. Concrete monads
The simplest option is to have the functions return IO a
. This is how
most of the core libraries are implemented, for example the functions in
Prelude.
If your library wraps lower level IO actions, then you must decide
whether the lower level action should be baked into your library, or
whether it should be passed in by the caller. For example my
pusher-http-haskell
library uses
http-client
to
actually make the http requests, but as an end user, you may want to use
the HTTP
library.
Lower level libraries will likely need to handle the actual IO themselves, but for higher level libraries it can be useful for the end user if they are provided with more flexibility. It also has the advantage of making it easier to mock the IO actions for testing.
The disadvantage is that the caller may have to supply an extra parameter even if they don’t need to. Often libraries will provide two versions of a function: one with default options, and another that is more configurable.
Wrap it in a monad transformer stack?
The problem with returning just IO a
is that you cannot seamlessly
interleave the effects of other monads.
Interleaving other monads with IO can be very useful. A classic example
of this came up in my pusher-http-haskell
library. Often you will have
some form of configuration options that change the way functions behave.
The simple approach is to accept these as parameters, but this can lead
to duplication if you have to repeatedly call functions from the library
with the same config – possibly making you dream of an OOP language!
A potential solution in this case would be to wrap IO
in a
ReaderT
.
ReaderT
is a monad
transformer,
which essentially lets you combine the effects of a Reader
monad with
a base monad – in this case IO
. What this means is that as well as
performing IO actions, we can also read from an implicit environment
(the config options). The environment only needs to be passed in a
single time when calling runReaderT
.
Problem with concrete monads
Whether returning just IO actions, or a concrete monad transformer stack, the main issue is that the monad you return may not match the monad the caller of the library is using.
Commonly this problem will manifest itself in the caller of the library
having to explicitly
lift
the computations to their monad transformer stack. This is not the end
of the world, but leads to a lot of boilerplate/excessive typing.
This problem becomes more serious for callback-based APIs. Consider this
function from the
websockets
library:
type ServerApp = PendingConnection -> IO ()
runServer :: String -> Int -> ServerApp -> IO ()
The problem is a simple lift
can’t change the return type of the
callback ServerApp
. This means that the caller is forced to “run”
their monad transformer stack in the callback.
One solution is to use
liftBaseOp
,
but in general the monad-control
library is complicated (try making an
instance of MonadBaseControl
) so I try and avoid it if possible.
2. Monad typeclasses
MonadIO
The transformers
library provides a MonadIO
typeclass which any
transformer stack with IO
as the base is an instance of. The great
thing about this is that if a library returns results in MonadIO
, then
the concrete type will specialise to whatever monad transformer stack
the caller is using.
Enter mtl
mtl
goes a step further, and defines monad typeclasses for all
standard monad transformers. This means that the caller can be using
any monad transformer stack provided it contains the monads that are
instances of the mtl
typeclasses of the library.
aws
and
fb
use this style to add
monads for handling errors alongside MonadIO
.
A neat advantage for certain typeclasses like MonadThrow
is that the
caller can either specialise it to a monad transformer like ExceptT
,
or simply to IO
where it will become a regular exception.
Issues with typeclasses
Conceptually mtl
is nice, but as well as incurring yet another
dependency, there are also other disadvantages:
The types become more complicated
This can be a particular hurdle for beginner Haskell programmers, for
example (MonadReader r m) -> m a
is more generic, but much less understandable than
r -> a
(functions from type r
are an instance of MonadReader r
).
It also means that compiler errors can be much harder to understand.
Potentially performance
A problem with using typeclasses is that function invocation is performed dynamically, because the implementation of the instance must be looked up at runtime, which can lead to worse performance. This Haskell wiki page says it can be 3 times as slow. Having said that, GHC will often automatically inline the correct implementation at compile time, so this may not turn out to be such an issue.
3. Free/operational monads
Another increasingly popular approach to handling monadic code over
mtl
is to use the free
or operational
monad. Without going too much into the technical details, they
essentially allow your library to return a series of instructions; the
caller of the library must then write an interpreter which actually
performs the corresponding effects (think IO actions).
This is nice because it decouples the logic from the means of performing the effects. Another advantage is that it allows different interpreters to be written for different purposes; this would be particular useful when writing tests.
However this approach comes with similar problems as mtl
. It also has
the disadvantage of being less widely used/understood at the moment.
Furthermore it means that the caller of the library will likely need to write more code because they have to implement the interpreter. For more complex libraries, this may be worth it, but for simple things like writing a client library for a web API, it is most likely overkill.
Conclusion
It is always tempting when writing a library to go for the most generic API possible, and as we have seen there are some great ways of doing this in Haskell. But there are also disadvantages to doing this which should be considered. To summarise these:
- more confusing type signatures and error message
- incur more dependencies on your library
- can introduce performance costs
Currently I have an mtl
-based API in my pusher-http-haskell
library.
But because of the disadvantages discussed here I am going to make it
return only MonadIO
, and allow the specific IO actions to be passed in
as parameters.
At the moment I am using MonadReader
for reading configuration, but I
am going to move back to regular parameters instead of mtl
because I
don’t think it is worth the added complexity in the types. In addition,
it is a library that does not need to be called frequently, so having an
implicit environment over explicit parameters is not such a big problem.
In general I think concrete monad transformer stacks should be avoided
unless the library is very opinionated e.g. a web framework. It is
tempting to use mtl
typeclasses all over the place, but in general I
think it should be avoided in library APIs. That said, using monad
typeclasses with non-monad transformer instances (e.g. MonadError
can
be a regular exception in IO
) is much more reasonable than using
something like MonadState
. I think free/operational are very
interesting approaches, but should be avoided in public APIs for the
time being. I think they can be a very effective approach for internal
APIs however.
Having said all that, there’s no harm in providing a simple IO
based
API, and then providing wrapper libraries that provide an
mtl
/free
/operational
interface as well, giving us the best of both
worlds.