There are plenty of buzzwords when it comes to type systems. Today we take a closer look at the concepts related to kinds.
5 min read
·
By Jørgen Granseth
·
December 18, 2020
Type systems are cool. Or - some are cool, others can be a bit of a hassle, but they all strive to let your compiler check that your intentions (type annotations) match your implementation (actual code). So types are there to allow you to catch bugs even before your program runs. Pretty neat! The more advanced the type system, the more logic can be declared in types and the more bugs you can catch with the help of your trusty compiler.
Now, type systems were not created equal, and there are limitations to which guarantees your types can provide. Haskell is an example of a language with a very sophisticated type system, more or less derived from the mathematical field of category theory, closely related to formal logic. Unsurprisingly this allows for a bunch of fancy type magic that lets the compiler check a lot of complex logic on your programs if you just nudge it in the right direction.
At the base level of our programs we have values, and these values have types. Int
, String
and Maybe [Bool]
are all concrete types, which means that values in our programs can be these things. Everything else is abstractions over concrete types.
The definition of Maybe
is essentially this:
data Maybe a = Just a | Nothing
The a
here is a parametrically polymorphic type parameter. In a language like Java, a similar effect is achieved with generic types. In a language like English, this means that Maybe
needs another type to become a concrete type. You can't have a value that's Just (sic.) Maybe
: It is a type constructor, which is akin to a function in the domain of types: it takes a type and gives you another type.
The kind of a type is an abstraction over its "shape" in terms of types. Concrete types are the simplest types, and they have kind *
, pronounced "type".
Maybe
has kind * -> *
because it takes a concrete type as an argument and gives us another concrete type.
data Either a b = Left a | Right b
It follows that a type like Either
has kind * -> * -> *
. You get the idea.
An Int
or a String
is just a value. In a pure functional language we can think of values as functions that take no arguments and return a value: Since there are no side effects and no arguments, the outcome is predetermined. We can conceptualize the order of a function as its level of abstraction from values. In this interpretation, values are functions of order 0.
A step up from values we have what are normally considered functions; a construct that operates on inputs and outputs that are values, so their level of abstraction is higher - they are of order 1. In general, a function of order n operates on inputs and outputs of order less than n.
A higher-order function is a function with order greater than 1 (who'd-a thunk it!) - in other words a function that operates on other functions. In an analogous way, a higher-kinded type is a type that operates on other types so that their level of abstraction from concrete types is greater than 1.
data StringContainerContainer c = AptlyNamedConstructor { value :: c String }
Exactly what we were looking for - a contrived example! Let's do a quick analysis of its kind: The StringContainerContainer
takes a polymorphic type argument, so c
has to be a "type" to something; * -> ?!
. But that something cannot be a concrete type since it has to take String
as a type parameter. String
has kind *
, so c
must have kind * -> *
, and StringContainerContainer
therefore has kind (* -> *) -> *
. We have found a type that operates on a more complex type than concrete types, so it is a higher-kinded type!
Note how this is fundamentally different from the kind of Either
. Either
is not a higher-kinded type since it only operates on concrete types: Haskell supports partial application in the type domain and the kind signatures are curried in the same way as their type counterparts. It follows that a partially applied Either String
must have kind * -> *
, so * -> * -> *
is equivalent to * -> (* -> *)
.
Functors exist in many languages. The essence of a functor is some context that you can map over, i.e. lift a function into the context and apply it in a sensible manner. A list is a straightforward example of a functor. In Haskell, functors are abstracted as a type class:
class Functor f where
fmap :: (a -> b) -> f a -> f b
In the signature of fmap
we see that the polymorphic type parameter f
is simultaneously polymorphic in another type. The payoff is that we can supply a rich ecosystem of functionality based on these class operators that allow code reuse for any functor (or other type class) while keeping the guarantees of static typing.
Most languages do not support higher-kinded polymorphism. However, it turns out that it is possible to achieve a lightweight higher-kindedness in languages whose type systems only support first-order kinds through a process called type defunctionalisation (Yallop & White, Lightweight higher-kinded polymorphism, 2014). If you have half an hour to spare, I recommend this talk on how the concept is realised in Kotlin's functional abstraction library, Arrow.