Advanced Types
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 ''PersonIntuition
Section titled “Intuition”Types are equations, not just labels: In Haskell, algebraic data types are literally algebra — Maybe Bool has 3 values (like the equation 1 + 2 = 3), and Either Bool () has 3 values (2 + 1 = 3). This mathematical foundation means you can reason about your type system arithmetically. GADTs are like functions that return different types depending on the input — a type-level if-else. Phantom types are invisible stamps that the compiler tracks but the runtime ignores — you can tag a value with Safe or Unsafe at compile time without any runtime cost.
Why it matters: Advanced types let you encode business rules directly in the type system. A function that takes SafeUrl instead of String can never be called with user input — the compiler enforces security invariants.
The key insight: Haskell’s type system is a full programming language — you can compute with types, make decisions at the type level, and push invariant checking from runtime to compile time.
Type 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.
Cross-References
Section titled “Cross-References”- Types and Functions: Basic type system that advanced types extend with GADTs and type families.
- Monads and Functors: Monad transformers and effect stacking that use advanced type features.
- Concurrency: STM and async patterns that benefit from phantom type safety.
Common Mistakes
Section titled “Common Mistakes”Using data when newtype would suffice: data allocates an extra constructor wrapper at runtime. Use newtype for single-constructor, single-field types to get zero-cost abstractions.
Forgetting that type families are open: Type family instances can be added anywhere, potentially causing overlapping instances. Close your type families or use closed type families to prevent unexpected overlaps.
Overusing Template Haskell: TH makes code harder to read, debug, and compile. It creates separate compilation boundaries and can produce confusing error messages. Prefer plain Haskell unless TH is truly necessary.