Types as Specifications:
An Intro to Type-level Programming in Haskell
Thomas Dietert
Lambda Conf 2019
Boulder, CO
Setup
- Install stack:
- Clone this repo:
.../types-as-specifications $ stack setup
.../types-as-specicications $ stack build
whoami
-
Software Engineer @ Freckle
-
Hardware Hobbyist
-
Boulderer
-
Reader
-
NE IPA drinker
“Everyone thinks they think, but if you don’t write down your thoughts you are fooling yourself"
- Leslie Lamport
Workshop Structure
- Introduction
- Lecture & Exercises
- Type-level Programming
- Lecture & Exercises
- Type-Indexed Lists
- Lecture & Exercises
- Discussion
- First-class TypeFamilies
- Type-level State Machines
Assumed Prerequisite Knowledge
-
Static vs Dynamic Type Systems
-
Type Signatures
-
Algebraic Datatypes
-
Type classes
-
Constraints
-
Functors, Applicatives, & Monads
Haskell
-
strong, static type system
-
Algebraic Datatypes (ADTs)
-
pure vs impure
-
GHC: an extensible compiler
Introduction:
Types as program Specifications
What is a Specification?
A specification is a formal description of program semantics
What does your program mean?
Types of Specifications
-
Decision Tables
-
Business Requirements
-
RFCs
-
Documentation
-
Types
What is a type?
Metadata associated with a particular region of memory locations that describe the structure of the data content of those memory locations.
A set designating the shape, form, or structure of language level values.
The Goal: to make invalid states unrepresentable
Values, Types, & Kinds
values describe data
types describe values
kinds describe types
$$ Int = \{..., -1, 0, 1, ... 9223372036854775807 \}$$
$$17 = \text{<Language Specific Memory Layout>}$$
Types of Values
Prelude> :type True
True :: Bool
Prelude> :type Just "Haskell"
Just "Haskell" :: Maybe [Char]
Prelude> :type 5
5 :: Num p => p
Prelude> :type fmap
fmap :: Functor f => (a -> b) -> f a -> f b
Prelude> :type (>>=)
(>>=) :: Monad m => m a -> (a -> m b) -> m b
Value Constructors
data Maybe a
= Just a
| Nothing
data Either a b
= Left a
| Right b
newtype T m a
= T { unT :: m a }
Prelude> :t Nothing
Nothing :: Maybe a
Prelude> :t Just
Just :: a -> Maybe a
Prelude> :t Left
Left :: a -> Either a b
Prelude> :t Right
Right :: b -> Either a b
Prelude> :t T
T :: m a -> T m a
Kinds of Types
Prelude> :kind Int
Int :: *
Prelude> :kind Maybe
Maybe :: * -> *
Prelude> :kind Either
Either :: * -> * -> *
Prelude> :kind Either Bool
Either Bool :: * -> *
Type Constructors
data Maybe a
= Just a
| Nothing
data Either a b
= Left a
| Right b
newtype T m a
= T { unT :: m a }
data App m f a b
= App
{ x :: f a
, y :: m a b
}
Prelude> :k Maybe
Maybe :: * -> *
Prelude> :k Either
Either :: * -> * -> *
Prelude> :k T
T :: (* -> *) -> * -> *
Prelude> :k App
App
:: (* -> * -> *)
-> (* -> *)
-> *
-> *
-> *
Type Constructors
data Maybe (a :: *)
= Just a
| Nothing
data Either (a :: *) (b :: *)
= Left a
| Right b
newtype T (m :: * -> *) (a :: *)
= T { unT :: m a }
{-# LANGUAGE KindSignatures #-}
data App
(m :: * -> * -> *)
(f :: * -> *)
(a :: *)
(b :: *)
= App
{ x :: f a
, y :: m a b
}
Intro to
Type-level Programming in Haskell
- Phantom Types
- GADTs
Language Extensions
{-# LANGUAGE MultiParamTypeClasses #-}
{-# LANGUAGE GADTs #-}
Phantom Types
- Allow us to attach extra information to all values of a datatype without modifying their structure.
- Notably, "phantom" type arguments attach this information to all value constructors.
-- Example:
data Proxy a = Proxy
Phantom Types: Motivation
data DistanceUnit
= Miles
| Kilometers
deriving Eq
data Distance
= Distance
{ distanceUnit :: DistanceUnit
, distanceValue :: Double
}
addDistances
:: Distance
-> Distance
-> Either [Char] Distance
addDistances d1 d2
| distanceUnit d1 == distanceUnit d2
= Right
$ Distance
{ distanceUnit = d1
, distanceValue = d1 + d2
}
| otherwise
= Left "Distance units unequal!"
Phantom Types: Motivation
-- This doesn't work very well...
convert :: Distance -> Distance
-- | Wrap the underlying conversion function
convertDistance :: Distance -> DistanceUnit -> Distance
convertDistance (Distance fromUnits v) toUnits =
convert v fromUnits toUnits
-- | This is better, but more granular than we want
convert :: Double -> DistanceUnit -> DistanceUnit -> Double
convert v Miles Kilometers = v * 1.60934
convert v Kilometers Miles = v * 0.621371
data PDistance a
= PDistance
{ pdistanceValue :: Double
}
addPDistances
:: PDistance a
-> PDistance a
-> PDistance a
addPDistances (PDistance pv1) (PDistance pv2)
= PDistance (pv1 + pv2)
Phantom Types: Solution
Phantom Types: Solution
data Miles
data Kilometers
milesToKilometers
:: PDistance Miles
-> PDistance Kilometers
milesToKilometers (PDistance v)
= PDistance (v * 1.60934)
kilometersToMiles
:: PDistance Kilometers
-> PDistance Miles
kilometersToMiles (PDistance v)
= PDistance (v * 0.621371)
Phantom Types: Solution
{-# LANGUAGE MultiParamTypeClasses #-}
class PConvert a b where
pconvert :: PDistance a -> PDistance b
instance PConvert Miles Kilometers where
pconvert = milesToKilometers
instance PConvert Kilometers Miles where
pconvert = kilometersToMiles
{-# LANGUAGE GADTs #-}
(Generalized Algebraic Datatypes)
- We can embed type information for datatype constructor return types
- Constructor return types can differ
- Pattern matching on GADT constructors can determine the return type of the function case expression
-- | GADT syntax
data Either a b where
Left :: a -> Either a b
Right :: b -> Either a b
-- | ADT syntax
data Either a b
= Left a
| Right b
-- Distance Units as "Phantom" type
data PDistance a
= PDistance
{ pdistanceValue :: Double
}
-- Distance Units as Generalized Type
data GDistance a where
GDistance :: Double -> GDistance a
GADT Translation
(PDistance => GDistance)
{-# LANGUAGE GADTs #-}
(Generalized Algebraic Datatypes)
data Miles
data Kilometers
data GDistance a where
GDistance :: Double -> GDistance a
class GConvert a b where
gconvert :: GDistance a -> GDistance b
instance GConvert Miles Kilometers where
gconvert (GDistance v)
= GDistance (v * 1.60934)
instance GConvert Kilometers Miles where
gconvert (GDistance v)
= GDistance (v * 0.621371)
GADTs: Motivation
No Phantom types
data ELit
= LBool Bool
| LInt Int
data Expr
= ELit ELit
| EAdd Expr Expr
| EAnd Expr Expr
| EEq Expr Expr
| EIf Expr Expr Expr
GADTs: Motivation
No Phantom types
eval :: Expr -> Either [Char] ELit
eval expr =
case expr of
ELit lit -> Right lit
EAdd e1 e2 -> do
ELit res1 <- eval e1
case res1 of
LInt n1 -> do
ELit res2 <- eval e2
case res2 of
LInt n2 ->
Right (LInt (n1 + n2))
LBool _ ->
Left "Cannot add EBool"
LBool _ ->
Left "Cannot add EBool"
...
Exercise 1
src/Typelevel/Exercises/Intro/GADTs.hs
GADTs: Motivation
Phantom types
data PLit
= PBool Bool
| PInt Int
data PExpr a
= PLit PLit
| PAdd (PExpr Int) (PExpr Int)
| PAnd (PExpr Bool) (PExpr Bool)
| PEq (PExpr a) (PExpr a)
| PIf (PExpr Bool) (PExpr a) (PExpr a)
The type parameter 'a' allows us to add extra information about the value at compile time.
GADTs: Motivation
Phantom types
peval :: PExpr a -> Either [Char] PLit
peval pexpr =
case pexpr of
PLit plit -> Right plit
PAdd pe1 pe2 -> do
res1 <- peval pe1
case res1 of
PBool _ -> Left "Cannot add PBool"
PInt n1 -> do
res2 <- peval pe2
case res2 of
PBool _ -> Left "Cannot add PBool"
PInt n2 -> pure (PInt (n1 + n2))
...
peval_Example_1 :: Either [Char] PLit
peval_Example_1 =
peval $
PAnd
(PLit (PBool True))
(PLit (PBool False))
peval_Example_2 :: Either [Char] PLit
peval_Example_2 =
peval $
PEq
(PLit (PInt 1))
(PLit (PBool True))
GADTs: Motivation
Phantom types
So how does this help us... ?
GADTs: Solution
data GLit a where
GBool :: Bool -> GLit Bool
GInt :: Int -> GLit Int
data GExpr a where
GLit :: GLit a -> GExpr a
GAdd :: GExpr Int -> GExpr Int -> GExpr Int
GAnd :: GExpr Bool -> GExpr Bool -> GExpr Bool
GEq :: Eq a => GExpr a -> GExpr a -> GExpr Bool
GIf :: GExpr Bool -> GExpr a -> GExpr a -> GExpr a
"Correct by Construction"
The return type of GExpr constructors can depend on the types of the constructor fields.
Exercise 2a & 2b
src/Typelevel/Exercises/Intro/GADTs.hs
geval :: GExpr a -> a
geval gexpr =
case gexpr of
GLit glit -> gevalLit glit
GAdd e1 e2 -> geval e1 + geval e2
GAnd e1 e2 -> geval e1 && geval e2
GEq e1 e2 -> geval e1 == geval e2
GIf c e1 e2 ->
if (geval c)
then geval e1
else geval e2
Exercise 2b: Solution
gevalLit :: GLit a -> a
gevalLit (GBool b) = b
gevalLit (GInt n) = n
Type-level Programming in Haskell
- Type-level Functions
- Value & Type Promotion
- Higher-Kinded data
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE GADTs #-}
{-# LANGUAGE KindSignatures #-}
{-# LANGUAGE MultiParamTypeClasses #-}
{-# LANGUAGE PolyKinds #-}
{-# LANGUAGE TypeFamilies #-}
{-# LANGUAGE TypeOperators #-}
{-# LANGUAGE UndecidableInstances #-}
Language Extensions
{-# LANGUAGE TypeFamilies #-}
The concept of a type family comes from type theory. An indexed type family in type theory is a partial function at the type level. Applying the function to parameters (called type indices) yields a type. Type families permit a program to compute what data constructors it will operate on, rather than having them fixed statically (as with simple type systems) or treated as opaque unknowns (as with parametrically polymorphic types).
- GHC Wiki
{-# LANGUAGE TypeFamilies #-}
("Type level functions")
const :: a -> b -> a
const x _ = x
"Value-level" functions
"Type-level" functions
type family Const a b where
Const a b = a
Prelude> const 42 "Haskell"
42
Prelude> :set -XTypeFamilies
Prelude> :kind! Const Int [Char]
Const Int [Char] :: *
= Int
{-# LANGUAGE TypeFamilies #-}
- Evaluated at compile type, during type-checking
- Domain: Types, Codomain: Types
- *Cannot be partially applied
- Denoted with type variables and kind signatures
{-# LANGUAGE TypeFamilies #-}
("Type level functions")
{-# LANGUAGE KindSignatures #-}
{-# LANGUAGE TypeFamilies #-}
module PartialTypeFamilyApply where
data DConst a b = DConst a
-- | We can partially apply a Type constructor...
type family CurryDConst (a :: *) :: (* -> *) where
CurryDConst Int = DConst Int
-- | But _not_ a type family!
type family TConst a b where TConst a b = a
type family CurryTConst (a :: *) :: (* -> *) where
CurryTConst Int = TConst Int
/home/thomasd/github/type-as-specifications/src/PartialTypeFamilyApply.hs:15:3: error:
• The type family ‘TConst’ should have 2 arguments, but has been given 1
• In the equations for closed type family ‘CurryTConst’
In the type family declaration for ‘CurryTConst’
|
15 | CurryTConst Int = TConst Int
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
{-# LANGUAGE TypeFamilies #-}
("Type level functions")
GHC's kind language is limited:
data True -- Declares the type 'True'
data False -- Declares the type 'False'
type family Or (b :: *) (b' :: *) :: * where
Or True _ = True
Or _ True = True
Or False False = False
{-# LANGUAGE TypeFamilies #-}
("Type level functions")
data True -- ^ Declares the type 'True'
data False -- ^ Declares the type 'False'
-- | Type-level Boolean Or
type family Or (b :: *) (b' :: *) :: * where
Or True _ = True
Or _ True = True
Or False False = False
Or Int Char = Maybe (IO Bool)
- Or can be instantiated with any type of kind *
- Or can return any type of kind *
{-# LANGUAGE DataKinds #-}
-
Values are promoted to types
-
Types are promoted to kinds
-- | Declares the _kind_ Bool
-- and the _type constructors_ True and False.
data Bool
= True
| False
-- | Type-level Boolean Or
type family Or (b :: Bool) (b' :: Bool) :: Bool where
Or 'True _ = 'True
Or _ 'True = 'True
Or 'False 'False = 'False
Exercises 1 & 2
src/Typelevel/Exercises/Basics.hs
{-# LANGUAGE DataKinds #-}
Type-level Natural Numbers
Peano Axioms
- 0 is a natural number
- For every natural number x, x = x
- For all natural numbers x and y, if x = y, then y = x.
- For all natural numbers x, y and z, if x = y and y = z, then x = z.
- For all a and b, if b is a natural number and a = b, then a is also a natural number.
- For every natural number n, S(n) is a natural number.
- For all natural numbers m and n, m = n if and only if S(m) = S(n).
- For every natural number n, S(n) = 0 is false.
{-# LANGUAGE DataKinds #-}
Type-level Natural Numbers
-- | Declares the _kind_ Nat
-- and the _type constructors_ Zero and Succ
data Nat
= Zero
| Succ Nat
-- | Type-level natural number addition
type family Add (n :: Nat) (m :: Nat) :: Nat where
Add 'Zero m = m
Add ('Succ n) m = Succ (Add n m)
Exercises 3 & 4
src/Typelevel/Exercises/Basics.hs
{-# LANGUAGE UndecidableInstances #-}
/home/thomasd/github/type-as-specifications/src/Typelevel.hs:86:3: error:
• Illegal nested type family application ‘Add m (Mult n m)’
(Use UndecidableInstances to permit this)
• In the equations for closed type family ‘Mult’
In the type family declaration for ‘Mult’
|
86 | Mult ('S n) m = Add m (Mult n m)
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
Regarding Exercises 3 & 4:
GHC can't guarantee that this instance definition terminates.
Type-level Natural Numbers
(w/ more ergonomics!)
-- Type aliases for readability
type One = 'Succ 'Zero
type Two = 'Succ ('Succ 'Zero )
type Three = 'Succ ('Succ ('Succ 'Zero ))
type Four = Mult Two Two
type Five = Add Two Three
type Six = Add Three Three
type Seven = Add Four Three
type Eight = Mult Four Two
type Nine = Mult Three Three
But this is annoying...
{-# LANGUAGE TypeOperators #-}
-- Type alias
type (||) a b = Or a b
infixl 7 ||
-- Type family definitions
type family (n :: Nat) :*: (m :: Nat) :: Nat where
'Zero :*: m = 'Zero
('Succ n) :*: m = m + (n :*: m)
Haskell Wiki:
"There are three kinds of fixity, non-, left- and right-associativity (infix, infixl, and infixr, respectively), and ten precedence levels, 0 to 9 inclusive (level 0 binds least tightly, and level 9 binds most tightly). If the digit is omitted, level 9 is assumed. Any operator lacking a fixity declaration is assumed to be infixl 9"
Type-level Natural Numbers
(w/ even more ergonomics!)
Prelude GHC.TypeNats> :set -XTypeOperators
Prelude GHC.TypeNats> :set -XDataKinds
Prelude GHC.TypeNats> import GHC.TypeNats
Prelude GHC.TypeNats> :kind! 1 + 1
1 + 1 :: Nat
= 2
Prelude GHC.TypeNats> :kind! 2 ^ 3
2 ^ 3 :: Nat
= 8
Prelude GHC.TypeNats> :kind! 2 <=? 3
2 <=? 3 :: Bool
= 'True
Prelude GHC.TypeNats> :kind! (1 + 3) ^ 2 - 5
(1 + 3) ^ 2 - 5 :: Nat
= 11
* We will use these type-level natural numbers for the remainder of the workshop
Exercise 5
src/Typelevel/Exercises/Basics.hs
So, we want our kinds to be "kind polymorphic".
{-# LANGUAGE PolyKinds #-}
{-# LANGUAGE PolyKinds #-}
- GHC will automatically infer polymorphic kinds for unannotated kind signatures
- No quantification for kind-variables
- Polykinds implies KindSignatures
type family IfThenElse (c :: Bool) (a :: k) (b :: k) where
IfThenElse 'True a b = a
IfThenElse 'False a b = b
Type-Indexed Lists*
- Length-Indexed Lists
- Type-level lists
- Heterogenous Lists
- Type-safe Extensible Records
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE ConstraintKinds #-}
{-# LANGUAGE GADTs #-}
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE FlexibleInstances #-}
{-# LANGUAGE KindSignatures #-}
{-# LANGUAGE MultiParamTypeClasses #-}
{-# LANGUAGE PolyKinds #-}
{-# LANGUAGE TypeFamilies #-}
{-# LANGUAGE TypeOperators #-}
{-# LANGUAGE UndecidableInstances #-}
Language Extensions
Length Indexed Lists
Exercise 1
-
Part A:
- Define a GADT that encodes a recursive list structure indexed by its length (as type Nat)
-
Part B:
- Implement the nappend function that appends two length indexed lists
- Note: You may have trouble with this. There are two solutions.
Type-level Lists
-- Defined in GHC.Types
data [a]
= []
| a : [a]
Prelude> :set -XDataKinds -XTypeOperators
Prelude> :t []
[] :: [a]
Prelude> :t 1 : 2 : 3 : []
[1,2,3] :: Num a => [a]
Prelude> :k '[]
'[] :: [k]
Prelude> :kind! 1 ': 2 ': 3 ': '[]
1 ': 2 ': 3 ': '[] :: [ghc-prim-0.5.3:GHC.Types.Nat]
= '[1, 2, 3]
Type-level Lists
-- Defined in GHC.Types
data [a]
= []
| a : [a]
Prelude> :set -XDataKinds -XTypeOperators
Prelude> :t []
[] :: [a]
Prelude> :t 1 : 2 : 3 : []
[1,2,3] :: Num a => [a]
Prelude> :k '[]
'[] :: [k]
Prelude> :kind! 1 ': 2 ': 3 ': '[]
1 ': 2 ': 3 ': '[] :: [ghc-prim-0.5.3:GHC.Types.Nat]
= '[1, 2, 3]
Exercise 1
src/Typelevel/Exercises/Lists.hs
Higher-Order Type Families
- Type-level functions that take type-level functions as arguments.
- Cannot return type-level functions as a result
- Cannot pass in type-families as arguments, only type-constructors
Higher-Order Type Families
type family Map (f :: k -> j) (xs :: [k]) :: [j] where
Map f '[] = '[]
Map f (x ': xs) = f x ': Map f xs
type family Filter (f :: k -> Bool) (xs :: [k]) :: [k] where
Filter p '[] = '[]
Filter p (x ': xs) =
IfThenElse (p x)
(x ': Filter p xs)
(Filter p xs)
Heterogenous Lists
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE GADTs #-}
{-# LANGUAGE KindSignatures #-}
{-# LANGUAGE PolyKinds #-}
{-# LANGUAGE TypeOperators #-}
data HList (a :: [*]) where
HNil :: HList '[]
HCons :: x -> HList xs -> HList (x ': xs)
Why wouldn't we annotate the kind of `a` as `[k]`, i.e. why isn't the type level list kind polymorphic?
Exercises 2 & 3
src/Typelevel/Exercises/Lists.hs
{-# LANGUAGE FlexibleInstances #-}
.../types-as-specifications/src/Typelevel/Lists.hs:58:10: error:
• Illegal instance declaration for ‘Show (HList '[])’
(All instance types must be of the form (T a1 ... an)
where a1 ... an are *distinct type variables*,
and each type variable appears at most once in the instance head.
Use FlexibleInstances if you want to disable this.)
• In the instance declaration for ‘Show (HList '[])’
|
58 | instance Show (HList '[]) where
| ^^^^^^^^^^^^^^^^
.../types-as-specifications/src/Typelevel/Lists.hs:61:39: error:
• Illegal instance declaration for ‘Show (HList (x : xs))’
(All instance types must be of the form (T a1 ... an)
where a1 ... an are *distinct type variables*,
and each type variable appears at most once in the instance head.
Use FlexibleInstances if you want to disable this.)
• In the instance declaration for ‘Show (HList (x : xs))’
|
61 | instance (Show x, Show (HList xs)) => Show (HList (x ': xs)) where
|
{-# LANGUAGE ConstraintKinds #-}
- Any type of kind Constraint can be used as a Constraint
- Tuples where each element is of kind Constraint can be used as a Constraint
type ShowNum a = (Show a, Num a)
type family Typ a b :: Constraint
type instance Typ Int b = Show b
type instance Typ Bool b = Num b
{-# LANGUAGE ConstraintKinds #-}
type family All (c :: k -> Constraint) (xs :: [k]) :: Constraint where
All _ '[] = ()
All c (x ': xs) = (c x, All c xs)
type family IsElem (a :: k) (xs :: [k]) :: Bool where
IsElem a '[] = 'False
IsElem a (a ': xs) = 'True
IsElem a (x ': xs) = Elem a xs
type family Elem (a :: k) (xs :: [k]) :: Constraint where
Elem a xs = IsElem ~ 'True
Exercise 4
src/Typelevel/Exercises/Lists.hs
Live Programming
-
Implement a type family that appends two HList values
-
Data.Type.Equality
-
Prove type equality at the value level
-
-
Type-safe Extensible Records
- "access" record field
- "extend" record
Types as Specifications:
By Thomas Dietert
Types as Specifications:
- 791