Home > Enterprise >  Right way to set constraint on function arguments
Right way to set constraint on function arguments

Time:08-28

I have some function. And I want to restrict types that can be passed to this function. Let's say only types that are cacheable. I can enumerate such types with a type family like:

type family Cacheable a::Bool where
  Cacheable X = 'True
  Cacheable _ = 'False

And add such a constraint to my function:

myFunc :: forall a. (Cacheable a ~ 'True) => ....

but this constraint is little bit redundant: I can remove it from the function's signature, and nothing changes. Nothing forces it to be in the signature.

Another approach is to create some typeclass whose usage in the body of myFunc will force me to add this constraint to myFunc's signature:

class Cacheable a
  ensureCacheable :: ()
  ensureCacheable = ()

instance Cacheable X

myFunc :: forall a. (Cacheable a) => ...
myFunc =
  let _ = ensureCacheable @a
  in ...

but it looks little bit funny.

What is the right/canonical way to do it in Haskell?

Let's suppose that Cacheable cannot have any reasoning methods. Think about it like about some classifier. Another name is IsQuery (vs IsCommand) and queries are cacheable, commands - no.

CodePudding user response:

For the sake of discussion, let's pick a single terminology:

type family CacheableTF a :: Bool where
  CacheableTF X = 'True
  CacheableTF _ = 'False

class CacheableC a
instance CacheableC X

What's the difference between these? Two aspects:

  • CacheableTF a expresses in classical logic the fact that a is a cacheable type. It's just a boolean after all. Thus you can arbitrarily stack negations on top of this. (Cacheable a ~ 'False) => .... is a constraint just as valid as the 'True version.
    By contrast, CacheableC a is constructive, it's a proposition, a promise that anything which has this in context will be able to access the methods of the type class. (Of course, in your example the method was pretty useless, but even then you could still build other functions on top of it.) Unlike with CacheableTF, you can't really use the negation of CacheableC at all.
    This aspect is directly reflected in the kinds:

    CacheableTF :: Type -> Bool
    CacheableC :: Type -> Constraint
    
  • CacheableTF is closed-world, CacheableC is open-world. If you want this to be an interface where people can later on make their own types cacheable as well, you need CacheableC. But this power is one of the reasons why class constraints can't be negated: just because the compiler can't find any instance while compiling one module, doesn't mean the type won't have an instance by the time the complete program is linked together.

If you want to make a decision based on whether or not the type is cacheable, you need CacheableTF. However in practice, you'd typically also need some methods detailing how to cache it, in the True case, not just trivial methods like in your CacheableC. This can be accomplished by a more general class that covers both the cacheable- and non-cacheable cases:

class DecideCache a where
  type IsCacheable a :: Bool
  howToActuallyGoAboutCachingIt
     :: (IsCacheable a ~ 'True) => SomeCompl -> Icated -> Stora -> GeMethod
  • Related