API constraints a'la carte in Haskell & PureScript
Rickard Andersson
Posted on May 8, 2019
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 " <>
link
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 " <>
link
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.
Posted on May 8, 2019
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.