Advanced Types | Haskell - Wyatt's Notes
Algebraic Data Types Revisited
Section titled “Algebraic Data Types Revisited”Algebraic data types (ADTs) in Haskell are the foundation of its type system. They combine sum types (multiple constructors, one is chosen) and product types (a constructor holds multiple fields):
-- Sum type: a Shape is one of these alternativesdata Shape = Circle Double Double Double | Rectangle Double Double Double Double | Triangle Double Double Double Double Double Double
-- Product type: a Point has both x and ydata Point = Point Double Double
-- Recursive ADT: a tree contains treesdata Tree a = Leaf a | Branch (Tree a) (Tree a)
-- Polymorphic ADT: works for any element typedata Either a b = Left a | Right bdata Maybe a = Nothing | Just aThe Algebra of Types
Section titled “The Algebra of Types”Haskell types form an algebra where:
- Types correspond to sets of values
|(sum) corresponds to disjoint union (cardinality )- Fields in a constructor correspond to cartesian product (cardinality )
a -> bcorresponds to
-- Bool = True | False: 2 values-- () = (): 1 value-- Maybe Bool = Nothing | Just True | Just False: 3 values-- Either Bool () = Left True | Left False | Right (): 3 values
-- (Bool, Bool) = 4 values: (F,F), (F,T), (T,F), (T,T)-- Bool -> Bool = 4 functions-- () -> Bool = 2 functions (constant True, constant False)GADTs (Generalized Algebraic Data Types)
Section titled “GADTs (Generalized Algebraic Data Types)”GADTs extend ordinary data types by allowing explicit type signatures on constructors:
{-# LANGUAGE GADTs #-}
-- Regular ADT: result type is always the samedata Expr a where Lit :: Int -> Expr Int Add :: Expr Int -> Expr Int -> Expr Int Mul :: Expr Int -> Expr Int -> Expr Int IsZero :: Expr Int -> Expr Bool If :: Expr Bool -> Expr a -> Expr a -> Expr a -- Each constructor can return a different type!Why GADTs?
Section titled “Why GADTs?”GADTs allow the type system to track information that regular ADTs cannot:
-- Without GADTs: Expr a means we can put anything anywhere-- The type checker cannot prevent this:-- badExpr = If (Lit 42) (Lit 1) (Lit 2) -- Bool where Int expected-- This compiles but makes no sense!
-- With GADTs: each constructor constrains its result type-- This is caught by the type checker:eval :: Expr a -> aeval (Lit n) = neval (Add e1 e2) = eval e1 + eval e2eval (Mul e1 e2) = eval e1 * eval e2eval (IsZero e) = eval e == 0eval (If cond t e) = if eval cond then eval t else eval e
-- The type of eval ensures safety:-- eval (If (Lit 42) (Lit 1) (Lit 2))-- Type error: expected Expr Bool, got Expr Int in If conditionMore GADT Examples
Section titled “More GADT Examples”-- Safe list operationsdata SafeList a b where Nil :: SafeList a Empty Cons :: a -> SafeList a b -> SafeList a NonEmpty
data Emptydata NonEmpty
safeHead :: SafeList a NonEmpty -> asafeHead (Cons x _) = x
-- This is impossible to call with an empty list-- safeHead Nil -- type error!-- Typed JSON representationdata JSON where JNull :: JSON JBool :: Bool -> JSON JNumber :: Double -> JSON JString :: String -> JSON JArray :: [JSON] -> JSON JObject :: [(String, JSON)] -> JSON
-- Safe accessor: compile-time guarantee of typegetBool :: JSON -> Maybe BoolgetBool (JBool b) = Just bgetBool _ = Nothing
getString :: JSON -> Maybe StringgetString (JString s) = Just sgetString _ = NothingDataKinds
Section titled “DataKinds”The DataKinds extension promotes data types to the kind level, allowing types to be used as type parameters:
{-# LANGUAGE DataKinds #-}
-- The promoted type Nat has kind *-- Its constructors "Z and 'S have kind Natdata Nat = Z | S Nat
-- Type-level natural numberstype Zero = 'Ztype One = 'S 'Ztype Two = 'S ('S 'Z)type Three = 'S ('S ('S 'Z))
-- Type-level listdata HList (xs :: [*]) where HNil :: HList '[] HCons :: x -> HList xs -> HList (x ': xs)
-- '[] and (:) are promoted constructors-- They exist at the type level as well as the term levelPhantom Types
Section titled “Phantom Types”Phantom types use type parameters that do not appear in the data constructors. They encode information in the type system without any runtime cost:
{-# LANGUAGE DataKinds #-}
data Meterdata Kilometer
data Distance a = Distance Double deriving (Show)
-- These are different types even though they have the same runtime representationd1 :: Distance Meterd1 = Distance 100.0
d2 :: Distance Kilometerd2 = Distance 1.0
-- Cannot accidentally mix units:-- addDistances :: Distance Meter -> Distance Kilometer -> Distance Meter-- This would require explicit conversiontoKilometers :: Distance Meter -> Distance KilometertoKilometers (Distance m) = Distance (m / 1000)More Phantom Type Examples
Section titled “More Phantom Type Examples”-- Typed file handlesdata ReadOnlydata ReadWrite
data File a = FilePath String deriving (Show)
readFile' :: File ReadOnly -> IO StringreadFile' (File path) = readFile path
writeFile' :: File ReadWrite -> String -> IO ()writeFile' (File path) contents = writeFile path contents
-- You cannot write to a ReadOnly file at the type level-- writeFile' (File "data.txt") "hello"-- Type error: expected File ReadWrite, got File ReadOnly
-- Safe state machinedata Lockeddata Unlocked
data StateMachine a where SM :: String -> StateMachine a
lock :: StateMachine Unlocked -> StateMachine Lockedlock (SM s) = SM s
unlock :: StateMachine Locked -> StateMachine Unlockedunlock (SM s) = SM s
-- The type system prevents double-locking or double-unlocking-- lock (lock initialSM) -- type error: expected Unlocked, got LockedType Families
Section titled “Type Families”Type families allow type-level functions — mappings from types to types:
{-# LANGUAGE TypeFamilies #-}
-- Closed type family: all equations must be togethertype family Elem xs where Elem '[] = 'False Elem (x ': xs) = 'True
-- Associated type family: lives inside a type classclass Collection c where type Element c empty :: c insert :: Element c -> c -> c toList :: c -> [Element c]
instance Collection [a] where type Element [a] = a empty = [] insert = (:) toList = id
instance Collection (Set.Set a) where type Element (Set.Set a) = a empty = Set.empty insert = Set.insert toList = Set.toListOpen vs Closed Type Families
Section titled “Open vs Closed Type Families”-- Open type family: new instances can be added anywheretype family Container a
type instance Container Int = [Int]type instance Container Bool = [Bool]-- Can add more in any module
-- Closed type family: all equations defined together-- Matches are tried in order; first match winstype family Rep a where Rep Int = [Int] Rep Bool = [Bool] Rep Double = [Double] -- All equations must be hereFunctional Dependencies
Section titled “Functional Dependencies”Functional dependencies constrain the relationship between type parameters in a multi-parameter type class:
{-# LANGUAGE FunctionalDependencies #-}
-- |s -> m| means m is uniquely determined by sclass MonadState s m | m -> s where get :: m s put :: s -> m () modify :: (s -> s) -> m () modify f = do s <- get put (f s)
-- Given the monad m, the state type s is determined-- So there can be at most one instance per monadinstance MonadState Int IO where get = readIORef globalIntRef put n = writeIORef globalIntRef nFunctional Dependencies vs Type Families
Section titled “Functional Dependencies vs Type Families”Both solve similar problems but with different trade-offs:
-- Functional dependencies approachclass Collects e c | c -> e where empty :: c insert :: e -> c -> c
-- Type families approachclass Collects c where type Elem c empty :: c insert :: Elem c -> c -> cFunctional dependencies are generally simpler for one-to-one relationships. Type families are more expressive for type-level computation and associated types.
Existential Types
Section titled “Existential Types”Existential types hide type information, allowing heterogeneous collections:
{-# LANGUAGE ExistentialQuantification #-}
-- A list of values that all implement Show-- but may have different typesdata Showable where Showable :: Show a => a -> Showable
showList :: [Showable] -> StringshowList = unlines . map (\(Showable x) -> show x)
-- Usagethings :: [Showable]things = [Showable 42, Showable "hello", Showable True]
-- heterogeneousList :: [Showable]-- heterogeneousList = [Showable 42, Showable "hello", Showable [1,2,3]]-- All these can go in the same list because their types are hiddenExistential Types and Type Classes
Section titled “Existential Types and Type Classes”-- A collection of comparable valuesdata Eqable where Eqable :: Eq a => a -> Eqable
-- We can compare Eqable values to themselves-- but not to each other (they may be different types)checkEquality :: Eqable -> Eqable -> Maybe StringcheckEquality (Eqable a) (Eqable b) = do -- a and b may have different types, so we cannot use (==) -- This is where GADTs with equality constraints help Nothing
-- With GADT equality constraints:data EqBox where EqBox :: (Eq a, Show a) => a -> EqBox
-- Still cannot compare different EqBox values-- but we can display them:showBox :: EqBox -> StringshowBox (EqBox x) = show xRank-N Types
Section titled “Rank-N Types”Rank-N types allow polymorphism in arguments (rank 2) or even in arguments of arguments (rank N).
Rank 2 Types
Section titled “Rank 2 Types”{-# LANGUAGE RankNTypes #-}
-- Rank 1 (normal): the type variable 'a' is quantified at the top levelnormal :: a -> anormal = id
-- Rank 2: the function argument is polymorphic-- The forall is INSIDE the argument typeapplyToBoth :: (forall a. a -> a) -> (Int, Bool)applyToBoth f = (f 42, f True)
-- Only functions that work for ALL types can be passed-- applyToBoth (*2) -- type error! *2 only works for Num a-- applyToBoth id -- works! id works for all aPractical Rank 2 Types
Section titled “Practical Rank 2 Types”-- ST Monad: runST has rank 2 type-- runST :: (forall s. ST s a) -> a-- The 's' parameter is quantified inside the argument-- This prevents the 's' from escaping the ST computationimport Control.Monad.ST
safeST :: IntsafeST = runST $ do ref <- newSTRef 42 modifySTRef ref (+1) readSTRef ref-- => 43-- The type variable 's' cannot leak out of runST
-- Callback-based APIwithFile :: FilePath -> (forall h. h -> IO a) -> IO a-- The handle h is polymorphic inside the callback-- This ensures the handle is properly closedRank 2 Constraints
Section titled “Rank 2 Constraints”-- A function that requires its argument to be Monad for ALL types-- This is rarely needed but exists-- doSomething :: (forall m. Monad m => m Int) -> IO ()Associated Types
Section titled “Associated Types”Associated types (synonym families in type classes) link a type family to a type class:
{-# LANGUAGE TypeFamilies #-}
class Keyed k where type Key k lookup' :: Key k -> Map (Key k) v -> Maybe v
instance Keyed String where type Key String = String
instance Keyed Int where type Key Int = Int
-- Each instance defines what Key means for that type-- This is cleaner than multi-parameter type classes for many casesThe Kind System
Section titled “The Kind System”Haskell has a kind system that classifies types. The default kind * (also written Type) classifies concrete types. Other kinds classify type constructors:
-- Kind *: concrete typesInt :: *Bool :: *Maybe Int :: *
-- Kind * -> *: type constructors taking one argumentMaybe :: * -> *[] :: * -> *IO :: * -> *
-- Kind * -> * -> *: type constructors taking two argumentsEither :: * -> * -> *(,) :: * -> * -> *Map :: * -> * -> *
-- With DataKinds, data constructors become kindsdata Nat = Z | S Nat-- Nat :: *-- 'Z :: Nat-- 'S :: Nat -> Nat
-- GHC.Prim: constraint kinds-- (Eq Int) :: Constraint-- (Monad m) :: ConstraintKind Signatures
Section titled “Kind Signatures”{-# LANGUAGE KindSignatures #-}
-- Explicit kind annotationsdata Proxy (a :: k) = Proxy
data GList (c :: * -> *) (a :: *) where GNil :: GList c a GCons :: c a -> GList c a -> GList c a
-- Kinds in type class declarationsclass Category (cat :: k -> k -> *) where id :: cat a a (.) :: cat b c -> cat a b -> cat a cPromoted Types
Section titled “Promoted Types”With DataKinds, data constructors are promoted to the type level. This enables type-level programming where types compute at compile time:
{-# LANGUAGE DataKinds, TypeOperators, GADTs #-}
data Nat = Z | S Nat
-- Type-level arithmetictype family Add (m :: Nat) (n :: Nat) :: Nat where Add 'Z n = n Add ('S m) n = 'S (Add m n)
type family Mul (m :: Nat) (n :: Nat) :: Nat where Mul 'Z n = 'Z Mul ('S m) n = Add n (Mul m n)
type Two = 'S ('S 'Z)type Four = 'S ('S ('S ('S 'Z)))type Six = Add Two Four -- evaluated at compile timeType-Level Natural Numbers
Section titled “Type-Level Natural Numbers”-- Peano natural numbers at the type leveldata Nat = Z | S Nat
type Zero = 'Ztype One = 'S 'Ztype Two = 'S ('S 'Z)type Three = 'S ('S ('S 'Z))
-- Vector: length-encoded listdata Vec a (n :: Nat) where VNil :: Vec a 'Z VCons :: a -> Vec a n -> Vec a ('S n)
-- head is safe for non-empty vectorsvhead :: Vec a ('S n) -> avhead (VCons x _) = x-- vhead VNil -- type error: cannot match Z with S n
-- append with correct length trackingvappend :: Vec a m -> Vec a n -> Vec a (Add m n)vappend VNil ys = ysvappend (VCons x xs) ys = VCons x (vappend xs ys)Type-Level Programming
Section titled “Type-Level Programming”Singleton Types
Section titled “Singleton Types”Singleton types bridge the gap between term-level and type-level values:
{-# LANGUAGE GADTs, DataKinds, TypeFamilies #-}
data Nat = Z | S Nat
data SNat (n :: Nat) where SZ :: SNat 'Z SS :: SNat n -> SNat ('S n)
-- Type-level to term-level conversionclass KnownNat (n :: Nat) where natVal :: proxy n -> Integer
instance KnownNat 'Z where natVal _ = 0
instance KnownNat n => KnownNat ('S n) where natVal _ = 1 + natVal (Proxy :: Proxy n)
-- Using singleton types for type-safe indexingindexVec :: SNat n -> Vec a ('S n) -> aindexVec SZ (VCons x _) = xindexVec (SS n) (VCons _ xs) = indexVec n xsType-Level Booleans and Conditionals
Section titled “Type-Level Booleans and Conditionals”data Bool = True | False -- promoted to type level
type family If (cond :: Bool) (t :: k) (f :: k) :: k where If 'True t f = t If 'False t f = f
type family Not (b :: Bool) :: Bool where Not 'True = 'False Not 'False = 'True
type family (&&) (a :: Bool) (b :: Bool) :: Bool where 'True && 'True = 'True 'True && 'False = 'False 'False && _ = 'FalseTemplate Haskell
Section titled “Template Haskell”Template Haskell (TH) enables compile-time metaprogramming — generating Haskell code programmatically:
{-# LANGUAGE TemplateHaskell #-}
import Language.Haskell.TH
-- splice: insert generated code at compile time-- $(...) runs at compile time-- [| ... |] quotes an expression (returns an Exp)
-- Generate a Show instance automatically-- deriveShow ''MyType
-- Generate boilerplate for record types-- makeLenses ''MyRecord
-- Run arbitrary Haskell at compile timemain = putStrLn $( let msg = "Generated at compile time!" in [| msg |] )Practical Template Haskell
Section titled “Practical Template Haskell”-- Generate case analysis for all constructors-- $(genCases ''MyDataType)
-- Lenses via Template Haskell (lens package)data Person = Person { _name :: String , _age :: Int }
-- This generates name, age lenses-- makeLenses ''Person
-- JSON serialization via Template Haskell-- deriveJSON defaultOptions ''PersonType System Best Practices
Section titled “Type System Best Practices”- Prefer newtype over data when wrapping a single type: zero runtime overhead and clearer intent.
- Use phantom types to encode invariants in the type system without runtime cost.
- Prefer GADTs when constructors should produce different result types.
- Use type families for type-level computation and associated types.
- Use functional dependencies for simpler constraints on multi-parameter type classes.
- Keep type-level programming simple: complex type-level code is hard to debug and understand.
- Document kind signatures when working with DataKinds.
- Use Template Haskell sparingly: it can make code harder to read and debug.
flowchart TD
A[1_Advanced Types] --> B[Key Concepts]
A --> C[Core Principles]
A --> D[Practical Applications]
B --> E[Fundamental definitions]
C --> F[Design patterns]
D --> G[Real-world usage]Intuition
Section titled “Intuition”Phantom types are like invisible labels. They add type information without adding runtime data. A phantom type parameter does not appear in the value, but the compiler uses it to enforce constraints at compile time. This is like having a secret code that only the compiler can read.
GADTs are like typed constructors. Each constructor can return a different specific type, not just the generic type. This is like a factory where each machine produces a different product, and the type system knows exactly which product each machine makes.
Cross-References
Section titled “Cross-References”- Type Classes - How type families and GADTs extend the type class system
- Types and Functions - How kind polymorphism and type-level programming build on basic type concepts
- Monads and Functors - How monad transformers and indexed types compose monadic effects
Common Mistakes
Section titled “Common Mistakes”Confusing type synonyms with new types. A type declaration creates an alias (interchangeable with the original). A newtype declaration creates a distinct type at compile time with zero runtime overhead. Students often use type when they need compile-time type safety, which newtype provides.
Forgetting that GADT pattern matching refines types. When you pattern match on a GADT constructor, the type variable is refined in that branch. This allows type-safe operations that would otherwise be impossible. Students sometimes ignore the type refinement, missing the whole point of GADTs.
Overusing type families when simpler type classes suffice. Type families associate types with type constructors, enabling type-level computation. However, they add complexity. Students often reach for type families when a simple type class with associated types would solve the problem more evidently.