Breaking apart the Haskell type class

2023-11-02

Type classes are perhaps the most distinctive feature of Haskell. I’ve found them pretty confusing, but in reality they are an incredibly elegant solution to a certain problem found in functional languages.

Why?

In Haskell, we can write monomorphic functions which accept concrete types. As an example, the function add shown below adds together two values of type Int.

add :: Int -> Int -> Int
add a b = a + b

We can also create functions which are polymorphic. The signatures of such functions contain type variables. As an example, the function head shown below has a type variable a, which means that it accepts lists of any type a.

head :: [a] -> a
head []    = undefined
head (x:_) = x

However, there are situations where we want to create functions that accept a specific set of different types. For example, a function should work with both Int and Float values:

a = 1 :: Int
b = 1 :: Float

x = add a a
y = add b b

add :: ? -> ? -> ?

Using a type variable like a in the function’s signature would make it too permissive, allowing unintended types like String.

add :: a -> a -> a
add a b = a + b

-- wrong!
x = add "hello" "world"

To address this issue, Haskell introduces a concept called “type classes”.

a = 1 :: Int
b = 1 :: Float

add :: Num a => a -> a -> a
add a b = a + b

In this case the types Int and Float are instances of the Num type class. By specifying the Num a => ... constraint in the function’s signature, we declare that the function should be polymorphic for instances of the Num type class.

How it works

You can visualize a type class as a set of operations defined for a given type. The Num type class, for example, includes operations such as addition, multiplication, and negation:

class Num a where
  (+) :: a -> a -> a
  (*) :: a -> a -> a
  negate :: a -> a
  -- etc.

add :: Num a => a -> a -> a
add a b = a + b

In this class declaration, each operation is like a record field for the type. It’s as if there’s a data type hidden behind the scenes, and each operation is a selector function for that data type:

data Num a = MkNum
  (a -> a -> a)
  (a -> a -> a)
  (a -> a)
  -- etc.

(+) :: Num a -> a -> a -> a
(+) (MkNum f _ _ ...) = f

add :: Num a -> a -> a -> a
add d a b = (+) d a b

Effectively, Num a serves as a table of operations for the Num class for type a. This elegant mechanism allows Haskell to implement polymorphism with type classes while maintaining strict type safety.

Kinds

A type in Haskell is a classification that defines what kind of data a value can represent. For example, Int is a type that represents integer values, and Float is a type for floating-point numbers.

Kinds are a higher-level classification system for types in Haskell. Every type in Haskell has a kind, and this kind represents the “type of types”.

The notation for kinds in Haskell uses an asterisk * to represent the most basic kind, which corresponds to concrete types. You can check the kind of any type by using the :kind command in GHCi.

The kind of Int is *, indicating that Int is a concrete type.

Int :: *

Similarly, the kind of Float is *, signifying that it is also a concrete type.

Float :: *

However, Haskell’s type system can go beyond basic types and deal with more complex constructs. For instance, type classes introduce a kind called Constraint, denoted as =>. The kind Constraint is used to represent constraints on types and is commonly encountered when defining type classes and their instances.

The kind of Num is * -> Constraint, showing that Num is a type class that takes a type (like Int) as an argument.

Num :: * -> Constraint

When Num is applied to Int, it becomes a constraint on the Int type, indicating that Int is an instance of the Num type class.

Num Int :: Constraint

Another example of a kind in Haskell is the kind of unary type constructors * -> *. These unary type constructors are similar to generic types in other languages because they accept a type parameter and produce a new type. The signature * -> * signifies that the type constructor transforms one concrete type into another concrete type.

An example of a trivial type constructor Item is shown below.

data Item a = MkItem a

Item        :: * -> *
Item Int    :: *
Item String :: *

In this definition, Item is a unary type constructor because it takes a type parameter a and produces a new type Item a, where a can be any concrete type.

A commonly used example of a unary type constructor in Haskell is the Maybe type. Maybe is used for representing optional values and handling potentially missing or undefined data in a type-safe manner. Its definition is as follows:

data Maybe a = Nothing | Just a

Maybe :: * -> *

Haskell type constructors can be parameterized with more than one type variable, as seen in the case of binary type constructors. An example of a binary type constructor is the Either data type:

data Either a b = Left a | Right b

Either            :: * -> * -> *
Either String     :: * -> *
Either String Int :: *

In this definition, Either is a binary type constructor because it requires two type parameters, a and b, to create a new type, Either a b. The Either type is often used for scenarios where a value can have one of two possible types, such as representing success or failure or providing an alternative to error handling. Conventionally, the Left value indicates error conditions, while the Right value signifies valid or correct values.

Higher Kinded Types

In Haskell, understanding the concept of “Higher Kinded Types” is crucial for grasping how certain more abstract type classes work. HKTs involve type classes that are parameterized by type constructors, rather than just simple types.

The Functor type class is an example of a HKT class. It defines two fundamental operations, fmap and <$, which operate on a type constructor f.

The Functor type class is defined as follows:

class Functor f where
  fmap :: (a -> b) -> f a -> f b
  (<$) :: a -> f b -> f a

Functor :: (* -> *) -> Constraint

The Functor type class is not constrained to specific types but is rather parameterized by type constructors. Its kind signature is (* -> *) -> Constraint, indicating that it works with type constructors. This allows it to be applied to types like Maybe, List, or other container types that accept a type argument.

Similarly to Functor, the Monad type class is another example of a HKT type class. It defines functions for monadic operations, such as >>=, >>, and return.

The definition of the Monad type class is as follows:

class Monad m where
  (>>=)  :: m a -> (  a -> m b) -> m b
  (>>)   :: m a ->  m b         -> m b
  return ::   a                 -> m a

Monad :: (* -> *) -> Constraint

Like the Functor type class, the Monad type class is not tied to specific types but is parameterized by type constructors. Its kind signature is also (* -> *) -> Constraint.

It is worth noting that type classes with a kind of (* -> *) -> Constraint can be applied to kinds like * -> * -> *. An example of this is the Functor instance of Either:

instance Functor (Either a) where
  fmap _ (Left x)   = Left x
  fmap f (Right x)  = Right (f x)

In the instance declaration above, we specify that, for any type a, Either a is an instance of the Functor type class. This allows you to use the fmap function with Either a, providing a way to map over the second type argument of the Either type while keeping the first type argument fixed.

Other languages

Scala

scala

Rust

rust

Bibliography

  1. P. Hudak, J. Hughes, S. Peyton Jones, and P. Wadler, “A History of Haskell: Being Lazy with Class,” in Proceedings of the Third ACM SIGPLAN Conference on History of Programming Languages, in HOPL III. San Diego, California: Association for Computing Machinery, 2007, pp. 12–11. doi: 10.1145/1238844.1238856.
  2. S. Peyton Jones, “Escape from the ivory tower: the Haskell journey.” [Online]. Available: https://youtu.be/re96UgMk6GQ
  3. R. Skinner, Effective Haskell: Solving Real-World Problems with Strongly Typed Functional Programming. in Pragmatic Bookshelf. O'Reilly Media, 2023.