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 Foldable and Traversable typeclasses. When designing a monadic API \[…\]
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_
.
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.
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
.
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:
1type ServerApp = PendingConnection -> IO () 2 3runServer :: 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.
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.
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.
Conceptually mtl
is nice, but as well as incurring yet another dependency, there are also other disadvantages:
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.
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.
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.
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:
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.