Now we have built a program which checks a number is even or odd at compile time. Although, the program is loose because type-class instances are open to being defined. If someone writes instance foo :: IsOdd Zero True or instance bar :: IsEven Zero Boolean somewhere, our program will end up with unexpected behavior. Can we improve it? Yes, we can! We can ensure IsEven and IsOdd are used in the way we want by making our type-level program typed.

Type-level computation with types

There are two things we can do to improve our type-level computations:

Use functional dependencies to make type classes work like functions. Annotate our type parameters defined in type classes with kinds to make type-level functions typed.

Functional dependencies restrict one-to-many relations

What multi-parameter type classes do is just relating types, so there is nothing to stop one adding a type-class instance like:

instance isOddZero' :: IsOdd Zero True

The problem is that once a natural number is provided, the result should be determined. Given the same input, the program should return the same output. Functional dependencies let us put on this restriction to our type classes:

class IsEven n b | n → b

class IsOdd n b | n → b

Where | n → b is the syntax of functional dependencies, which tells the compiler that knowing n determines b . With this restriction, we are only able to relate the same input with the same output, just like how functions work.

After adding functional dependencies, if we try to relate Zero to both False and True :

instance isOddZero :: IsOdd Zero False

instance isOddZero' :: IsOdd Zero True

we will get a compile-time error:

Overlapping type class instances found for Main.IsOdd Zero

True

What functional dependencies do is make type classes more like functions, so no wonder the syntax is n → b same as the function syntax. We can view IsEven and IsOdd as type-level functions, which, given an n , return a b .

You may notice that we are still able to put any type into IsEven and IsOdd , e.g., instance bar :: IsEven Zero Boolean . But we are already at the type level, we can not use types to type our type-level functions. What else can we use to solve this problem? We use kinds!

Kinds type types

Kinds are the types of types. All types we have seen so far belong to the Type kind. To introduce new kinds in PureScript, we use the foreign function interface (FFI) syntax despite that we do not use any code outside PureScript.

Here is how we define new kinds:

foreign import kind Nat

foreign import kind Boolean

Nat is the kind for type-level natural numbers, and Boolean is the kind for type-level booleans. We can annotate our types with these new kinds by using FFI as well:

foreign import data Zero :: Nat

foreign import data Succ :: Nat → Nat foreign import data True :: Boolean

foreign import data False :: Boolean

So Zero is a Nat type, Succ is a Nat → Nat type and both True and False are Boolean types.

Notice that Nat → Nat means Succ is a unary type constructor. Because it’s a constructor awaiting a Nat type, we can’t apply it with other types anymore. In other words, Succ Int will not get compiled since now. Succ is typed with kinds!

Then, our type classes can be redefined as:

class IsEven (n :: Nat) (b :: Boolean) | n → b

class IsOdd (n :: Nat) (b :: Boolean) | n → b

With the kind annotations, we can only use IsEven and IsOdd with a Nat type and a Boolean type now. instance bar :: IsEven Zero Boolean is no longer compiled because the type Boolean is a Type type, not a Boolean type.

That is all! We do not need to change the definitions of type-class instances.

Because only Type types can have term-level values, to kick off the compiler, we need some help like undefined . This time we use a phantom type which carries a Boolean type:

data BProxy (b :: Boolean) = BProxy

The type-level BProxy has the kind Boolean → Type . We use it to construct a Type type from a Boolean type.

Let’s play with the type BProxy in PSCi to get more familiar with the idea:

> :k True

Main.Boolean > :k BProxy True

Type > :k BProxy False

Type

The term-level BProxy is just a bridge, like undefined . We use it to convey types from the term level to the type level:

> :t BProxy

forall t1. BProxy t1 > :t BProxy :: BProxy True

BProxy True > :t BProxy :: BProxy False

BProxy False

Finally, we can test our code:

test3 :: BProxy True

test3 = BProxy :: ∀ b. IsEven Two b => BProxy b

test3 passes the type check, because Two is even.

test4 :: BProxy True

test4 = BProxy :: ∀ b. IsEven Three b => BProxy b

test4 does not pass the type check, because Three is not even.

Thanks to the functional dependencies, test4 has a better error message than test2 does:

Could not match type False with type True

Put it all together: