Haskell World logo Haskell WorldWrite code, build worlds
Tutorials

Understanding Type Classes in Haskell With Real Examples

Type classes are one of Haskell's most powerful and distinctive features. They are often described as similar to interfaces in object-oriented languages, and there is something to that comparison, but it misses the aspects that make type...

Understanding type classes in Haskell with concrete real examples

Type classes are one of Haskell's most powerful and distinctive features. They are often described as similar to interfaces in object-oriented languages, and there is something to that comparison, but it misses the aspects that make type classes genuinely more expressive. Understanding type classes properly - what they are, how they work, and why they matter - unlocks a large portion of what makes Haskell code read and compose the way it does.

What a type class is

A type class defines a set of functions that a type must implement to be considered an instance of that class. A type that implements those functions is an instance of the class. Functions that require a type to be an instance of a class declare this in their type signature using a constraint.

The Eq type class defines equality. Any type that wants to support equality comparison must implement the == function. A function that needs to compare two values for equality uses the constraint Eq a =>` in its type signature: member :: Eq a => a -> [a] -> Bool. This says: for any type a that has an Eq instance, member takes a value of that type and a list of that type and returns a Bool.

This is how Haskell achieves polymorphism that is constrained in a principled way. The function works for any type, but only for types that support the operations the function requires. The constraint documents exactly what requirements the function places on its type parameter, and the compiler verifies that any type you use with the function satisfies those requirements.

Eq and Ord: the equality and ordering classes

Eq is the class for equality. Instances must implement == and optionally /= (not equal, which defaults to not . (==)). Most built-in types have Eq instances: integers, characters, strings, booleans, tuples of Eq types, and lists of Eq types. You can derive an Eq instance for your own types automatically: data Color = Red | Green | Blue deriving (Eq). The derived instance compares by constructor and fields.

Ord extends Eq with ordering. Instances implement compare, which returns LT, EQ, or GT. Ord instances unlock sorted containers, minimum and maximum functions, and sorting. Again, automatic deriving works for most simple types: the derived Ord instance compares constructors in the order they are listed in the type definition. Red < Green < Blue in the Color example, because Red is listed first.

The relationship between Eq and Ord in Haskell reflects the mathematical structure: ordering implies equality, so Ord requires Eq as a superclass. Any type that implements Ord automatically has an Eq instance. This superclass relationship is declared in the class definition: class Eq a => Ord a where .... Superclass relationships enforce that constraints are satisfied logically, not just technically.

Show and Read: conversion to and from strings

The Show type class defines the conversion of a value to a human-readable string representation. Implementing Show means implementing the show function: show :: a -> String. The automatic deriving produces a Show instance that reflects the type's constructor and field values in a format that is also valid Haskell syntax: show (Point 3.0 4.0) produces "Point 3.0 4.0".

The Read type class is the inverse: parsing a string representation back into a value. The derived Read instance recognizes the format produced by the derived Show instance. Using both together, you can serialize and deserialize simple data types without writing any explicit conversion code. This is not suitable for production serialization - use aeson for JSON, binary for binary formats - but it is useful for quick debugging and for simple configuration file parsing.

One practical use of Show that developers encounter frequently is in test output. When a test fails, the testing framework needs to display the actual and expected values. If those values have Show instances, the output is readable. Without Show, the framework can only report that something was not equal without showing what it was. Adding deriving (Show) to your custom types is a low-cost improvement to test debuggability.

Functor, Foldable, and Traversable

Functor is the class for types that can be mapped over. Implementing Functor means implementing fmap :: (a -> b) -> f a -> f b. This applies a function to every element inside a container without changing the container's structure. List's Functor instance applies the function to every list element. Maybe's Functor instance applies the function if the value is Just, and leaves Nothing unchanged. IO's Functor instance applies the function to the value that the IO action will produce.

A function that only needs to apply a transformation inside a container uses the Functor constraint: mapValues :: Functor f => (a -> b) -> f a -> f b. This works for lists, Maybes, IOs, and any other type with a Functor instance. The function is maximally general - it works for every container type that supports mapping - without knowing or caring about the specific container type.

Foldable generalizes the folding operation. List's fold processes elements left to right, accumulating a result. Foldable extends this to any container: length, sum, toList, and many other standard functions work on any Foldable type. Traversable extends Foldable further: it allows traversing a container while collecting effects. mapM applies an IO-returning function to each element of a list and collects the results in order. This generalizes to any Traversable type.

Num, Integral, and Floating: numeric type classes

Numeric operations in Haskell are defined through type classes, which is why integer literals are polymorphic - 42 has type Num a => a, not Int or Integer. The Num class defines the basic arithmetic operations: addition, subtraction, multiplication, negation, fromInteger. Any type that implements these is a numeric type that works with arithmetic operators.

Integral extends Num with integer division and modulo. Int and Integer both implement Integral. Floating extends Num with transcendental functions: sin, cos, log, sqrt. Double and Float implement Floating. The class hierarchy reflects mathematical structure: all Integral types are Num, all Floating types are Fractional, and Fractional types are Num.

The practical implication is that arithmetic code can be polymorphic over numeric types. A function that computes the average of a list of numbers can work with integers, doubles, or any other numeric type by using numeric type class constraints. This is one case where Haskell's class system enables genuinely useful polymorphism that would require duplicated code or casting in most other languages.

Writing your own type class

Defining a type class is straightforward. Suppose you want a class for types that can be serialized to a binary representation and deserialized from one:

class Serializable a where serialize :: a -> ByteString; deserialize :: ByteString -> Maybe a

A type implementing this class provides both functions. The Maybe in deserialize's return type handles the case where the input is not a valid serialization of the type. A function that needs to serialize and then deserialize a value uses both constraints: roundTrip :: Serializable a => a -> Maybe a; roundTrip = deserialize . serialize.

Type class instances can be defined for concrete types and for parameterized types. An instance for Int is concrete. An instance for [a] given a Serializable a instance is parameterized - if you know how to serialize the element type, you know how to serialize a list of them. This recursive instance structure is how Haskell builds complex serialization from simple parts without requiring hand-written code for each type.

Default method implementations

Type classes can provide default implementations for methods, which instances can choose to use or override. The Eq class's default implementation of /= is x /= y = not (x == y). An instance only needs to implement ==; the default handles /=. A type that needs a more efficient /= can override the default.

This mechanism reduces the boilerplate required to implement a class. The Ord class requires only compare to be implemented; all other methods (<=, >=, <, >, min, max) have default implementations derived from compare. An instance that needs more efficient comparison operators can override specific methods while inheriting the correct semantics for the rest.

The interplay between type classes, instances, and default methods is where much of Haskell's expressiveness lives. Code that is polymorphic over type class constraints composes freely with any type that satisfies those constraints. New types automatically gain access to all library functions that require only the classes they implement. This is how the Haskell standard library achieves the level of reuse that it does - the functions are general, the types are specific, and the type class system connects them.

FB
Finnian Burke

Finnian Burke spent eight years building distributed systems before leaving to write about the programming patterns that actually hold up under pressure. He has worked with Haskell, Rust, and Go in production, and writes mostly about the parts of functional programming that turn out to be useful regardless of the language you use day to day.

More posts by Finnian

More from the blog