Advanced Type Features
From simple tags to proofs-as-programs: master the type-level abstractions that make compilers your ally instead of your enemy.
Overview
Type systems began as bookkeeping — a way to catch "you added an integer to a string" at compile time. But over the past half-century, researchers discovered something startling: a sufficiently expressive type system is not just a safety net. It is a language for stating and proving properties about programs. The journey from C's int to Haskell's forall a. (a -> Bool) -> [a] -> [a] to Agda's Vec n a is a journey from bookkeeping to specification to proof.
This module follows that journey. We begin with algebraic data types (ADTs) — the structured unions and products that let you model "a shape is either a circle or a rectangle" precisely. We then cover pattern matching, the elegant deconstruction mechanism that makes ADTs practical. From there we turn to the polymorphism tower: generics (parametric polymorphism), subtyping, and the subtle question of variance that trips up even experienced engineers. We close with two of the deepest ideas in the field: dependent types, where values can appear in types, and the Curry-Howard correspondence, the profound isomorphism between types and logical propositions.
These are not academic curiosities. Every major modern language — Rust, Swift, Kotlin, TypeScript, Scala — has absorbed these ideas. Understanding them lets you write code that is not merely tested but provably correct by construction, and it gives you the vocabulary to understand any new type system you encounter in your career.
The examples span Haskell (for purity and notation), Rust (for systems-level ADTs and generics), TypeScript (for subtyping and variance in a language you may already know), and some Agda pseudocode (for dependent types). You do not need to be an expert in any of these — the concepts translate universally.
Every major reliability failure in software can be traced, in part, to a type system that could not express a crucial invariant. Null pointer exceptions, buffer overflows, use-after-free bugs, and incorrect protocol implementations are all cases where the type system said "fine" when it should have said "impossible". The advanced type features in this module are the machinery that closes those gaps. Rust's ownership system — which eliminates entire classes of memory bugs — is built on a foundation of generics and lifetime variance. TypeScript's structural subtyping catches millions of bugs per day across the web ecosystem. Dependent types, once confined to proof assistants, are now appearing in production languages (Idris, Lean, F*) used to verify cryptographic protocols and operating system kernels.
Beyond safety, these features profoundly change how you think about programs. When you model a problem with ADTs and pattern matching, you are forced to enumerate every case — the compiler will reject you if you forget one. When you understand variance, you can reason about why a List<Dog> is not a List<Animal> even though Dog is an Animal, and you will never again be surprised by a runtime ArrayStoreException in Java. And when you encounter the Curry-Howard correspondence, you will see — with genuine wonder — that writing a function and proving a theorem are the same act, separated only by notation. These ideas are the intellectual core of programming language theory and are increasingly the foundation of verified, reliable software at scale.
The Curry-Howard Correspondence: Types, Proofs, and Programs
The Curry-Howard correspondence is a three-way isomorphism: every logical proposition corresponds to a type, every proof of that proposition corresponds to a program of that type, and the structure of proofs (introduction/elimination rules) corresponds exactly to the structure of type constructors and pattern matching.
The concepts
01Algebraic Data Types (ADTs)
An algebraic data type is a type built by combining other types using two fundamental operations: product ("and") and sum ("or"). The name comes from the fact that the number of possible values of a composite type follows algebraic rules — products multiply, sums add.
Product types ("and"): A value of a product type contains one value of each component type simultaneously. A Point = (Float, Float) is a product: every point has both an x-coordinate and a y-coordinate. In Haskell, data Pair a b = Pair a b — the type Pair Int Bool has |Int| × |Bool| = infinitely many values.
Sum types ("or"): A value of a sum type contains exactly one of several alternatives. data Shape = Circle Float | Rectangle Float Float — a shape is either a circle with a radius or a rectangle with width and height. The number of values is |Circle values| + |Rectangle values|, hence "sum".
ADTs are recursive: data List a = Nil | Cons a (List a) — a list is either empty (Nil) or a head element followed by a tail list. This simple definition gives you singly-linked lists, and pattern matching on it gives you virtually all list algorithms.
Why ADTs matter: they force you to enumerate the possible shapes of your data. In a language without sum types (like Java before sealed classes), you might represent a shape as an interface with many implementations — but the type system cannot tell you whether you've handled every case. ADTs move that check to compile time. You cannot forget to handle Nil because the compiler will warn about a non-exhaustive pattern match.
Real-world payoff: Rust's Option<T> (= Some(T) | None) and Result<T, E> (= Ok(T) | Err(E)) are sum types. They make null-pointer exceptions and unhandled errors impossible to express — you must explicitly handle both branches.
-- Haskell: A small expression language as an ADT
data Expr
= Lit Int -- literal number
| Add Expr Expr -- addition
| Mul Expr Expr -- multiplication
| IfZero Expr Expr Expr -- if e1==0 then e2 else e3
-- Evaluate: the type guarantees every case is handled
eval :: Expr -> Int
eval (Lit n) = n
eval (Add e1 e2) = eval e1 + eval e2
eval (Mul e1 e2) = eval e1 * eval e2
eval (IfZero c t f) = if eval c == 0 then eval t else eval f
-- Example: (2 + 3) * 4
example :: Expr
example = Mul (Add (Lit 2) (Lit 3)) (Lit 4)
-- eval example == 20
02Pattern Matching
Pattern matching is the mechanism for destructuring algebraic data types — inspecting which variant a sum type holds and binding the contained values to names. It is to ADTs what field access is to structs, but it is exhaustive (the compiler checks you cover every case) and safe (no casting required).
A pattern is a structural description of a value. Cons x xs is a pattern that matches any non-empty list, binding the head to x and the tail to xs. [] (or Nil) matches the empty list. The compiler checks that the set of patterns is exhaustive (covers all cases) and warns about redundant patterns (unreachable cases).
Nested patterns let you match deeply into a structure in one step. Cons x (Cons y _) matches a list with at least two elements, binding the first two.
Guards add boolean conditions to patterns: | x > 0 = ... after a pattern fires only if the guard is satisfied.
Pattern matching in modern languages: Rust's match is essentially Haskell's case expression. Python 3.10+ added structural pattern matching (match/case). Scala has match. Even C++'s std::visit over std::variant is a workaround for the lack of first-class pattern matching.
The deep connection: pattern matching is the eliminator for a sum type. Every sum type A | B | C has a canonical eliminator that says "give me a function for each variant, and I'll give you a function from the sum type to any result". Pattern matching is syntactic sugar for this eliminator, and exhaustiveness checking ensures the eliminator is total — defined on every possible input.
Performance: compilers (especially ML/Haskell compilers) compile pattern matches to efficient decision trees, often outperforming hand-written chains of if-else.
-- Haskell: pattern matching on a recursive ADT
data Tree a = Leaf | Node (Tree a) a (Tree a)
-- depth: maximum depth of the tree
depth :: Tree a -> Int
depth Leaf = 0
depth (Node l _ r) = 1 + max (depth l) (depth r)
-- inorder traversal with nested patterns
inorder :: Tree a -> [a]
inorder Leaf = []
inorder (Node l x r) = inorder l ++ [x] ++ inorder r
-- Rust equivalent (for comparison):
-- enum Tree<T> { Leaf, Node(Box<Tree<T>>, T, Box<Tree<T>>) }
-- fn depth<T>(t: &Tree<T>) -> usize {
-- match t {
-- Tree::Leaf => 0,
-- Tree::Node(l, _, r) => 1 + depth(l).max(depth(r)),
-- }
-- }
03Generics and Parametric Polymorphism
Parametric polymorphism (called generics in most languages) lets you write a single function or type that works uniformly for any type, without knowing what that type is. The canonical example is identity :: a -> a in Haskell: for any type a, given a value of type a, return it unchanged. The function cannot do anything with the value — it doesn't know what type it is — so the only thing it can return is the value it was given. The type signature proves the implementation.
This is John Reynolds's parametricity theorem (1983): a parametrically polymorphic function must act uniformly across all types. This gives you "theorems for free" — you can prove properties of a function from its type alone, before seeing its code. For example, any function f :: [a] -> [a] can only rearrange, drop, or duplicate elements — it cannot create new ones because it doesn't know what type a is.
In Haskell: data Maybe a = Nothing | Just a — the type parameter a is a placeholder. fmap :: (a -> b) -> Maybe a -> Maybe b works for any a and b.
In Rust: fn largest<T: PartialOrd>(list: &[T]) -> &T — the type parameter T is constrained by the trait PartialOrd. Rust (and Haskell's typeclasses) uses bounded parametric polymorphism: you can require the type to implement certain interfaces.
In Java/C#: generics were added later and are implemented via erasure — the type parameter is erased at compile time. This means List<Integer> and List<String> share the same runtime representation, which enables some compatibility but prevents runtime type inspection.
In C++: templates are a form of generics but with duck typing — the constraints are not checked until instantiation. C++20 Concepts bring explicit constraints.
The key insight: parametric polymorphism is the mechanism for code reuse at the type level. Instead of writing sortInts, sortStrings, and sortShapes separately, you write sort :: Ord a => [a] -> [a] once.
-- Haskell: parametric polymorphism + typeclasses
-- fmap works for any Functor, any type a -> b
fmap :: Functor f => (a -> b) -> f a -> f b
-- "Free theorem": any function reverse-like :: [a] -> [a]
-- satisfies: map f . reverse == reverse . map f
-- Proof comes from the TYPE alone, not the implementation.
-- Rust bounded generics:
use std::fmt::Display;
fn print_twice<T: Display>(x: T) {
println!("{} {}", x, x);
}
// Works for i32, f64, String, any type implementing Display
// The bound T: Display is checked at the call site
04Subtyping and the Liskov Substitution Principle
Subtyping is a relation between types: S is a subtype of T (written S <: T) if any value of type S can be safely used wherever a T is expected. This is not the same as inheritance, though they often coincide in OOP languages.
The correct formal definition is Liskov's Substitution Principle (LSP, 1987): if S <: T, then for every program context C[-] that expects a T, substituting an S must not break the program's observable behaviour. This is stronger than "S has all the methods of T" — it requires those methods to honour the contracts (pre/postconditions) of T's methods.
Structural vs. nominal subtyping: In Java and C#, S <: T only if S explicitly declares implements T or extends T — this is nominal subtyping. In TypeScript and Go, S <: T if S has at least the same fields/methods as T, regardless of declarations — this is structural subtyping (also called "duck typing with types").
Function subtyping: This is where intuition breaks down. When is (A -> B) <: (C -> D)? Answer: when C <: A (input is contravariant) and B <: D (output is covariant). To accept a function where a wider input is expected, the function must handle at least as many inputs. To produce something where a wider output is needed, the output must be a subtype. This leads us to variance.
Record/object subtyping: A record with more fields is a subtype of one with fewer fields (width subtyping). {x: Int, y: Int} is a subtype of {x: Int} because it can be used anywhere an {x: Int} is needed. This is why TypeScript allows passing {name: string, age: number} where {name: string} is expected.
Common pitfall — LSP violations: Java arrays are covariant (String[] <: Object[]), but this breaks LSP. If you put a Dog into an Animal[] that is actually a Cat[], you get an ArrayStoreException at runtime. A properly designed type system (like Scala's or Rust's) prevents this at compile time via correct variance annotations.
// TypeScript: structural subtyping
type Animal = { name: string };
type Dog = { name: string; breed: string };
// Dog <: Animal (Dog has all fields of Animal, plus more)
function greet(a: Animal): string {
return "Hello, " + a.name;
}
const d: Dog = { name: "Rex", breed: "Labrador" };
console.log(greet(d)); // OK — Dog is structurally a subtype of Animal
// Function subtyping: (Animal -> void) <: (Dog -> void)
// because Dog <: Animal (contravariant in input)
type DogHandler = (d: Dog) => void;
type AnimalHandler = (a: Animal) => void;
const handleAnimal: AnimalHandler = (a) => console.log(a.name);
// Safe: handleAnimal can handle Dogs (contravariance)
const h: DogHandler = handleAnimal; // TypeScript allows this
05Variance: Covariance, Contravariance, and Invariance
Variance describes how subtyping relationships between type arguments relate to subtyping relationships between generic types. If Dog <: Animal, what is the relationship between List<Dog> and List<Animal>?
There are three possibilities:
- Covariant:
List<Dog> <: List<Animal>. The subtyping goes in the same direction as the type argument. Read-only containers are safely covariant: if you can only read from aList<Dog>, you can safely treat it as aList<Animal>. - Contravariant:
List<Animal> <: List<Dog>. The subtyping goes in the opposite direction. Write-only containers are safely contravariant: a function that can consume anyAnimalcan certainly consume aDog. - Invariant: neither is a subtype of the other. Mutable containers must be invariant: if
List<Dog> <: List<Animal>, you could insert aCatinto what is actually aList<Dog>, corrupting it.
The rule of thumb: type parameters in output position (return types, readable fields) should be covariant; type parameters in input position (function arguments, writable fields) should be contravariant; type parameters in both positions (mutable containers) must be invariant.
In Scala: you declare variance explicitly. class Producer[+A] is covariant (only produces A), class Consumer[-A] is contravariant (only consumes A). The compiler checks that you use A only in the declared positions.
In Java: generics are invariant by default, but you can use wildcards: List<? extends Animal> is a covariant read-only view; List<? super Dog> is a contravariant write-only view. This is the "PECS" rule (Producer Extends, Consumer Super).
In Rust: variance is inferred automatically from how the type parameter is used. Vec<T> is covariant in T because you can only read values out (not write arbitrary values back through a shared reference).
In Haskell: the Functor typeclass requires fmap :: (a -> b) -> f a -> f b, which means f is covariant in its argument. Contravariant (for (b -> a) -> f a -> f b) and Profunctor (covariant in one arg, contravariant in another) formalize this.
Variance is one of the most practically important type-theory concepts — it explains a whole class of surprising type errors and is the key to understanding generics in any language.
-- Scala: explicit variance annotations
class Producer[+A](val value: A) -- covariant: only produces A
class Consumer[-A] { -- contravariant: only consumes A
def consume(a: A): Unit = println(a)
}
class Container[A](var value: A) -- invariant: both reads and writes
-- Subtyping consequences:
-- Producer[Dog] <: Producer[Animal] (covariant, same direction)
-- Consumer[Animal] <: Consumer[Dog] (contravariant, flipped)
-- Container[Dog] is NOT related to Container[Animal]
-- Java wildcards (PECS):
-- void readAll(List<? extends Animal> list) { ... } // covariant
-- void addDog(List<? super Dog> list) { list.add(new Dog()); } // contravariant
06Typeclasses and Ad-hoc Polymorphism
Ad-hoc polymorphism (also called overloading) allows the same function name to have different implementations depending on the type it is called with. show 42 and show True both call show, but with completely different implementations. This is distinct from parametric polymorphism, where the same code runs for all types.
Typeclasses (Haskell) are the principled, type-safe way to achieve ad-hoc polymorphism. A typeclass is a contract — a set of function signatures that a type must implement. An instance is the evidence that a particular type satisfies that contract.
class Eq a where
(==) :: a -> a -> Bool
instance Eq Int where
x == y = ... -- primitive comparison
instance Eq a => Eq [a] where
[] == [] = True
(x:xs) == (y:ys) = x == y && xs == ys
_ == _ = False
The instance Eq a => Eq [a] says: "if you can compare elements of type a, I can compare lists of a". This is instance resolution — the compiler automatically constructs the right implementation from the available instances.
Superclasses: Ord requires Eq as a superclass — you cannot order things you cannot compare. This creates a typeclass hierarchy.
Rust traits are essentially typeclasses. impl Display for MyType { ... } is an instance declaration. Rust's orphan rules prevent you from implementing a foreign trait for a foreign type, avoiding ambiguity.
Theoretical foundation: typeclasses are dictionary-passing under the hood. Eq a => becomes an extra function argument — a dictionary — containing the implementations of == for type a. The compiler inserts these dictionary arguments automatically. This is why typeclasses compose so cleanly.
Typeclass laws: good typeclasses come with laws that instances must satisfy. Eq must be an equivalence relation (reflexive, symmetric, transitive). Functor must satisfy fmap id = id and fmap (f . g) = fmap f . fmap g. These laws enable equational reasoning about programs.
-- Haskell: defining and using a typeclass
class Container f where
empty :: f a
insert :: a -> f a -> f a
toList :: f a -> [a]
data Stack a = Stack [a]
instance Container Stack where
empty = Stack []
insert x (Stack xs) = Stack (x:xs)
toList (Stack xs) = xs
-- Generic function works for ANY Container
fillWith :: Container f => [a] -> f a
fillWith = foldr insert empty
-- The compiler picks the right 'insert'/'empty' at each call site
example :: Stack Int
example = fillWith [1,2,3] -- Stack [3,2,1]
07Dependent Types
In a simply-typed language, types and values live in separate universes — types classify values, but values cannot appear in types. Dependent types break this wall: types can depend on values. This lets you express invariants that ordinary type systems cannot capture.
The canonical example is Vec n a — a vector of exactly n elements of type a. The type carries the length as a value. The type of append :: Vec m a -> Vec n a -> Vec (m+n) a tells you the exact length of the result. A bounds-checked get :: Vec n a -> Fin n -> a uses Fin n (a type with exactly n values: 0, 1, ..., n-1) to make out-of-bounds access unrepresentable.
Dependent products (Σ-types): (x : A, B x) — a pair where the type of the second component depends on the value of the first. Example: (n : Nat, Vec n Int) — a vector together with its length. The type guarantees they are consistent.
Dependent functions (Π-types): (x : A) -> B x — a function whose return type depends on the argument value. Example: replicate :: (n : Nat) -> a -> Vec n a — given n, produces a vector of exactly that length.
Total functions: in a dependently-typed language like Agda or Idris, all functions must be total (defined on all inputs and provably terminating). This is necessary for consistency — a non-terminating function could "prove" False.
Practical dependent types: Full dependent types (Agda, Coq, Lean, Idris) are used for formal verification of software and mathematics. Lighter forms appear in TypeScript (literal types, template literal types), Rust (const generics: [T; N]), and Liquid Haskell (refinement types). These give you much of the safety benefit without the full complexity of dependent type theory.
The cost: dependent types make type checking undecidable in general (since it requires checking program equivalence). Agda and Coq restrict the language (no general recursion) to regain decidability.
-- Agda-style pseudocode (illustrative)
data Nat : Set where
zero : Nat
suc : Nat -> Nat
data Vec (A : Set) : Nat -> Set where
[] : Vec A zero
_::_ : A -> Vec A n -> Vec A (suc n)
-- Type of 'head' guarantees non-empty input
head : Vec A (suc n) -> A
head (x :: _) = x
-- The case '[]' is IMPOSSIBLE to write -- the type forbids it!
-- append: type tracks exact lengths
append : Vec A m -> Vec A n -> Vec A (m + n)
append [] ys = ys
append (x :: xs) ys = x :: append xs ys
-- Rust const generics (a practical approximation):
fn zip<T, U, const N: usize>(a: [T; N], b: [U; N]) -> [(T, U); N] {
// N is a VALUE in the TYPE -- this is limited dependent typing!
std::array::from_fn(|i| (a[i].clone(), b[i].clone()))
}
08The Curry-Howard Correspondence
The Curry-Howard correspondence (also called the Curry-Howard isomorphism, or the propositions-as-types principle) is one of the deepest ideas in computer science: types correspond to logical propositions, and programs correspond to proofs.
The correspondence is not a metaphor — it is a precise, formal bijection:
| Logic | Type Theory | Programming |
|---|---|---|
| Proposition P | Type P | A type |
| Proof of P | Term of type P | A value / program |
| P ∧ Q (and) | Product type (P, Q) | A pair |
| P ∨ Q (or) | Sum type Either P Q | A tagged union |
| P → Q (implies) | Function type P -> Q | A function |
| ⊥ (false) | Empty type Void | Uninhabited type |
| ⊤ (true) | Unit type () | The trivial type |
| ∀x. P(x) | Dependent product (x:A) -> P x | A polymorphic function |
| ∃x. P(x) | Dependent sum (x:A, P x) | An existential type / pair |
Concrete example: The proposition "if P and Q, then P" has the proof λ(p,q). p. As a type, (P, Q) -> P. As a program, fst :: (a, b) -> a. The same object is simultaneously a tautology, a type signature, and a function.
The proof of a function: when you write fmap :: (a -> b) -> Maybe a -> Maybe b and implement it correctly, you have proved the logical statement: "for all propositions a and b, if a implies b, then (Maybe a implies Maybe b)" — i.e., Maybe is a covariant functor.
Proof assistants: Coq, Lean, Agda, and Isabelle are programming languages where programs are proofs. The Four Colour Theorem was formally verified in Coq. The CompCert C compiler is proved correct in Coq — its assembly output is proved equivalent to the C source.
The gotcha — consistency: the correspondence only works if the type system is consistent as a logic — i.e., there is no proof of Void. This requires the language to be total (no infinite loops). Haskell's undefined :: a is a value of every type, making Haskell's logic inconsistent (but still useful for programming). Agda enforces totality.
Curry-Howard is the philosophical foundation explaining why type-driven development — writing the type first and letting the type guide the implementation — actually works. The type is the theorem; the implementation is the proof.
-- Haskell: propositions as types, proofs as programs
-- Tautology: P -> Q -> P ("if P and Q, then P")
const_proof :: p -> q -> p
const_proof x _ = x
-- This IS the proof of the tautology, not just a program that "encodes" it.
-- Conjunction elimination: (P, Q) -> P
and_elim_left :: (p, q) -> p
and_elim_left (x, _) = x
-- Modus ponens: (P -> Q, P) -> Q
modus_ponens :: (p -> q, p) -> q
modus_ponens (f, x) = f x
-- Disjunction introduction: P -> Either P Q
or_intro_left :: p -> Either p q
or_intro_left = Left
-- Ex falso quodlibet: Void -> a (false implies anything)
ex_falso :: Void -> a
ex_falso v = absurd v -- absurd :: Void -> a (no cases to match!)
-- Note: in Agda/Lean/Coq these proofs are checked for TOTALITY,
-- making the logic consistent. In Haskell, 'undefined' breaks consistency.
Key insights
- ✦ADTs are not just a data modeling convenience — they are the only way to make illegal states *unrepresentable* at compile time, eliminating entire categories of bugs by construction.
- ✦Parametricity gives you 'theorems for free': the more polymorphic a function's type, the fewer implementations it can possibly have, and you can often prove its correctness from the type signature alone.
- ✦Variance is the answer to 'why can't I use a List<Dog> as a List<Animal>?' — mutable containers must be invariant because writing breaks the substitution principle, while read-only producers are safely covariant and write-only consumers are safely contravariant.
- ✦The Liskov Substitution Principle is *stronger* than just 'has the same methods' — it requires behavioral compatibility, including pre/postconditions, and most OOP code violates it in subtle ways.
- ✦Typeclasses/traits are dictionary-passing in disguise: the compiler constructs and threads implicit evidence arguments, which is why they compose so cleanly and can be inferred automatically.
- ✦Dependent types collapse the distinction between 'type-level' and 'value-level' reasoning — the type of `append` literally encodes the claim that the output length equals the sum of input lengths.
- ✦The Curry-Howard correspondence is not a metaphor: a function implementation IS a constructive proof of its type-as-proposition, and a dependently-typed program is a formal mathematical certificate.
- ✦Pattern matching exhaustiveness checking is the compiler enforcing the *totality* condition on the eliminator of a sum type — a non-exhaustive match is a partial function, which is a logical axiom in disguise.
Common pitfalls
- ⚠Confusing subtyping with inheritance: a subtype must satisfy the behavioral contract (LSP), not just the structural interface. Java's covariant arrays are a famous example of a type-safe-looking but LSP-violating design that causes runtime exceptions.
- ⚠Getting variance wrong on mutable containers: assuming that List<Dog> is a subtype of List<Animal> because Dog is a subtype of Animal — this is the classic covariant mutable container mistake that Java arrays made, and that every language's generics documentation warns about.
- ⚠Treating typeclasses/traits as just 'interfaces': failing to notice that typeclass instances are *resolved* by the compiler based on type, not dispatched at runtime on an object — this changes performance characteristics and means you cannot have two instances of the same typeclass for the same type in scope.
- ⚠Forgetting that pattern matches in Haskell (and some other languages) are lazy by default — a seemingly exhaustive match can silently fail to force evaluation, causing surprises with bottom values.
- ⚠Believing that dependent types require a proof assistant: modern languages offer lighter forms (Rust const generics, TypeScript literal types, Liquid Haskell refinements) that give much of the safety benefit without full dependent type complexity.
- ⚠Misunderstanding the Curry-Howard correspondence as purely theoretical: the practical consequence is that writing the type *first* (type-driven development) genuinely constrains and guides the implementation, and a well-typed program in a total language is a machine-checked proof.
Practice
- 01medium
Implement a type-safe expression evaluator. Define an ADT for a simple language with integers, booleans, addition, equality comparison, and if-then-else. Then write an *interpreter* that evaluates it, and a *type-checker* that rejects ill-typed expressions (like `1 + True`) before evaluation. Use a second ADT for the type (IntTy | BoolTy) and write `typecheck :: Expr -> Maybe Ty`.
- 02easy
Prove that variance is necessary for safety. In Java (or any language with covariant mutable arrays), write a short program that compiles without warnings but throws an ArrayStoreException at runtime. Explain exactly which variance rule is violated, and show how you would fix the API design to prevent this at compile time.
- 03hard
Implement a type-safe heterogeneous list (HList) in Haskell or a language with type-level programming. An HList is a list where each element can have a different type, and the type of the whole list encodes the types of all elements. Implement `hhead :: HList (a : rest) -> a`, `htail :: HList (a : rest) -> HList rest`, and `happend` with the correct type signature.
- 04medium
Write out the Curry-Howard correspondence explicitly for five logical tautologies. For each tautology, (a) state it in propositional logic, (b) write its Haskell type signature, (c) implement the function. Tautologies to use: (1) P → (Q → P), (2) (P → Q) → (Q → R) → (P → R), (3) (P ∧ Q) → (Q ∧ P), (4) P → (P ∨ Q), (5) (P → R) → (Q → R) → (P ∨ Q) → R.
- 05hard
Design a simple dependently-typed stack API in Rust using const generics. Implement `Stack<T, const N: usize>` with `push` (returns `Stack<T, N+1>`), `pop` (returns `(T, Stack<T, N-1>)`, callable only when N > 0), and `peek` (callable only when N > 0). How does this compare to a runtime-checked stack? What invariants does the type system enforce?
Resources
The single best reference for this entire module. Chapters 11-16 cover subtyping and variance; Part III covers polymorphism.
Concrete and readable. Covers ADTs, pattern matching, and typeclasses with excellent worked examples.
Stanford's graduate PL course. Covers type theory, subtyping, and advanced type features with lecture notes available.
The definitive accessible essay on Curry-Howard by one of Haskell's creators. Beautifully written history and explanation.
The original 1989 paper on parametricity and free theorems. Shows how to derive theorems from type signatures alone.
Free, community-maintained, thorough coverage of ADTs and typeclasses in Haskell with many examples.
Official Agda docs — the best hands-on introduction to dependent types in a practical language.
Deep categorical and type-theoretic foundations. Chapter 2 covers dependent types and Curry-Howard rigorously. Free online.
Official Rust reference. Rust's trait system and generics are among the best practical implementations of bounded parametric polymorphism.
MIT OCW course covering type systems, subtyping, and type inference in depth.
Checkpoints — you can now…
- ✓You can define an ADT for any problem domain (e.g., a card game, a file system, an AST) with appropriate product and sum types, and explain why the illegal states are unrepresentable.
- ✓You can write exhaustive pattern matches in at least one language (Haskell, Rust, or Scala), explain what exhaustiveness means, and describe what happens when it is violated.
- ✓You can explain the difference between parametric polymorphism and ad-hoc polymorphism, give a concrete example of each, and explain why parametric polymorphism enables 'theorems for free'.
- ✓You can state whether a generic type is covariant, contravariant, or invariant in each of its type parameters, justify your answer in terms of read/write positions, and give a concrete example of what goes wrong if variance is incorrect.
- ✓You can explain the Liskov Substitution Principle in behavioral (not just structural) terms and identify at least one real-world API that violates it.
- ✓You can state the Curry-Howard correspondence table from memory, translate at least three logical tautologies into Haskell type signatures, and implement the corresponding functions.
- ✓You can describe what a dependent type is, give two examples (Vec n a and Fin n), and explain what invariants they enforce that ordinary generics cannot.