Typeclasses are a construct for declaring categorical behavior on types in the Haskell programming language, however, the concept is not restricted to just Haskell. The typeclass is an implementation of ad-hoc polymorphism, which unlike with interface or class inheritance, allows us to define polymorphic behavior on the fly.
Typeclasses provide an abstraction by defining interfaces and the values that implement them. In most object-oriented languages, interfaces are defined using direct inheritance from a child type to a parent type. Instead of operating on the class level, typeclasses define an interface, then instantiate the implementation in the form of a value. This allows flexibility with interface implementation, since the typeclass instances can be interchanged through function parameters and package imports. With typeclasses, library consumers have the ability to extend the functionality of types without modifying the source.
A typeclass consists of some generic interface and an implementation for a particular type. The type can be any
representation of a value (ex. int
, String
, Person
, Vehicle
...) and the value itself can be anything. The most
important part of a typeclass is that the implementation exists seperately from the implementation of the value itself
and can be used like a value.
Typeclass Implementations Across Languages
For the following implementation examples, we will be implementing the
Semigroup typeclass on int
, string
, and list
types.
Semigroup defines a single combine
function that takes two instances of type T
as inputs and outputs their combined
value.
For example, calling combine
with the integers 4
and 6
should return 10
if we are using addition as the
combine
implementation (we could also implement this for multiplication).
As an example of how typeclasses can be used within a library, I'll also be creating a function applyTo
that will
combine each value in a list with a given value. For the input List(1, 2, 3, 4, 5)
, applyTo
with a value of 8
would return List(9, 10, 11, 12, 13)
.
It is an implicit requirement that a Semigroup
's combine operation also be associative: combining a
group of values can occur in any order as in (1 + 2) + 3 = 1 + (2 + 3)
. However, this is not a possible restriction in
most programming languages, so it will not be factored into these examples.
Scala
Although there is not first-class support for typeclasses in Scala, there are language constructs to help create them.
We first create a trait representing our Semigroup
typeclass, then create a typeclass instance representing integer
addition, and finally create a function which implicitly takes a typeclass instance as a parameter. The implicit
keyword in Scala 2 lets us create a value in the implicit scope and summon it when the typeclass is requested.
trait Semigroup[T] {
def combine(a: T, b: T): T
}
implicit val intAdditionSemigroup: Semigroup[Int] = (a: Int, b: Int) => a + b
// intAdditionSemigroup: Semigroup[Int] = repl.MdocSession$MdocApp$$Lambda$8409/1609029207@183380af
def applyTo[A](values: List[A], value: A)(implicit semigroup: Semigroup[A]): List[A] =
values.map(v => semigroup.combine(v, value))
applyTo(List(1, 2, 3, 4, 5), 8)
// res0: List[Int] = List(9, 10, 11, 12, 13)
C#
Typeclasses in C# require some creativity because anonymous objects aren't a thing. To access typeclass instances, we
will provide an instance
static method which returns a singleton typeclass instance. Although this will make it
difficult to use the Semigroup<T>
typeclass in a generic context, it makes the code a little bit neater.
public interface Semigroup<T>
{
T Combine(T a, T b);
}
public class IntAdditionSemigroup : Semigroup<int>
{
public static IntSemigroup instance = new IntSemigroup();
public int Combine(int a, int b) = a + b;
}
public List<T> ApplyTo<T>(Semigroup<T> instance, List<T> values, T value)
{
return values.Select(v => instance.combine(v, value));
}
ApplyTo(IntAdditionSemigroup.instance, new List<int> { 1, 2, 3, 4, 5 }, 8);
Note that because C# has no equivalent of the implicit scope found in Scala, the semigroup instance must be provided
directly to the ApplyTo
function.
Typescript
Typescript's implementation is a little neater, but also requires passing the semigroup instance directly because an
implicit
scope doesn't exist. Typescript does support instantiating anonymous objects, which makes creating the
typeclass instances simple.
interface Semigroup<T> {
combine(a: T, b: T): T
}
const numberAdditionSemigroup = {
combine(a: number, b: number): number {
return a + b
}
} as Semigroup<number>
function combineAll<T>(instance: Semigroup<T>, values: T[], value: T): T[] {
return values.map(v => instance.combine(v, value))
}
combineAll(numberAdditionSemigroup, [1, 2, 3, 4, 5], 8)
Rust
Rust supports Ad-Hoc polymorphism out of the box since the implementation of interfaces for types must be defined
separately from the types themselves. The trait
implementations in Rust act just like typeclass definitions with an
accompying impl
block for instance definitions.
I've made the Semigroup
typeclass receive the values a
and b
by reference so that we don't have to take ownership
of the values, which makes it easier to work with.
trait Semigroup {
fn combine(a: &Self, b: &Self) -> Self;
}
impl Semigroup for i32 {
fn combine(a: &i32, b: &i32) -> i32 {
a + b
}
}
fn apply_to<T>(values: Vec<T>, value: T) -> Vec<T> where T: Semigroup {
values.iter().map(|v| { T::combine(&v, &value) } ).collect()
}
apply_to(vec![1, 2, 3, 4, 5], 8)
We do not have the option to provide multiple implementations of Semigroup
for i32
within the same scope, so any
additional implementations of combine
(multiplication) would have to be placed in a separate scope and imported.
Helpful Typeclasses
There are a number of common typeclasses that can be combined to implement similar behavior across all implementing
types. Semigroup
, Eq
, and Show
are simple typeclasses, but more complex ones like Monoid
, Monad
, and
Functor
can provide a lot of additional functionality.
I will be implmenting the following examples with Scala, but symmetric implementations can be made for C#, Typescript,
and Rust using the methods outlined in our Semigroup
example.
Eq
Eq
provides a typesafe equals method eqv
. Eq
should be used when we want to check that the value of two
values with the same type is the same. Calling eqv
with two values of different types should fail to compile.
trait Eq[T] {
def eqv(a: T, b: T): Boolean
}
Because we provide a function that determines if two values are equal, we also get a function determining if two values are not equal for free.
object Eq {
def neqv[T](a: T, b: T)(implicit eq: Eq[T]): Boolean = !eq.eqv(a, b)
}
For implementing neqv
I've created a companion class which takes an implicit Eq
instance. The neqv
method can
also be defined in the Eq
trait itself.
Then in our library, we can use our type-safe Eq
implementation instead of the ==
which can vary in accuracy
depending on type.
def combineIfNotEqual[T](a: T, b: T, otherwise: T)(implicit eq: Eq[T], semigroup: Semigroup[T]): T =
if (Eq.neqv(a, b)) semigroup.combine(a, b)
else otherwise
Show
Show
provides a method to get an explicit function for turning a value into a String
type. This is very helpful
when we want to print the state of a complex object to the console without having to override any existing toString
method directly on the type implementation. Also, when a function wants to print the value of a generic type to the
console, it can use its Show
implementation instead of relying on the built-in toString
method to be correct. Often,
the default toString
method will print out garbage, expecially for complex class instances in the JVM.
trait Show[T] {
def show(value: T): String
}
Then, when we want to do some debugging from a function we write, we can require an explicit Show
implementation
which the function caller provides.
implicit val showInt: Show[Int] = (value: Int) => s"Integer($value)" // ex. Integer(5)
// showInt: Show[Int] = repl.MdocSession$MdocApp$$Lambda$8411/1082088733@55e053c0 // ex. Integer(5)
def print[T](value: T)(implicit show: Show[T]): Unit = println(show.show(value))
print(500)
// Integer(500)
For a more complex object, this can save us a lot of headache.
class PersonWithRandomAge(val first: String, val last: String) {
private val random = scala.util.Random
val age = random.nextInt(100)
}
implicit val showPerson: Show[PersonWithRandomAge] = (person: PersonWithRandomAge) =>
s"Person(name: ${person.last}, ${person.first}, age: ${person.age})"
// showPerson: Show[PersonWithRandomAge] = repl.MdocSession$MdocApp$$Lambda$8412/2120903508@249f69dc
Even better, with some help from the Scala Cats library, we can use this implementation when we have a collection of
people without any extra work. We just have to be sure to implement the cats.Show
trait instead of our custom Show
trait.
import cats.implicits._
implicit val catsShowPerson: cats.Show[PersonWithRandomAge] = (person: PersonWithRandomAge) =>
s"Person(name: ${person.last}, ${person.first}, age: ${person.age})"
// catsShowPerson: Show[PersonWithRandomAge] = repl.MdocSession$MdocApp$$Lambda$8413/1421579510@73b650dc
val people: List[PersonWithRandomAge] = List(new PersonWithRandomAge("Kup", " Quickdew"), new PersonWithRandomAge("Hellweed", "Underhill"))
// people: List[PersonWithRandomAge] = List(
// repl.MdocSession$MdocApp$PersonWithRandomAge@6b55ffa6,
// repl.MdocSession$MdocApp$PersonWithRandomAge@8695850
// )
people.show
// res2: String = "List(Person(name: Quickdew, Kup, age: 10), Person(name: Underhill, Hellweed, age: 41))"
Monoid
The Monoid
typeclass is an extension of Semigroup
with an additional method empty
that returns a value
representing the default state of non-existence. For integers, this value would be 0
or for strings ""
.
It is often helpful to extend the functionality of one typeclass with that of another.
We can expand on our previous Semigroup
trait to implement Monoid
.
trait Semigroup[T] {
def combine(a: T, b: T): T
}
trait Monoid[T] extends Semigroup[T] {
def empty: T
}
Any implementation of Monoid
can be used where a Semigroup
is required.
This comes in handy when we want to fold over a collection of values.
implicit val intAdditionMonoid: Monoid[Int] = new Monoid[Int] {
override def combine(a: Int, b: Int): Int = a + b
override def empty: Int = 0
}
// intAdditionMonoid: Monoid[Int] = repl.MdocSession$MdocApp3$$anon$9@20789fc0
def combineAll[T](values: List[T])(implicit monoid: Monoid[T]) =
values.foldLeft(monoid.empty)(monoid.combine)
combineAll(List(1, 2, 3, 4, 5)) // 15
// res4: Int = 15
Functor
The Functor
typeclass defines a map
method from type A
to type B
so that a Functor[A]
can be mapped to a
Functor[B]
type. This is loosely related to the mathematical definition of a Functor
F
which defines a
mapping from a set A
to a set B
such that F[idA] -> F[idB]
and F[g * f] -> F[g] * F[f]
. In this definition,
the identity element idA
depends on the identity of the the category (0
for integer addtion and 1
for integer
multiplication) and g
and f
are functions applied to the element contained in F
. For example, if g(x) = x + 1
and f(x) = x + 5
, then F[g(f(3))]
must be equal to F[g(3)] + F[f(3)]
.
For the typeclass definition of Functor
, we are going to take advantage of Scala's ability to define generic type
arguments with a number of "holes". For example, Functor[F[_]]
defines a Functor
type for a generic argument named
F
that itself has a single type argument. In most languages, this restriction will not be possible, which can make
defining this Functor
type tricky.
In our example, I will use Option
as the Functor
argument. Option
in Scala contains a previously defined map
method, but I will instead show how the implementation works using just Some
and None
case classes.
I'm also adding an additional Functor
object with a summoner function which pulls the given implicit functor out
of the implicit
scope. This will let us call the Functor
map function directly.
trait Functor[F[_]] {
def map[A, B](functorA: F[A], mapping: A => B): F[B]
}
object Functor {
// summoner function
def apply[F[_]](implicit functor: Functor[F]): Functor[F] = functor
}
implicit val optionFunctor: Functor[Option] = new Functor[Option] {
override def map[A, B](functorA: Option[A], mapping: A => B): Option[B] =
functorA match {
case Some(a) => Some(mapping(a))
case None => None
}
}
// optionFunctor: Functor[[A >: Nothing <: Any] => Option[A]] = repl.MdocSession$MdocApp3$$anon$12@34e5b981
Functor[Option].map[Int, Int](Some(50), x => x * 10)
// res5: Option[Int] = Some(value = 500)
Functor[Option].map[Int, Int](None, x => x * 10)
// res6: Option[Int] = None
Monad
The Monad
typeclass is an extension on the Functor
typeclass which provides a flatten
function. The flatten
function squashes a value of type F[F[_]]
into F[_]
. Once defined, we can combine flatten
and map
to create
flatMap
, which works like map
except it takes in a function of type A => F[B]
instead of A => B
and returns
the type F[B]
.
trait Monad[F[_]] extends Functor[F] {
def flatten[A](nestedFunctorA: F[F[A]]): F[A]
def flatMap[A, B](functorA: F[A], mapping: A => F[B]): F[B] = flatten(map(functorA, mapping))
}
implicit val listMonad: Monad[List] = new Monad[List] {
override def map[A, B](functorA: List[A], mapping: A => B): List[B] = functorA match {
case head :: tail => mapping(head) :: map(tail, mapping)
case _ => List()
}
override def flatten[A](nestedFunctorA: List[List[A]]): List[A] = nestedFunctorA match {
case head :: tail => head ++ flatten(tail)
case _ => List()
}
}
// listMonad: Monad[List] = repl.MdocSession$MdocApp3$$anon$16@20c7f12b
object Monad {
// summoner function
def apply[F[_]](implicit monad: Monad[F]): Monad[F] = monad
}
Monad[List].map(List(100, 5, 22), (x: Int) => x * 5)
// res7: List[Int] = List(500, 25, 110)
Monad[List].flatten(List(List(1, 3, 4), List(9, 18, 0)))
// res8: List[Int] = List(1, 3, 4, 9, 18, 0)
Monad[List].flatMap(List(1, 2, 3, 4), (x: Int) => List(x % 2, x % 3))
// res9: List[Int] = List(1, 1, 0, 2, 1, 0, 0, 1)
Conclusion
Typeclasses are a flexible and functional approach to abstraction and generic programming. They are type-safe, modular, and simple to test and relieve many headaches software developers encounter with common behaviors on types.