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
Lists, were lifted to the
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
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
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 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
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
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
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.
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.
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
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
It also means that compiler errors can be much harder to understand.
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
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.
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
operational interface as well, giving us the best of both worlds.