danidiaz / dep-t

Dependency injection for records-of-functions.
http://hackage.haskell.org/package/dep-t
BSD 3-Clause "New" or "Revised" License
8 stars 2 forks source link

Add generic "Has-" typeclass #5

Closed danidiaz closed 3 years ago

danidiaz commented 3 years ago

There could be a Has typeclass for when we don't want to write specialized HasLogger, HasRepository... typeclasses.

It could be something like this:

{-# LANGUAGE AllowAmbiguousTypes #-}
{-# LANGUAGE PolyKinds #-}
{-# LANGUAGE DefaultSignatures #-}
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE TypeApplications #-}

type Has :: k -> (Type -> Type) -> Type -> Constraint
class Has k d e | e -> d where
    type The k d e :: Type
    type The k d e = DepType k d
    the :: e -> The k d e
    default the :: (DepMetadata k, HasField (PreferredDepName k) e (The k d e)) => e -> The k d e
    the = getField @(PreferredDepName k)

type DepMetadata :: k -> Constraint
class DepMetadata k where
    type PreferredDepName k :: Symbol
    type DepType k (d :: Type -> Type) :: Type

Poly-kinded on the "marker", to allow using lifted datakinds to identify the component. When the component is a parametrizable record, perhaps we could use the record itself as the marker.

Markers could have DepMetadata instances that would produce the actual type of the dependency given the marker.

danidiaz commented 3 years ago

A less ambitious version has been implemented in branch has_helper (8bcfd6f).

type Has :: ((Type -> Type) -> Type) -> (Type -> Type) -> Type -> Constraint
class Has k d e | e -> d where
  dep :: e -> k d
  default dep :: (DepDefaults k, HasField (DefaultFieldName k) e (k d)) => e -> k d
  dep = getField @(DefaultFieldName k)

type DepDefaults :: k -> Constraint
class DepDefaults k where
  -- The Char kind would be useful here, to lowercase the first letter of the
  -- k type and use it as the default preferred field name.
  type DefaultFieldName k :: Symbol

The "marker" is not poly-kinded here. Instead, the marker is a monad-parameterizable record (or newtype) which holds the functions:

newtype Logger d = Logger { log :: String -> d () }

instance DepDefaults Logger where
    type DefaultFieldName Logger = "logger" 

data Repository d = Repository {
    select :: String -> d [Int],
    insert :: [Int] -> d ()
}

instance DepDefaults Repository where
    type DefaultFieldName Repository = "repository"

It seems to work well:

mkController :: forall d e m . MonadDep [Has Logger, Has Repository] d e m => Controller m
mkController = Controller \url -> do
  e <- ask
  liftD $ log (dep e) "I'm going to insert in the db!" 
  -- liftD $ (dep e).log "I'm going to insert in the db!" -- Once RecordDotSyntax arrives...
  liftD $ select (dep e) "select * from ..." 
  liftD $ insert (dep e) [1,2,3,4]
  return "view"

-- also toss in this helper function
withEnv :: forall d e m r . (LiftDep d m, MonadReader e m)  => (e -> d r) -> m r      
withEnv f = do
    e <- ask
    liftD (f e)

-- better than with all that liftD spam... although slightly less flexible
mkController' :: forall d e m . MonadDep [Has Logger, Has Repository] d e m => Controller m
mkController' = Controller \url -> withEnv \e -> do
  log (dep e) "I'm going to insert in the db!" 
  select (dep e) "select * from ..." 
  insert (dep e) [5,3,43]
  return "view"

One problem (if it's a problem) with this style is that it pushes us towards avoiding "bare" functions in the environment, and instead using nested records or at least wrapper newtypes. Which could make applying advices a bit more cumbersome.

danidiaz commented 3 years ago

This is still poly-kinded:

type DepDefaults :: k -> Constraint
class DepDefaults k where
  type DefaultFieldName k :: Symbol

Should it be? Perhaps if k were ((Type -> Type) -> Type), some more useful behaviors and data could be attached to this typeclass.

danidiaz commented 3 years ago

Added in 5b6654fa47ec08e74503ffbb600cd438e83ef6a3.