Search code examples
haskellmonad-transformers

Implicit lifting when combining / mixing mtl-style typeclass constraints


I'm currently refactoring some Haskell code that I have that interacts with Data.Time. Ultimately I have a bunch of functions that interact with time:

getCurrentTime :: IO UTCTime
getCurrentTime = T.getCurrentTime

getCurrentDay :: IO Day
getCurrentDay = T.utctDay <$> getCurrentTime

daysUntil :: Day -> IO Integer
daysUntil day = T.diffDays day <$> getCurrentDay

etc etc and so on, ultimately these are just my own helper functions that are all based around T.getCurrentTime from Data.Time. Which is the 'effect' of all of these functions.

The first refactor I did to this code was to simply change them to use MonadIO to allow them to be used in the various transformer stacks compatible with this typeclass:

getCurrentTime :: MonadIO m => m UTCTime
getCurrentTime = liftIO T.getCurrentTime

getCurrentDay :: MonadIO m => m Day
getCurrentDay = T.utctDay <$> getCurrentTime

daysUntil :: MonadIO m => Day -> m Integer
daysUntil day = T.diffDays day <$> getCurrentDay

This is straightforward enough as I just need to lift T.getCurrentTime and the rest of the implementations just follow suit.

Recently though I have been reading about stubbing and faking effects in Haskell, and would like to be able to run these functions with a fake UTCTime result for getCurrentTime.

Going off some of the things I have read online, and looking at how Pandoc implements separating out pure and effectful operations, I've come up with this:

newtype TimePure a = TimePure
  { unTimePure :: Reader UTCTime a
  } deriving (Functor, Applicative, Monad, MonadReader UTCTime)

newtype TimeEff m a = TimeEff
  { unTimeIO :: m a
  } deriving (Functor, Applicative, Monad, MonadIO)

class (Functor m, Applicative m, Monad m) => TimeMonad m where
  getCurrentTime :: m UTCTime

instance TimeMonad TimePure where
  getCurrentTime = ask

instance MonadIO m => TimeMonad (TimeEff m) where
  getCurrentTime = liftIO T.getCurrentTime

getCurrentDay :: TimeMonad m => m Day
getCurrentDay = T.utctDay <$> getCurrentTime

daysUntil :: TimeMonad m => Day -> m Integer
daysUntil day = T.diffDays day <$> getCurrentDay

Again, other than the additional definitions at the top, I haven't had to change much - my original functions just need to change to use TimeMonad m rather than MonadIO m.

This is ideal, and I am able to run my time functions in a pure context now.

However now when I come to some real world code, given an example function like this that interacts with the DB:

markArticleRead :: MonadIO m => Key Article -> SqlPersistT m ()
markArticleRead articleKey =
  updateLastModified articleKey =<< getCurrentTime

I have to adjust my function like so:

markArticleRead :: (MonadIO m, TimeMonad m) => Key Article -> SqlPersistT m ()
markArticleRead articleKey =
  updateLastModified articleKey =<< lift getCurrentTime

Obviously I have to do this as getCurrentTime does not need MonadIO to run. The issue I have is with the re-introduction of lift, this is needed because there is two 'layers' of the transformer stack, rather than one (I think thats an appropriate explaination?).

One of the nice things about the introduction of MonadIO was it removed the need for having to lift things everywhere, and it made functions like this, which a lot of the time contain business logic etc, a lot less noisy. Is there a way for me to re-gain this benefit, where I can get mtl style implicit lifting, or is it impossible now due to the types I have introduced?


Solution

  • Your problem is TimeEff, its just not needed. The interface separation is type classes, not concrete Monads. TimePure is good because you need some Monad to provide facilities for testing, but since any old MonadIO can do the trick for the IO case, you just don't need to specific a concrete Monad for that.

    TimeEff as you have it adds only one thing to your program, and it's the need to use lift to convert a TimeEff m to m. And since this works forall MonadIO we can use UndecidableInstances to permit unification without even adding TimeMonad to the effectful case. (I know UndecidableInstances sounds bad, but it's not)

    Running example

    instance (Monad m, MonadIO m) => TimeMonad m where
      getCurrentTime = liftIO T.getCurrentTime
    
    markArticleRead :: MonadIO m => Key Article -> SqlPersistT m ()
    markArticleRead articleKey =
      updateLastModified articleKey =<< getCurrentTime
    

    Some other notes.

    class (Functor m, Applicative m, Monad m) => TimeMonad m where
    

    can be

    class Monad m => TimeMonad m where
    

    since Monad already has Applicative and Functor as superclasses. So those come along for free. Now as a matter of personal taste, I would even leave out Monad

    class GetsTime m where
      getCurrentTime :: m UTCTime
    

    This kind of decoupling is nice, both because it makes your code more general, but also because it removes any relationship to algebras. The class here really has no laws, and is just not algebraic, so it's nice to leave those relationships open imho. This would mean you need to add annotations in some places, but I feel it's a good thing to document the algebraic constraint and effectful constrain separately.

    getCurrentDay :: (Functor m, TimeMonad m) => m Day
    getCurrentDay = T.utctDay <$> getCurrentTime