DEV Community

Rickard Andersson
Rickard Andersson

Posted on • Updated on

API constraints a'la carte in Haskell & PureScript

The example in this post is 100% compatible in PureScript as well, with the exception of the effect monad which is called Effect in PureScript. Replacing IO with Effect is all you have to do, as well as use the libraries available there.

I think everyone who's in and around Haskell & PureScript hear some variant of "Every program you make always ends up having a bunch of IO () in it all the time anyway." and they're not necessarily wrong. The complaint is that you have this neat type system that should constrain your effects but all you get is IO or Effect, which just plain permits everything.

It's not untrue, but it's not exactly the full story either. Consider the following function:

downloadFile' :: Link -> IO (Response ByteString)
downloadFile' (Link link) =
  Http.get link `Exception.catch`
  (\(HttpExceptionRequest req (StatusCodeException resp bytestring)) -> do
     case resp ^. responseStatus . statusCode of
       404 -> putStrLn $ "ERROR: File not found (404) for " <> link
       code ->
         putStrLn $ "ERROR: Unknown error with code " <> show code <> " for " <>
     pure $ fmap (const (LBS.fromStrict bytestring)) resp)

We can of course see that we're trying to download a file. But there's a lot going on that doesn't really have anything to do with downloading files in here. In total, the effects we are dealing with:

  • We're using HTTP functions: Http.get
  • We're dealing with exceptions: Exception.catch
  • We're printing to the terminal: putStrLn

How can we be clearer in our types about what we're doing in a lightweight way?

Well, let's make some constraints:

class MonadTerminalIO m where
  putStrLnM :: String -> m ()

instance MonadTerminalIO IO where
  putStrLnM = putStrLn

class MonadHttp m where
  httpGetM :: String -> m (Response LBS.ByteString)

instance MonadHttp IO where
  httpGetM = Http.get

Exception.catch already has an associated type class/constraint, so we don't need to make that one.

We can now transform our function to the following:

downloadFile ::
     (MonadHttp m, MonadTerminalIO m, Exception.MonadCatch m)
  => Link
  -> m (Response ByteString)
downloadFile (Link link) =
  httpGetM link `Exception.catch`
  (\(HttpExceptionRequest req (StatusCodeException resp bytestring)) -> do
     case resp ^. responseStatus . statusCode of
       404 -> putStrLnM $ "ERROR: File not found (404) for " <> link
       code ->
         putStrLnM $ "ERROR: Unknown error with code " <> show code <> " for " <>
     pure $ fmap (const (LBS.fromStrict bytestring)) resp)

We're now being a lot clearer with what we're doing in our type signature and all it took was a couple of type classes and a generic return monad type.

Because we now return m (Response ByteString) and have a set of constraints on m instead, we've guaranteed that there can't be any random IO or other effects in our function, because those are in fact not valid for any m the type system can imagine. When we try to add something that can talk to the network, for example, it would have to have an associated constraint called MonadNetwork, for example, and we would have to add it to our constraints to make those functions available in that scope.

If we find ourselves wanting better type signatures, they could be just a few small constraints / type classes away. It's a very effective way to limit the capabilities of a function and be very clear about what's happening inside of it.

This is all possible because of type classes which serve as constraints on generic type variables, as well as higher-kinded types that allow us to talk generically about types wrapping types and together they form something I really like about Haskell & PureScript.

Discussion (0)