Defer extra constraints in later integration stage of the development

I’d like to share one scenario I have encountered using Haskell and a solution for it I have come up with. And I’d appreciate your feedback on my attempt.

I have modules that is defining a set of logic in “conceptual-level” where “irrelevant” details should be stripped off as much as possible, e.g. stringification(Show), serialization, etc.

Let’s look at one example:

class A a where

data AnyA = forall a. A a => MkAnyA a

data A1 = A1
instance A A1

data A2 = A2
instance A A2

someAs :: [AnyA]
someAs = [MkAnyA A1, MkAnyA A2, MkAnyA A1]

-- PROBLEM: How to extend "Show" instances to all A-s so that we can do "show a"
instance Show AnyA where show (MkAnyA a) = "Which A?"

main :: IO ()
main = do
  (putStrLn . show) someAs

Here is the solution I have, the trick is to use a ExtraConstraints open type family as a “extension point” for future integrations. Here is the full code:

{-# LANGUAGE TypeFamilies            #-}
{-# LANGUAGE UndecidableSuperClasses #-}

import           Data.Kind (Constraint, Type)


-- | Extra constraints type family served as type class extension mechanism for the type
type family ExtraConstraints (a :: Type -> Constraint) :: Type -> Constraint

--------------------------------------------------------------------------------
-- Concept-level
--
-- In this level, we want the definitions stripping off as much as irrelevant
-- details possible.
--------------------------------------------------------------------------------
-- * A as a conceptual-level type class needs not to know what integrations would be
--   added to the system-level instances of A.
-- * To allow future extensions, ExtraConstraints is provided as the unknown constraints
--   decided only in the system-level instances.
-- * GHC is paranoid about cyclic constraints, so it wants UndecidableSuperClasses.
class (ExtraConstraints A a) => A a where

-- * E.g. an existential type of all A-s needs not to know the actual constraints in the system-level
data AnyA = forall a. A a => MkAnyA a

-- * Let's define some instances
data A1 = A1
instance A A1

data A2 = A2
instance A A2

-- * Let's define a function that uses these instances
someAs :: [AnyA]
someAs = [MkAnyA A1, MkAnyA A2, MkAnyA A1]

--------------------------------------------------------------------------------
-- System-level
--
-- In this level, we apply the concepts and add integration to them.
--------------------------------------------------------------------------------

-- * E.g. We integrate the "Show" type class for all A-s
type instance ExtraConstraints A = Show

-- * Show instances need to be defined, otherwise GHC would throw "No insance" errors
instance Show A1 where show _ = "A1"
instance Show A2 where show _ = "A2"

-- * Now the system can comfortably have a Show instance for the AnyA
-- * Note that A did not need to know Show was required constraints in the system-level
instance Show AnyA where show (MkAnyA a) = show a

main :: IO ()
main = do
  (putStrLn . show) someAs

Cheers,

2 Likes

Thanks for sharing this interesting approach. I see that you’re able to sneak Show into AnyA without making AnyA or A depend on it. However, AFAICT you’ll need orphan instances in order to make use of this property. After all, if the definition of class A and type instance ExtraConstraints A will live in different modules, the latter can’t be in the same module that defines type family ExtraConstraints (since A depends on it). The good news is, I’ve experimented a little to trigger some nasty behavior caused by inconsistent orphan ExtraConstraints A instances in different modules, but seems like GHC actually checks for conflicting type family instances in dependencies, so I get a compile error when I try to combine some nasty modules in Main.hs. That said, I’m not sure if GHC performs that check across package boundaries. I.e. if AnyA and ExtraConstraints are defined in package a and then package b and c contain conflicting definitions for instance ExtraConstraints A in some internal modules and then package d passes an AnyA produced by package b into a function coming from package c. I could imagine a potential segfault coming out of that, but I’m too lazy to try it…

Putting aside the orphan instance issue, my main concern would be the fact that even though A doesn’t depend on Show (or the way you’re sneaking it into AnyA), it’s still coupled with Show. I.e. there’s no extra architectural flexibility gained compared to just having data AnyA = forall a. (Show a, A a) => MkAnyA a . The only difference is in module organization. Don’t get me wrong, module organization can be a critical concern in a non-trivial Haskell codebase. Maybe you’ll need FancyConstraintFromMyApplicationDomain instead of Show and that constraint might necessarily depend on 50% of your codebase, so not depending on it can be very valuable for AnyA.

So, I can see this being useful under special circumstances if you’re already knee deep into passing existentials around, but I think your AnyA should be avoided as much as possible. Nothing beats the modularity of passing plain data types around and allowing individual use sites to decide on what combinations of constraints they need. Existentials are all about sealing off and hiding details, so I think it’s a little backwards to try to introduce extensibility back into them.

Thank you @enobayram for the feedback. I agree that orphaned instance is often required later to make it work. And I am still curious if it is “special circumstances”, or an often come-up requirement.

I did a bit digging and found couple of links related: