3 Approaches to Monadic API Design in Haskell

blog_@7mar.jpg

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 \[…\]

Introduction

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 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_.

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:

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.

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 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.

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.