Avoid overlapping instances with closed type families

Posted on February 5, 2017 by Kwang Yul Seo

Overlapping instances are one of the most controversial features in Haskell. Fortunately, there are many tricks that let us avoid overlapping instances. In this post, I will introduce one such trick which uses closed type families.

Why overlapping instances are bad

In Haskell, we expect adding an extra instance in one module does not cause any other modules that depend on the given module to fail to compile or have different behaviors as long as the dependent modules use explicit import lists.

Unfortunately, OverlappingInstances breaks this expectation.

{-# LANGUAGE FlexibleInstances #-}
{-# LANGUAGE MultiParamTypeClasses #-}
{-# LANGUAGE FunctionalDependencies #-}

module A where

class C a b c | a b -> c where
  f :: a -> b -> c

instance C String a String where
  f s _ = s
module B where

import A(C(..))

func :: String -> Int -> String
func = f

func "foo" 3 evaluates to "foo".

Let’s add a new instance declaration in A.

instance {-# OVERLAPPING #-} C String Int String where
  f s i = concat $ replicate i s

Module B still compiles, but func "foo" 3 now evaluates to "foofoofoo" because C String Int String is more specific than C String a String.

Wen can see that adding an extra instance silently broke the backward compatibility. To make the matters worse, there is no way to go back to the old behavior. GHC automatically chooses a more specific instance. In this case, C String Int String is chosen because it is more specific than C String a String.

Use cases of overlapping instances

Overlapping instances are controversial because they are too useful to remove. Overlapping instances are appealing because they express the common pattern of adding a special case to an existing set of overloaded functions.

Let’s check how show method from Prelude handles a list.

λ> show [1,2,3]
λ> show [False, True, False]

It converts a given list to a string by putting a comma between elements. According to the rule, it must show "foo" as ['f', 'o', 'o']. But show handles a string (a list of characters) in a different manner.

λ> show "abc"

This requires overlapping instances because [a] overlaps with [Char].

instance Show a => Show [a] where

instance {-# OVERLAPPING #-} Show [Char] where

Haskell 98 solution

Haskell Prelude avoided overlapping instances by using the extra-method trick. The trick does not require any GHC extensions, but class definitions become more complicated. Interested readers are referred to Brandon Simmons’s How the Haskell Prelude Avoids Overlapping Instances in Show for the details.

Another solution with closed type families

This solution is a variation of the solution introduced in Overcoming Overlapping section of Oleg Kiselyov’s Type equality predicates: from OverlappingInstances to overcoming them.

Here’s the list of GHC extensions and imports we need.

{-# LANGUAGE DataKinds #-}
{-# LANGUAGE FlexibleInstances #-}
{-# LANGUAGE MultiParamTypeClasses #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE TypeFamilies #-}
{-# LANGUAGE UndecidableInstances #-}

import Data.Proxy

F is a type-level function which returns 'True for Char and 'False for any other types. This does not require overlapping instances because the set of special cases are closed.

type family (F a) :: Bool where
  F Char  = 'True
  F a     = 'False

ShowList class defines showl method.

class ShowList a where
  showl :: [a] -> String

The type checker computes the type of flag by evaluating F a and dispatches the method based on the type of flag. If it is 'True, it searches the special case instances. Otherwise, it searches the generic case instance.

instance (F a ~ flag, ShowList' flag a) => ShowList a where
  showl = showl' (Proxy :: Proxy flag)

class ShowList' (flag :: Bool) a where
  showl' :: Proxy flag -> [a] -> String

instance ShowList' 'True Char where
  showl' _ x = x

instance (Show a) => ShowList' 'False a where
  showl' _ x = show x

We can add another special case for Bool as follows:

type family (F a) :: Bool where
  F Char  = 'True
  F Bool  = 'True
  F a     = 'False

instance ShowList' 'True Bool where
  showl' _ x = map toBinaryDigit x
    where toBinaryDigit False = '0'
          toBinaryDigit True  = '1'

Now showList [True,False,True] evaluates to 101 instead of [True,False,True].

Other solutions

Oleg Grenrus’s gist contains other workarounds for OverlappingInstances.