John A De Goes bio photo

John A De Goes

Twitter LinkedIn Github

Type Classes: When To Use Them, When To Avoid Them

Type classes are a powerful tool for abstraction in functional programming. But they are poorly understood, and often abused.

In this post, I want to explain what a type class is—in a way that will be quite foreign to most readers—and offer my own personal recommendations on when you should use type classes to solve a problem, and when you should avoid them.

Note: This post assumes you already know how to create and use type classes.

Type Class 101

In the simplest possible case, a type class is a function from a type to a set of lawful operations on values of that type.

Let’s take this definition apart, one piece at a time:

  1. A type class is a function …. This is not a function in the programming language sense of the word, but rather, a function in the mathematical sense of the word. We give a type class some type, like Int, and we get back something (just one something). Moreover, when we give the type class the same type, we get back the same thing. How this function is encoded varies depending on the capabilities of the host language. For example, Scala and Haskell have quite different mechanisms for representing type classes.
  2. …from a type…. Technically, type classes can have multiple parameters, not just one; and technically, they may not be types, but rather, type constructors. But in the common case, type classes are functions that take one type. Some languages, like Java or Kotlin, don’t have higher-kinded type parameters.
  3. …to a set…. Like function above, this is not a programming language set, but rather, a mathematical set. Each operation in the set is unique (there are no duplicates). Like with “function” above, how the set is encoded varies depending on the programming language.
  4. …of lawful operations…. The operations provided by the type class are governed by laws. Laws are rather like unit tests, except they apply in all cases, not just some cases. A few programming languages (like Idris) can express laws in a way the compiler will enforce, but most programming languages rely on manual use of property-checking to verify laws are satisfied as part of a test suite.
  5. …on values of that type. The operations provided by the type class are all operations on values that have the type described in (2).

A type class instance, on the other hand, is a piecewise definition of the type class function for a given type. The type class function is not defined for all types, but only for some types, and those definitions are described by type class instances. A type class is not defined for a given type when there does not exist an instance of the type class for that type. On the other hand, if a type class is defined for some type, then the instance provides the definition of the operations for that type.

Type classes compliment parametric polymorphism. Parametric polymorphism allows us to create data structures and functions that are polymorphic in a type parameter.

Parametric polymorphism serves two useful functions:

  1. It allows us to create reusable data structures and functions, which can be used across not just a single (monomorphic) type, but across lots of types;
  2. It allows us to throw away information that is not relevant to a problem, constraining possible solutions, and improving guarantees of correctness.

In the following code, we write parametrically polymorphic code that accesses the 2nd element of a tuple:

def snd[A, B](tuple: (A, B)): B = tuple._2
snd :: (a, b) -> b
snd (_, b) = b

This code can work for all types A and B, and can therefore work for all tuples of arity 2. It is very resuable, and doesn’t know more about the types than it needs to.

While parametric polymorphism allows us to throw away structure, to make code more reusable and constraint implementations, sometimes it throws away too much structure to solve a given problem. In the above definition of snd, the function does not know anything about the types A and B, which is fine for this problem, but which is not fine for most problems.

Most polymorphic functions will require some structure from a type: they will need the ability to do something with values of that type. For example, they might need to combine them, compare them, iterate over them, or construct them.

To add structure to a type, to give us some operations, we use type classes. Type classes, being functions from types to a set of lawful operations on values of those types, give us the ability to operate with values of some unknown type. Although we may not know what the type is, thanks to type classes, we have enough structure to generically solve our problem.

The final piece of the puzzle is called a type class constraint. Type class constraints are the mechanism by which a language allows us to statically require that some polymorphic type have an instance for a given type class.

Example

In the following example, we define a type class called Ord, which provides a single lawful operation for some type A.

sealed trait Ordering
object Ordering {
  object LT extends Ordering
  object EQ extends Ordering
  object GT extends Ordering
}
trait Ord[A] {
  def compare(l: A, r: A): Ordering
}
object Ord[A] {
  def apply[A](implicit A: Ord[A]): Ord[A] = A
}
data Ordering = LT | EQ | GT
class Ord a where 
  compare :: a -> a -> Ordering

In Scala, if we wish to obtain the operation for some type A, let’s say Int, we utilize the expression Ord[Int], which gives us the instance for the type Int. If such an instance has not been defined, then we will get a compile-time error.

Similarly, in Scala, if we wish to add a constraint on some parametric parameter, we can use context bounds, which are how constraints are encoded in Scala:

def sort[A: Ord](list: List[A]): List[A])

In Haskell, if we wish to obtain the operation for some type A, let’s say String, we cannot call the type class function directly, because instances are not first-class values in Haskell. However, we can use the type class operation, and Haskell will automatically “call” the type class function to retrieve the instance for that type. If such an instance has not been defined, we will get a compile-time error.

In Haskell, if we wish to add a constraint, we use a type class constraint:

sort :: Ord a => [a] -> [a] -> [a]

In both of these examples, the use of parametric polymorphism allows us to throw away irrelevant details, making our sort function more generic, and constraining possible implementations, leading to stronger guarantees on correctness.

With this background out of the way, let’s talk about when to use type classes, and more importantly, when to not use them!

Type Class Best Practices

The purpose of type classes is to add structure to polymorphic types—just enough structure to allow us to solve our problem, but not more structure than necessary, so we can benefit from maximum code reuse, and maximally constrain our implementation.

The word structure is critically important. By structure, I mean algebraic structure: the operations that a type class provides us with satisfy some properties (the laws) across all types for which there is an instance.

Without a notion of structure, we cannot know the meaning of generic code. In the previous example of sort, our polymorphic function requires the ability too totally order the elements of the list it is sorting. Without a notion of total order, embedded into the laws of the Ord type class, we cannot meaningfully define what it is to “sort” elements.

