Updated

Typeclasses

Typeclasses are a type system construct that enables ad-hoc polymorphism. Essentially, a typeclass is a nominal classification of types that all support some specified behaviour, by having each type providing its type-specific implementation for those behaviours. Alternatively, a typeclass can be seen as a constraint for a type to support specified behaviours.

Just like classes in OOP are blueprints for creating instances of the class (objects), a typeclass is a blueprint for creating typeclass instances. This time, a typeclass provides the interface/specification/contract for members of the typeclass to adhere to, and typeclass instances provide the actual type-specific implementations of functions specified in the typeclass. In essence, a typeclass is a constraint over types, and a typeclass instance is a witness that for types meeting those constraints.

To build on intuition, pretend that there is a super cool magic club, and members of this club must have a magic assistant and a magic trick. This club acts as a typeclass. Then suppose cats and dogs want to join this club. To do so, they must provide proof to the club administrators (in Haskell, the compiler) that they have a magic assistant and a magic trick. Suppose that the cats come together with their mouse friends as their magic assistants, and their magic trick is to cough up a furball, and the dogs all present their chew toys as their magic assistants, and their magic trick is to give their paw. The club administrator then puts all these into boxes as certificates of their membership into the club—in our analogy, these certificates are typeclass instances.

Let us return to the shape and house example we have seen at the start of this chapter. We first define some types (slightly different from before) that all have an area:

data Shape = Circle Double
           | Rectangle Double Double
           | Triangle Double Double
data House = H [Room]
data room = R { roomName :: String
              , shape    :: Shape }

Now, our goal is to describe the phenomenon that some types have an area. For this, we shall describe a contract for such types to follow. The contract is straightforward—all such types must have an area function (known as a method).

class HasArea a where
  area :: a -> Double

An important question one might ask is: why is HasArea polymorphic? To give an analogy, recall in our Python implementation with dictionaries that HasArea is a dictionary where we are looking up type-specific implementations of area by type. Essentially, it is a finite map or (partial) function from types to functions. This essentially makes HasArea polymorphic, because it acts as a function that produces different implementations depending on the type!

Then, the area function should also receive a parameter of type a—that is, if a is a member of the HasArea typeclass, then there is a function area :: a -> Double. The example typeclass instances make this clear:

instance HasArea Shape where
  area :: Shape -> Double
  area (Circle r) = pi * r ^ 2
  area (Rectangle w h) = w * h
  area (Triangle w h) = w * h / 2

instance HasArea Room where
  area :: Room -> Double
  area x = area $ shape x

instance HasArea House where
  area :: House -> Double
  area (H rooms) = sum $ map area rooms

Each instance of HasArea provides a type-specific implementation of area. For example, the HasArea Shape instance acts as a witness that Shape belongs to the HasArea typeclass. It does so by providing an implementation of area :: Shape -> Double (in the obvious way). We do the same for rooms and houses, and now the area function works for all (and only) these three types!

x :: Shape = Triangle 2 3
y :: Room = R "bedroom" (Rectangle 3 4)
z :: House = H [y]

ax = area x -- 3
ay = area y -- 12
az = area z -- 12

Now let us investigate the type of area:

ghci> :t area
area :: forall a. HasArea a => a -> double

The type of area is read as "a function for all a where a is constrained by HasArea, and receives an a, and returns a Double".

Constrains on type variables are not limited to class methods. In fact, we can, and probably should, make functions that use area polymorphically over type variables, constrained by HasArea. Let us consider a function that sums the area over a list of shapes, and another one over a list of rooms:

totalArea :: [Shape] -> Double
totalArea [] = 0
totalArea (x : xs) = area x + totalArea xs

-- alternatively
totalArea' :: [Shape] -> Double
totalArea' = sum . map area

totalArea'' :: [Room] -> Double
totalArea'' = sum . map area

Both totalArea' and totalArea'' have precisely the same implementation, except that they operate over Shape and Room respectively. We can substitute these types for any type variable a, so long as there is an instance of HasArea a! Therefore, the most general type we should ascribe for this function would be

totalArea :: HasArea a => [a] -> Double
totalArea = sum . map area

Now our totalArea function works on any list that contains a type that has an instance of HasArea!

xs :: [Shape] = [Rectangle 1 2, Triangle 3 4]
ys :: [House] = [H [R "bedroom" (Rectangle 1 2)]]
axs = totalArea xs -- 8
ayx = totalArea ys -- 2

How Typeclasses Work

By now, you should be able to observe that typeclasses allow (1) otherwise disparate types adhering to a common interface, i.e. ad-hoc polymorphism and (2) decoupling types and behaviour, all in a type-safe way—this is very difficult (if not impossible) to achieve in other languages like Python. The question then becomes: how does Haskell do it?

The core idea behind typeclasses and typeclass instances is that typeclasses are implemented as regular algebraic data types, and typeclass instances are implemented as regular terms of typeclasses. Using our area example, we can define the typeclass as

data HasArea a = HA { area :: a -> Double }

Then, typeclass instances are merely helper-terms of the HasArea type:

hasAreaShape :: HasArea Shape
hasAreaShape = HA $ \x -> case x of
  Circle    r   -> pi * r ^ 2
  Rectangle w h -> w * h
  Triangle  w h -> w * h / 2

Notice that area now has the type HasArea a -> a -> Double. Clearly, area hasAreaShape is now the Shape-specific implementation for obtaining the area of a shape! We can take this further by defining the helper-terms for other types that wish to implement the HasArea typeclass:

hasAreaRoom :: HasArea Room
hasAreaRoom = HA $ \x -> area hasAreaShape (shape x)

hasAreaHouse :: HasArea House
hasAreaHouse = HA $ \x -> case x of
  H rooms -> sum $ map (area hasAreaRoom) rooms

Finally, we can use the area function, together with the type-specific helpers, to compute the area of shapes, rooms and houses!

x :: Shape = Triangle 2 3
y :: Room = R "bedroom" (Rectangle 3 4)
z :: House = H [y]

ax = area hasAreaShape x -- 3
ay = area hasAreaRoom y -- 12
az = area hasAreamHouse z -- 12

This is (more-or-less) how Haskell implements typeclasses and typeclass instances. The only difference is that the Haskell compiler will automatically infer the helper term when a typeclass method is used, allowing us to omit them. This term inference that Haskell supports allow us to define and use ad-hoc polymorphic functions in a type-safe way.