Breaking apart the Haskell type class
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 a binary type constructor can often be partially
applied until it has the shape a type class expects. 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.
What type classes buy us
At this point, the core idea is not that strange anymore.
A type class is a named collection of operations. A constraint such as Num a
means that the compiler must be able to find an implementation of that
collection for the type a. Once it has found one, functions can use the
operations from the class without knowing the exact concrete type.
This gives us a nice middle ground between two extremes:
- ordinary polymorphism, where a function must work for every possible type
- concrete functions, where a function works for exactly one type
Type classes let us say: this function works for any type, as long as the type supports this small vocabulary of operations.
That is why they show up in so many parts of Haskell. Eq describes equality,
Ord describes ordering, Show describes conversion to text, Num describes
numeric operations, and Functor describes things that can be mapped over.
These are not inheritance hierarchies in the object-oriented sense. They are
more like named capabilities that the compiler can resolve statically.
Instances and laws
There is one more important cultural detail. A type class declaration can say which operations exist, but it usually cannot express all the rules those operations should obey.
For example, Eq has this rough shape:
class Eq a where
(==) :: a -> a -> Bool
(/=) :: a -> a -> Bool
The type checker can ensure that (==) returns a Bool, but it cannot prove
that equality is reflexive, symmetric, or transitive. It will happily accept a
bad instance:
instance Eq Bool where
True == True = False
False == False = False
_ == _ = True
This compiles, but it breaks the meaning people expect from Eq.
The same thing happens with Functor. The type of fmap says how values move
through a structure, but the usual functor laws are still a promise made by the
instance author:
fmap id == id
fmap (f . g) == fmap f . fmap g
This is part of the trade-off. Type classes are very good at making operations available in a principled and type-safe way, but the deeper algebraic meaning of those operations often lives in documentation, tests, and convention.
Other languages
Scala
Scala has a very direct encoding of the same idea. Instead of writing a Haskell type class, we can write a trait parameterized by a type:
trait Show[A] {
def show(value: A): String
}
An implementation of the type class is just a value of that trait:
given Show[Int] with {
def show(value: Int): String =
value.toString
}
Then a function can ask for the implementation with a using parameter:
def printValue[A](value: A)(using show: Show[A]): Unit =
println(show.show(value))
This is very close to the dictionary-passing explanation from earlier. The
compiler finds a Show[A] value and passes it to the function. The main
difference is mostly syntactic and cultural: in Scala, the dictionary is a
normal value in the language, while in Haskell the type class machinery is more
specialized and deeply integrated into the compiler.
Rust
Rust traits also cover part of the same territory:
trait Show {
fn show(&self) -> String;
}
impl Show for i32 {
fn show(&self) -> String {
self.to_string()
}
}
fn print_value<T: Show>(value: T) {
println!("{}", value.show());
}
This gives Rust ad-hoc polymorphism: print_value works for any T with a
Show implementation.
Rust traits are not exactly Haskell type classes, though. Rust usually attaches
the type being implemented as the Self type of the trait, while Haskell type
classes are more naturally written as predicates over types:
printValue :: Show a => a -> IO ()
Rust also has a strong coherence model: for a given trait and type, there should be one clear implementation. This avoids many ambiguity problems, but it also makes some patterns harder to express. Haskell has its own coherence story too, but it has historically been more willing to grow extensions for type-level programming, such as multi-parameter type classes, functional dependencies, type families, and higher-kinded abstractions.
The practical takeaway is that Rust traits feel like a cousin of Haskell type classes, not a direct copy. They solve a similar problem, but under different constraints: Rust is also thinking about ownership, method syntax, monomorphization, dynamic dispatch, and separate compilation.
Final intuition
The simplest way to understand type classes is this:
A type class is a promise that some operations exist for a type, and a constraint is a request for that promise.
Once you see that, the rest becomes less mysterious. Num a => ... means “give
me numeric operations for a”. Functor f => ... means “give me mapping for
the type constructor f”. Monad m => ... means “give me sequencing for m”.
The machinery can become very sophisticated, especially once kinds, higher-kinded types, and type-level programming enter the picture. But the core idea stays surprisingly small: define a vocabulary of operations, implement it for particular types, and let the compiler pass the right implementation to polymorphic code.