Structure allows abstraction: we can write code that is generic across a range of different types precisely because we can define the ways in which those types are similar. In the case of sorting, we can abstract over the element type, because we can define what it means to compare elements of a given type. Although Int and String are quite different, they both posess enough structure to provide a total ordering.

Abstraction is not the same as indirection. Indirection provides a layer of insulation between our code and concrete data types, and is useful for testability and modularity.

Indirection is not necessarily governed by laws (algebraic or otherwise). In object-oriented programming languages, interfaces are often used to provide indirection, where it is sometimes mistakenly called abstraction. In functional programming languages, records of functions or modules can be used to provide indirection.

The structure that type classes provide is the foundation for writing well-defined generic code; without structure, there is no abstraction—only indirection.

This leads to my rule of when to use type classes:

Consider using type classes whenever you can identify enough common structure across a range of data types that is by itself wholly sufficient for writing well-defined generic code across these data types

Conversely, if you cannot identify common structure (through a set of well-defined algebraic laws), then you cannot write well-defined generic code across different data types. This is a strong sign that what you really want is indirection, not abstraction.

This leads to my rule of when not to use type classes:

Consider using indirection whenever you cannot identify enough common structure across a range of data types that is wholly sufficient for writing well-defined generic code

Now sometimes we can define structure across a range of data types, but that structure is not wholly sufficient for writing generic code.

An example is the algebraic structure called magma. A magma provides an composition operation with closure (that i8s, if you compose two values of the same type, you get back another value of the same type). This is less structure than a semigroup, because semigroups also provide a guarantee of associativity.

The guarantee of closure is so weak, that we cannot write much (if any) generic code that only requires this guarantee. In fact, closure is so weak, no laws are necessary to express: a parametrically polymorphic type system provides us this guarantee “for free”.

This is why it is so critically important that the structure provided by the type class be sufficiently strong that useful generic code is possible.

If you are looking at some structure that is sufficiently rare so as to permit very few useful instances for a given type, then there’s a good chance that a type class will allow you to write generically useful code. Conversely, if you are looking at some structure that permits infinitely many equally useful instances, then there’s a good chance having a type class will not allow you to write generically useful code.

I consider these best practices for when to use and when not to use type classes. However, in the next section, I want to provide some practical reasons for following this advice, as well as show you what to do when type classes aren’t a good fit.

The Power of Values

Type classes provide a means of accessing structure as a function of type.

As I have defined them, anyway, a type class has a single instance for any type. If for any reason a type class had multiple instances for a given type, then this would imply that type classes are not functions—at least, not functions in the mathematical sense of the word, because they would map the same type to different sets of operations.

Such non-determinism would mean that any given type would no longer map to a unique set of operations. It would further imply that we could not know, just looking at a concrete type, what set of operations a type class would give us access to.

Indeed, one of the chief problems of introducing non-determinism into my definition of a type class is that simple refactorings, like moving a function from one file to another, can change the runtime behavior of your program—a fact that Scala developers know all too well, thanks to their exposure to implicit abuse.

Ensuring that type classes are globally coherent gives us the ability to clearly and easily understand how our program will behave, and to preserve this behavior in the presence of refactoring, without having to be cautious.

Yet, while global coherence (determinism of the type class function) is important for reasoning and refactoring, there is something distinctly anti-modular about type classes. Type classes are, after all, global, piecewise defined functions from types to sets of operations.

Global definitions are opposed to local definitions, and local, compositional definitions are the foundation of modularity.

Herein lies the fundamental tradeoff of type classes: type classes provide global (anti-modular), type-directed reasoning that stems from the very algebraic structure of the types in question; while modules and their weaker variants (records of functions), provide local (modular), value-directed reasoning that stems from the makeup of the values themselves.

Although the battle between type classes and modules goes back for decades, there is no real competition, because while type classes are a natural fit for algebraic structure, modules (or records of functions) are a natural fit for indirection.

Indirection gives us loose coupling between pieces of the whole, allowing us to, for example, connect to different types of databases at runtime, accessing them through a uniform interface; allowing us to support local logging, but also remote logging; allowing us to have a local file system, and a remote one; and allowing us to have test implementations, as well as production implementations.

Type classes give us algebraic structure in the small, and indirection gives us modularity in the large. They do not compete with each other, but rather, compliment each other, and the weaknesses of the one are the strengths of the other.

Some beautiful properties of modules (and records of functions) include the following:

  • Superior Composition. We can take values, and compose them with other values, to yield values. While type class composition is possible, it looks and feels awkward because it has to be encoded in a different language than the primary, expression-oriented language that we write most of our code in.
  • No Dependence on Type. While type classes are functions of types to values, modules are just ordinary values, so for a given type (or set of types), we can have as many different modules as we like. For example, we can have a different database module for each underlying database type (MySQL, PostgreSQL, SQL Server, etc.), not connected to the type of effect we are using or any other type.
  • Superior Customization. We can easily and programmatically mix and match pieces of different modules, for example, drilling into a module and adding instrumentation to a single operation. Optics make these types of customizations trivial, but optics work on data, and type classes are not data.

The benefits of working with ordinary values are tremendous and should not be overlooked. Now it’s also true that type classes have their own benefits. The lure of these benefits is sometimes so strong, it leads to type class abuse.

Type Class Abuse

Type class abuse is what happens when we use a type class to represent something that should be a module (or record of functions).

Typical symptoms of type class abuse include the following:

  • The type class has no laws that permit well-defined generic use of the type class
  • There are many possible useful instances of the type class for a given type
  • The type class exists primarily to avoid passing or composing values