Kinds
Just like how expressions have types, types have kinds.
expression :: type -- type signature
type :: kind -- kind signature
Bool :: *
Maybe :: * -> *
For example, the type Bool
has kind *
(star) because Bool
has no parameters. On the other hand, Maybe
is of kind * -> *
(star to star), as we know that Maybe
is a type constructor and has 1 parameter.
* -> *
basically says that if you give this kind a type as an argument, then it gives back a type.
[] :: * -> *
is another example â given a type a
to the list type constructor and it will give [a]
(a list of a
).
Kinds = Better Code
Kinds help us code instances better, as long as we understand it.
Monad :: (* -> *) -> Constraint
instance Monad Either where ...
-- Error: Expected kind * -> * but Either has kind * -> * -> *
In this example, it is easy to see why we get an error, because Monad
is a type class that takes a type of kind * -> *
(basically a type constructor) and gives a Constraint
. So giving it a type of kind * -> * -> *
violates this constraint.
You can think of type classes as a constraint constructor, although this isnât a standard term so donât use it!
Finding the kind
In the Haskell repl, we have seen we can use :t <VALUE>
to find the type of a given value or variable. Similarly, we can find the kind of a type using :k <TYPE>
, and additionally reduce the type to a normal form with :kind! <TYPE>
.
Type promotion
Using Haskell language extensions, we are able to create our own kinds of types. This feature is not in Haskell by default, so we need to add a language extension to use it. We can use the Haskell extension XDataKinds
in three ways:
-
Writing the following at the top of our source file, as language pragma:
{-# LANGUAGE XDataKinds #-} module Program where
-
Modifying the
.cabal
file for the entire targetlibrary ... default-extensions: DataKinds
-
With flags on the command line (requires specification every time)
$ ghc Program.hs -XDataKinds
Usually we use Language Pragmas.
DataKinds is the language extension that allows us to create our own kinds.
With DataKinds
enabled, each data type we define not only introduces a type with some constructor but also introduces
- a new kind which has the same name as the data type
- additional data types â have the same name as the data constructors but preceded with a
'
.- These additional data types are of the new kind that is introduced.
Example. Here we are getting a type-level boolean of kind Bool
.
data Bool = True | False
True :: Bool -- type signature
False :: Bool
-- With DataKinds enabled, the 'True and 'False data types are introduced
'True :: Bool -- kind signature (do not confuse with typings)
'False :: Bool
To visualise this you can look at the table below. The right-most column is what the DataKinds
language extension adds in addition to what happens normally when you define a type with data
.
 | Normally | DataKinds Additions |
---|---|---|
Kind-Level | * |
Bool |
Type-Level | Bool |
'True and 'False |
Value-level | True and False |
 |
GADTs
Generalised Algebraic Data Types (GADTs) allow us to define more expressive data type definitions. The language extension is
GADTs
.
-- ADT syntax
data Vector a = Nil | Cons a (Vector a)
{-
Behind the scenes we get
Nil :: Vector a
Cons :: a -> Vector a -> Vector a
-}
-- with GADT syntax
data Vector a where
Nil :: Vector a
Cons :: a -> Vector a -> Vector a
Currently, the head
function on lists
will break if given an empty list. So we can fix that by returning a Maybe
or Either
type and then deal with the result in another function.
But GADTs
together with another extension, XKindSignatures
, will allow us to define a head
function for Vector
that will reject an empty list at compile-time â removing the need for us to handle the results of Maybe
or Either
.
data Vector (n :: Nat) a where
Nil :: Vector 'Zero
Cons :: a -> Vector n a -> Vector ('Succ n) a
vhead :: Vector ('Succ n) a -> a
vhead (Cons x xs) = x
data Nat where -- Behind the scenes we get
Zero :: Nat -- 'Zero :: Nat
Succ :: Nat -> Nat -- 'Succ :: Nat -> Nat
In the new definition of Vector
we have placed a specification on the 1st parameter n
. (n :: Nat)
essentially says that the type n
has to be of kind Nat
. And in the new definition of Nil
and Cons
, we are able to specify that Nil
will only accept an element of type Vector 'Zero
and Cons
will always give a Vector
1 element larger.
In this example, GADTs allow us to specify more constrained return types for data constructors.
Singleton Types
Types where there is a 1:1 correspondence between types and values. Essentially a particular singleton type will only ever have one value that is associated with it.
From above we have seen that with DataKinds
and GADTs
we are able to encode information about the value of a certain element through its type. In specific cases, we may want to make use of this to define singleton types.
data SNat (n :: Nat) where
SZero :: SNat 'Zero
SSucc :: SNat n -> SNat ('Succ n)
In this example, we are defining a singleton type for natural numbers. If we try to define sone
or stwo
, their types will be unique.
sone :: SNat ('Succ 'Zero)
sone = SSucc SZero
stwo :: SNat ('Succ ('Succ 'Zero))
stwo :: SSucc sone
Reification
Reification is the process of converting types to values, typically using type classes.
There are a few ways to implement the instances for the type classes and one way (which is used in the lectures) is to use proxy types.
Proxy Types
We donât have types at runtime due to type erasure, so we canât write a function that takes types as arguments in Haskell, even though we sometimes want to.
Functions only accept arguments whose types are of kind
*
. Proxy types are used to establish a link between types of other kinds and types of kind*
, thus âcarryingâ types of other kinds into the scope of a functionâs type.
For example, we might want to define a function
fromNat :: forall (n :: Nat) => n -> Int
which takes a type-level natural number n
of kind Nat
as input and returns the corresponding Int
value. However, only types of kind *
have values at run-time in Haskell. Since there are no values of types of kind Nat
, we cannot define a fromNat
function of the given type.
Proxy types provide a partial work around Haskellâs limitations for cases where knowing what type is given to a function as its âargumentâ at compile-time is sufficient.
TLDR. A proxy type is a type of kind
k -> *
(wherek
can be specialised to the kind of whatever types you want to give to a function as argument). Only types of kind*
have values, so we apply our proxy type constructor to some argument of kindk
to get a type of kind*
which then makes a suitable parameter for a function.
In the following example, we will show how this allows us to convert a type into a corresponding value. In other words, we show how to take information from the type level and convert it to information at the value level (reification).
data NatProxy (n :: Nat) = MkProxy
Here, the NatProxy
type constructor is of kind Nat -> *
, meaning it takes some type n
of kind Nat
and gives back a type of kind *
which has a value at run-time. MkProxy
is the data constructor for NatProxy
and is of type NatProxy n
.
Now with our proxy type, we can define a fromNat
function with a slightly modified type. Since NatProxy :: Nat -> *
, NatProxy n
is a valid parameter type for a function since it is of kind *
. We use a type class to overload this fromNat
function so that we can provide different implementations for it, depending on which type n
is. For example, if n
is 'Zero
we just define fromNat
to return 0
as shown:
class FromNat (n :: Nat) where
fromNat :: NatProxy n -> Int
instance FromNat 'Zero where -- 'Zero is a type defined by Nat in GADT section
-- fromNat :: NatProxy 'Zero -> Int
fromNat _ = 0
Next, the instance for any other n
requires us to constrain the n
in the instance head to be an instance of FromNat
so that we can define the instance for 'Succ n
recursively. The recursive call to fromNat
is given MkProxy
as argument, with an explicit type annotation instructing the compiler to choose the fromNat
implementation for n
. (Read the comments in the code snippet.)
{-# LANGUAGE ScopedTypeVariables #-}
instance FromNat n => FromNat ('Succ n) where
-- fromNat :: FromNat n => NatProxy ('Succ n) -> Int
fromNat _ = 1 + fromNat (MkProxy :: NatProxy n)
-- (MkProxy :: NatProxy n) essentially means the n in "NatProxy n"
-- is the same n as that in the instance head.
-- ScopedTypeVariables extension has to be enabled for this to work.
vlength :: forall n a . FromNat n => Vector n a -> Int
vlength _ = fromNat (MkProxy :: NatProxy n)
-- Here "forall n a" is required for ScopedTypeVariables to work correctly
Although we use proxy types to implement the reification process here, they are independent techniques. That is, proxy types have uses outside of reification and reification can be accomplished without proxy types, which is not covered in the module.
Additional Example (Type Application)
FYI. This section is additional material and is not tested in the exams. I have included an example that Michael showed me, to illustrate how we may reify types without using proxy types.
Quoting Michael,
âI just typed straight into Slack, so it may not compile as is, but conceptually this [is how we would do it]â
This example makes use of a few language extensions which are shown.
{-# LANGUAGE TypeApplication, AllowAmbiguousTypes, ScopedTypeVariables #-}
class FromNat (n :: Nat) where
fromNat :: Int
instance FromNat Zero where
fromNat = 0
instance FromNat n => FromNat (Succ n) where
fromNat = 1 + fromNat @n
type Test = Succ (Succ Zero)
test :: Int
test = fromNat @Test
Here, @
is type application which is used to explicitly supply an argument for a universal quantifier. Universal quantifiers are usually implicit in Haskell, but in some cases it is useful to make them explicit when writing down typings. In the case of fromNat
, its type is as follows with the universal quantifiers made explicit:
fromNat :: forall (n :: Nat) . FromNat n => Int
When we write fromNat @Test
(last line), the n
gets instantiated with Test
and we get FromNat Test => Int
as the type. Because Test
expands to something that has an instance of FromNat
, the constraint is satisfied and the compiler can pick the right implementation of fromNat
to use, which is how the reification âworksâ.
Type application can be used with other variables, for example
something
. As long as these have an instance ofFromNat
defined for them. So if we writefromNat @something
thenn
will be instantiated withsomething
.
Concluding Reification
Here, we have provided two examples of how we can reify types. You have seen how useful type classes are as they allow us overload the fromNat
function for different types of n
. This resolves to the correct instances at compile-time so the right implementation of the function is evaluated at runtime.
Type Families
Language extension: TypeFamilies
Type families are type-level functions and allow us to perform computation at the type-level. There are two main types of type families, closed type families and associated (open) type families.
Associated (open) type families are âattachedâ to type classes and is open to further extension when defining instances.
Closed type families are very much like ânormalâ functions in the sense that you âknowâ what kind of types the type family will deal with and the effect to be achieved, and you just define it as such.
Closed Type Families
I think the best way to explain this is through an example. As you can see, the syntax for type families is different from anything weâve seen before. The example below should be pretty self explanatory and you can see that the head of definition sort of resembles the type signature of a normal function, and the definition of the actual Add
function is just pattern matching on the type of Nat
.
{-# LANGUAGE TypeFamilies #-}
type family Add (n :: Nat) (m :: Nat) :: Nat where
-- Add :: Nat -> Nat -> Nat
Add 'Zero m = m
Add ('Succ n) m = 'Succ (Add n m)
If we enable the TypeOperators
language extension, we can use operators in our type family definition, which in turn allow us to use it very nicely in the vappend
example below.
{-# LANGUAGE TypeOperators #-}
type family (+) (n :: Nat) (m :: Nat) :: Nat where
-- or
type family (n :: Nat) + (m :: Nat) :: Nat where
vappend :: Vector n a -> Vector m a -> Vector (n + m) a
vappend Nil ys = ys
vappend (Cons x xs) ys = Cons x (vappend xs ys)
How to test in REPL
We can get REPL to evaluate types for us by using the :kind!
syntax. This evaluates the type to its normal form.
Lecture> :kind! (+) 'Zero 'Zero
(+) 'Zero 'Zero :: Nat
= 'Zero
Associated (Open) Type Families
Motivation for Associated Type Families. Sometimes, when we define a certain type class, we want it to be as general as possible but because of certain constraint requirements for different instances of the type class, it is difficult without type families.
Example. Letâs say we want to define a type class Collection
for various data structures like lists or trees etc.
class Collection c where
-- c :: * -> *
empty :: c a
insert :: a -> c a -> c a
member :: a -> c a -> Bool -- Checks if `a` is part of a collection
Now when we try to define an instance of Collection
for lists, we could try to implement it this way. However, we get a type error due to our usage of elem
, which requires elements in the list to have the Eq
constraint.
instance Collection [] where
empty = []
insert x xs = x : xs
member x xs = x `elem` xs
-- however we get a type error:
-- No instance for Eq a arising from use of elem
We could add an Eq a
constraint onto the type class definition for member. However, this makes the Collection
type class too restrictive for other data structures. Furthermore, we canât predict what kind of data structures that other people will create, and Collection
should be as general as possible.
Associated type families to the rescue
Associated type families allow us to associate a type family with our type class. This allows to do âsomethingâ based on the instance of the type class which depends on the problem we are trying to solve.
Regarding the example above, our goal is to have some way of placing constraints on the type of the elements contained inside
c
, so that our instances for the type class type checks.
So for any instance of collection, the Elem
type family, given c
, will return the type of elements in c
. This establishes a relation between c
and some type of kind *
.
class Collection c where
type family Elem c :: *
empty :: c
insert :: Elem c -> c -> c
member :: Elem c -> c -> Bool
If we write down the specialised type signatures for insert
and member
for this instance of Collection
for lists, we can see that the associated type family Elem
tells the compiler that the a
contained inside the list, is the same a
that is used in the rest of the instance definition. We can now place an Eq
constraint on a
and everything type checks.
instance Eq a => Collection [a] where
type Elem [a] = a -- this isn't a type alias; it's a type family instance.
empty = []
-- insert :: Elem [a] -> [a] -> [a] (Specialised type signature)
insert x xs = x : xs
-- member :: Elem [a] -> [a] -> Bool
member x xs = x `elem` xs
Overloaded Lists
Language Extension: OverloadedLists
This is a neat language extension that allows us to use list notation for other data types as long as the data type has an instance of IsList
defined for it.
-- Without
poultree :: Tree String
poultree = Node Black
(Node Black Leaf "Chicken" Leaf)
"Duck"
(Node Black Leaf "Goose" Leaf)
-- With
{-# LANGUAGE OverloadedLists #-}
poultree :: Tree String
poultree = ["Duck", "Goose", "Chicken"]
Below we show an example with the Tree
data structure from one of the labs.
-- IsList is defined in GHC.Exts
class IsList l where
type family Item l :: *
fromList :: [Item l] -> l
fromListN :: Int -> [Item l] -> l
fromListN _ xs = fromList xs
toList :: l -> [Item l]
instance Ord a => IsList (L.Tree a) where
type Item (L.Tree a) = a
toList = L.toList
fromList = foldr (flip L.insert) L.empty