CMPUT-325 · Lesson 7 of 12 · 34 min
Types I: type systems and type checking
A type system is a set of rules that assigns a type to each part of a program and rejects programs that break the rules. Types classify values (this is an integer, that is a function from strings to Booleans), document intent, guide compilers to efficient code and, above all, catch errors before they happen. Robin Milner summarized the goal in 1978: 'well-typed programs cannot go wrong.' This lesson makes that slogan precise. We write typing rules as inference rules, implement a type checker for the simply typed lambda calculus, state the soundness theorem and test it, and then look at where real languages deliberately or accidentally fall short of soundness.
CMPUT-274 lesson 7 introduced static and dynamic typing, strong and weak typing, nominal and structural typing, generics and variance from a programmer's point of view. Here we take the formal view: what exactly a typing rule says, how a checker is built from the rules, and what it means to prove that a type system works. Lesson 8 adds polymorphism and type inference.
Typing judgements and rules
A typing judgement Γ ⊢ e : τ reads 'in context Γ, expression e has type τ'. The context Γ records the types of the variables in scope, much like the environment of lesson 4 records their values. Typing rules are inference rules in the style of lesson 3, but instead of describing how a program runs they describe which programs are acceptable. Here are the rules for variables, functions and application in the simply typed lambda calculus (STLC):
The function rule says: to check λx:τ1.e, add x : τ1 to the context and check the body; if the body has type τ2, the function has type τ1 → τ2. The application rule says the argument's type must match the function's parameter type. The conditional rule requires both branches to have the same type, which is why 'if b then 1 else true' is rejected even though only one branch will run. These rules are syntax-directed: for each form of expression there is exactly one rule, so a checker can simply recurse over the abstract syntax tree.
# A type checker for the simply typed lambda calculus with integers, Booleans,
# conditionals, let and pairs. Types: "int" | "bool" | ("->", t1, t2) | ("*", t1, t2)
# e ::= n | True | False | x | ("+", e, e) | ("<", e, e) | ("if", e, e, e)
# | ("lam", x, t, e) | ("app", e, e) | ("let", x, e, e) | ("pair", e, e) | ("fst", e) | ("snd", e)
def show(t):
if isinstance(t, str): return t
if t[0] == "->": return f"({show(t[1])} -> {show(t[2])})"
return f"({show(t[1])} * {show(t[2])})"
class TypeError_(Exception): pass
def check(env, e, depth=0, trace=None):
def note(t):
if trace is not None: trace.append(" " * depth + f"⊢ {brief(e)} : {show(t)}")
return t
if isinstance(e, bool): return note("bool")
if isinstance(e, int): return note("int")
if isinstance(e, str):
if e not in env: raise TypeError_(f"unbound variable {e}")
return note(env[e])
tag = e[0]
sub = lambda env2, x: check(env2, x, depth + 1, trace)
if tag in ("+", "<"):
for side in (e[1], e[2]):
if sub(env, side) != "int": raise TypeError_(f"operand of {tag} must be int")
return note("int" if tag == "+" else "bool")
if tag == "if":
if sub(env, e[1]) != "bool": raise TypeError_("condition must be bool")
t1, t2 = sub(env, e[2]), sub(env, e[3])
if t1 != t2: raise TypeError_(f"branches differ: {show(t1)} vs {show(t2)}")
return note(t1)
if tag == "lam":
_, x, tx, body = e
return note(("->", tx, sub({**env, x: tx}, body)))
if tag == "app":
tf, ta = sub(env, e[1]), sub(env, e[2])
if not (isinstance(tf, tuple) and tf[0] == "->"): raise TypeError_(f"applying a non-function of type {show(tf)}")
if tf[1] != ta: raise TypeError_(f"argument has type {show(ta)}, expected {show(tf[1])}")
return note(tf[2])
if tag == "let":
return note(sub({**env, e[1]: sub(env, e[2])}, e[3]))
if tag == "pair": return note(("*", sub(env, e[1]), sub(env, e[2])))
if tag in ("fst", "snd"):
t = sub(env, e[1])
if not (isinstance(t, tuple) and t[0] == "*"): raise TypeError_("fst/snd of a non-pair")
return note(t[1] if tag == "fst" else t[2])
def brief(e):
s = str(e).replace("'", "")
return s if len(s) <= 34 else s[:31] + "..."
twice = ("lam", "f", ("->", "int", "int"), ("lam", "x", "int", ("app", "f", ("app", "f", "x"))))
inc = ("lam", "n", "int", ("+", "n", 1))
prog = ("app", ("app", twice, inc), 5)
tr = []
print("type:", show(check({}, prog, trace=tr)))
print("derivation (premises indented under conclusions, conclusions printed last):")
print("\n".join(tr[-6:]))
for bad in [("if", 1, 2, 3), ("app", inc, True), ("+", 1, ("lam", "x", "int", "x")), ("app", 5, 5)]:
try:
check({}, bad)
except TypeError_ as err:
print(f"rejected {brief(bad):30} -> {err}")type: int
derivation (premises indented under conclusions, conclusions printed last):
⊢ 1 : int
⊢ (+, n, 1) : int
⊢ (lam, n, int, (+, n, 1)) : (int -> int)
⊢ (app, (lam, f, (->, int, int), ... : (int -> int)
⊢ 5 : int
⊢ (app, (app, (lam, f, (->, int, ... : int
rejected (if, 1, 2, 3) -> condition must be bool
rejected (app, (lam, n, int, (+, n, 1)),... -> argument has type bool, expected int
rejected (+, 1, (lam, x, int, x)) -> operand of + must be int
rejected (app, 5, 5) -> applying a non-function of type intSoundness: progress and preservation
What does it mean for a type system to work? The standard answer, due to Andrew Wright and Matthias Felleisen in 1994, is type soundness, proved as two lemmas about the small-step semantics of lesson 3. Progress: a well-typed closed expression is either a value or can take a step; it is never stuck. Preservation (also called subject reduction): if a well-typed expression takes a step, the result has the same type. Together they imply that evaluation of a well-typed program never reaches a stuck state such as 'add 1 to a function' or 'branch on an integer', and if it produces a value, the value has the predicted type.
The proofs are by induction on typing derivations, and they are routine but long, which is why researchers check them with proof assistants. The program below does the next best thing for our toy language: it generates thousands of random well-typed terms, runs them with a small-step evaluator that raises an error on any stuck state, and checks the type of every result. Property-based testing of this kind, popularized by the QuickCheck library for Haskell, is a practical way to find soundness bugs before attempting a proof.
# Type soundness, tested: generate random well-typed closed terms, evaluate them with a
# small-step evaluator, and confirm none gets stuck and every result has the predicted type.
import random
rnd = random.Random(11)
def gen(t, env, d):
"""A random term of type t ("int" or "bool") using variables in env."""
choices = [v for v, tv in env.items() if tv == t]
if d == 0 or rnd.random() < 0.25:
if choices and rnd.random() < 0.5: return rnd.choice(choices)
return rnd.randint(0, 9) if t == "int" else rnd.choice([True, False])
k = rnd.random()
if t == "int" and k < 0.4: return ("+", gen("int", env, d - 1), gen("int", env, d - 1))
if t == "bool" and k < 0.4: return ("<", gen("int", env, d - 1), gen("int", env, d - 1))
if k < 0.7: return ("if", gen("bool", env, d - 1), gen(t, env, d - 1), gen(t, env, d - 1))
x = f"v{d}{rnd.randint(0, 99)}"; tx = rnd.choice(["int", "bool"])
return ("app", ("lam", x, tx, gen(t, {**env, x: tx}, d - 1)), gen(tx, env, d - 1))
def is_value(e): return isinstance(e, (int, bool)) and not isinstance(e, str) or (isinstance(e, tuple) and e[0] == "lam")
def subst(e, x, v):
if isinstance(e, str): return v if e == x else e
if not isinstance(e, tuple): return e
if e[0] == "lam": return e if e[1] == x else ("lam", e[1], e[2], subst(e[3], x, v))
return (e[0],) + tuple(subst(a, x, v) for a in e[1:])
def step(e):
tag = e[0]
if tag in ("+", "<"):
if not is_value(e[1]): return (tag, step(e[1]), e[2])
if not is_value(e[2]): return (tag, e[1], step(e[2]))
return e[1] + e[2] if tag == "+" else e[1] < e[2]
if tag == "if":
if not is_value(e[1]): return ("if", step(e[1]), e[2], e[3])
return e[2] if e[1] is True else e[3]
if tag == "app":
if not is_value(e[1]): return ("app", step(e[1]), e[2])
if not is_value(e[2]): return ("app", e[1], step(e[2]))
return subst(e[1][3], e[1][1], e[2])
raise RuntimeError(f"stuck: {e}") # progress says this never happens for well-typed terms
stuck = wrong_type = steps = 0
for i in range(3000):
t = rnd.choice(["int", "bool"]); e = gen(t, {}, 5)
try:
while not is_value(e):
e = step(e); steps += 1
if (t == "bool") != isinstance(e, bool): wrong_type += 1
except RuntimeError:
stuck += 1
print(f"3000 random well-typed terms, {steps} steps in total")
print(f"stuck terms: {stuck} results of the wrong type: {wrong_type}")3000 random well-typed terms, 29430 steps in total stuck terms: 0 results of the wrong type: 0
STLC has one more remarkable property: strong normalization. Every well-typed term terminates. The self-application in Ω = (λx.x x)(λx.x x) cannot be typed, because x would need a type τ equal to τ → σ, which no finite type is. The flip side is that STLC cannot express general recursion, so real typed languages add a fixed-point operator or recursive definitions as a primitive, giving up termination in exchange for Turing-completeness. Languages designed for proofs, such as Agda, Coq and Lean, keep termination by checking that every recursive call is on a smaller argument.
Product, sum and algebraic data types
Real type systems add ways to build data. A product type τ1 × τ2 holds a value of each type, like a tuple or a record. A sum type τ1 + τ2 holds a value of one type or the other, tagged with which; it is the type-theoretic version of an enum with data, a tagged union or a variant. Algebraic data types combine sums and products with recursion, as in Rust's enum, Swift's enum with associated values, Haskell's data and OCaml's type. Pattern matching takes them apart, and a type checker can verify exhaustiveness: that every case is handled. Rust refuses to compile a match that forgets a case.
enum Shape { Circle(f64), Square(f64), Triangle(f64, f64) }
fn area(s: &Shape) -> f64 {
// the Triangle case has been forgotten
match s { Shape::Circle(r) => 3.14159 * r * r, Shape::Square(a) => a * a }
}
fn main() {
println!("{}", area(&Shape::Square(2.0)));
}error[E0004]: non-exhaustive patterns: `&Shape::Triangle(_, _)` not covered
--> main.rs:5:11
|
5 | match s { Shape::Circle(r) => 3.14159 * r * r, Shape::Square(a) => a * a }
| ^ pattern `&Shape::Triangle(_, _)` not covered
|
note: `Shape` defined here
--> main.rs:1:6
|
1 | enum Shape { Circle(f64), Square(f64), Triangle(f64, f64) }
| ^^^^^ -------- not covered
= note: the matched value is of type `&Shape`
help: ensure that all possible cases are being handled by adding a match arm with a wildcard pattern or an explicit pattern as shown
|
5 | match s { Shape::Circle(r) => 3.14159 * r * r, Shape::Square(a) => a * a, &Shape::Triangle(_, _) => todo!() }
| +++++++++++++++++++++++++++++++++++
error: aborting due to 1 previous error
For more information about this error, try `rustc --explain E0004`.Optional values instead of null
Tony Hoare, who introduced null references in ALGOL W in 1965, later called them his 'billion-dollar mistake', because every reference might be null and the type system says nothing about it. Sum types fix this: an Option or Maybe type has two cases, Some(value) or None, and the type checker forces the programmer to handle both before using the value. Rust, Swift, Kotlin, Haskell and Scala do this, and TypeScript and Python type checkers achieve the same effect with union types and flow-sensitive narrowing, as mypy shows below.
from typing import Optional
def find_user(uid: int) -> Optional[str]:
return {1: "ada", 2: "grace"}.get(uid)
def greeting(uid: int) -> str:
name = find_user(uid)
return "Hello, " + name.title() # name might be None
def greeting_fixed(uid: int) -> str:
name = find_user(uid)
if name is None:
return "Hello, stranger"
return "Hello, " + name.title() # narrowed to str here$ mypy --strict users.py users.py:8: error: Item "None" of "str | None" has no attribute "title" [union-attr]
Where real type systems fall short
Few mainstream languages are fully sound, and the gaps are instructive. Some are deliberate compromises. Java made arrays covariant in 1995, before it had generics, so that a method taking Object[] could sort any array; the price is a run-time check on every array store, and a program that type-checks can still fail with ArrayStoreException. C and C++ casts let a programmer tell the compiler to treat any memory as any type. TypeScript chose 'pragmatic unsoundness': its any type turns checking off, and several rules are relaxed so that existing JavaScript code type-checks. Gradual typing systems such as TypeScript and Python's type hints do not check types at run time at all, so a value from outside the program can violate its annotation.
public class Covariance {
public static void main(String[] args) {
String[] names = {"Ada", "Grace"};
Object[] things = names; // allowed: arrays are covariant in Java
try {
things[0] = Integer.valueOf(42); // type checks statically...
} catch (ArrayStoreException e) {
System.out.println("run-time check caught it: " + e);
}
System.out.println("names[0] is still " + names[0]);
}
}run-time check caught it: java.lang.ArrayStoreException: java.lang.Integer names[0] is still Ada
function total(xs: number[]): number {
return xs.reduce((a, b) => a + b, 0);
}
const fromNetwork: any = JSON.parse('["1", "2", "3"]'); // really strings
console.log(total(fromNetwork)); // compiles; prints a string$ tsc --strict --target es2020 total.ts $ node total.js 0123
Unsoundness is not always accidental, and soundness is not always worth its cost. But it is important to know which guarantee a type system actually gives. When a Rust program compiles without unsafe blocks, memory safety and the absence of data races follow from the type system. When a TypeScript program compiles, it means the program is consistent with its annotations as far as the checker could see, which is valuable for tooling and refactoring but is a weaker promise. Reading a language's type system with soundness in mind tells you which bugs it can rule out.
Worked examples
Worked problem
Type-checking by hand
Find the type of λf:int→bool. λx:int. if f x then x else x + 1, or explain why it fails.
Show solution
- Context after the two lambdas: f : int → bool, x : int.
- f x: f has type int → bool and x has type int, so f x : bool, as the condition requires.
- Then-branch x : int. Else-branch x + 1 : int, since both operands are int.
- Both branches have type int, so the if has type int, and the whole term has type (int → bool) → int → int.
Answer: (int → bool) → int → int.
Worked problem
Why Ω is untypable
Show that λx.x x has no type in STLC.
Show solution
- Suppose x : τ. For x x to be well typed, x must be a function, so τ = τ1 → τ2.
- Its argument is x itself, of type τ, so the parameter type must match: τ1 = τ.
- Then τ = τ → τ2, an equation in which τ appears inside itself.
- No finite type satisfies such an equation, so no typing derivation exists.
Answer: Self-application would need a type equal to a function type containing itself, which STLC does not have, so λx.x x and Ω are rejected.
Worked problem
Spotting an unsoundness
In Java, String[] s = new String[1]; Object[] o = s; o[0] = 1; compiles. What happens, and which design choice causes it?
Show solution
- Java treats String[] as a subtype of Object[] (array covariance), so the assignment o = s type-checks.
- The static type of o[0] is Object, so storing an Integer type-checks too.
- At run time the JVM checks each array store against the array's real element type and throws ArrayStoreException.
Answer: The store fails at run time with ArrayStoreException; covariant arrays are a deliberate gap patched by a run-time check.
Common mistakes
MistakeStatic typing means every possible error is caught before running.
InsteadType systems catch specific classes of errors; division by zero, out-of-bounds indexes and logic errors usually remain.
MistakeIf a TypeScript or mypy program type-checks, values always match their annotations at run time.
InsteadGradual type systems are unsound by design and do not check annotations at run time.
MistakeSoundness means the type checker accepts every correct program.
InsteadSoundness means accepted programs do not go wrong; sound checkers usually reject some correct programs too.
MistakeProgress alone proves type safety.
InsteadSafety needs both progress and preservation; preservation keeps every intermediate expression well typed.
MistakeNull is just another value of every reference type.
InsteadTreating null as a member of every type is the source of null-pointer errors; Option or Maybe types make absence explicit.
Summary and key terms
- A typing judgement Γ ⊢ e : τ assigns types in a context; typing rules are inference rules.
- Syntax-directed rules turn directly into a recursive type checker.
- Type soundness is proved as progress (never stuck) plus preservation (types are kept by steps).
- The simply typed lambda calculus is strongly normalizing, so recursion needs an added primitive.
- Product, sum and algebraic data types build data; exhaustiveness checking catches missing cases.
- Option types make absence explicit instead of using null.
- Many real languages are unsound in places, by design or for compatibility; know which guarantees a language gives.
- Type system
- Rules assigning types to program phrases and rejecting ill-typed programs.
- Typing judgement
- A statement Γ ⊢ e : τ that e has type τ in context Γ.
- Typing context
- A mapping from variables in scope to their types.
- Syntax-directed
- Having exactly one typing rule for each form of expression.
- Progress
- The property that a well-typed closed term is a value or can take a step.
- Preservation
- The property that evaluation steps preserve a term's type.
- Type soundness
- The guarantee that well-typed programs do not reach stuck or erroneous states.
- Sum type
- A type whose values are one of several tagged alternatives.
- Exhaustiveness checking
- Verifying that a pattern match handles every case of a type.
- Gradual typing
- A type system mixing statically checked and unchecked code, usually without run-time enforcement.
Practice problems
Work each problem on paper first, then reveal the answer to check.
Read the judgement x:int, f:int→int ⊢ f x : int aloud.
Show answer
In a context where x is an int and f is a function from int to int, f x has type int.
What is the type of λx:bool. λy:int. y?
Show answer
bool → int → int.
Why is if true then 1 else false rejected by STLC?
Show answer
The branches have different types (int and bool), and the conditional rule requires them to match.
State progress and preservation.
Show answer
Progress: a well-typed closed term is a value or can step. Preservation: if a well-typed term steps, the result has the same type.
Why can STLC not express a general recursive function?
Show answer
Every well-typed term terminates (strong normalization), so non-terminating recursion is impossible; a fixed-point primitive must be added.
What is a sum type, and which construct eliminates it?
Show answer
A type of tagged alternatives; it is eliminated by case analysis or pattern matching.
In the Rust example, what did the compiler report?
Show answer
A non-exhaustive match error, because the Triangle case was not handled.
How did mypy accept greeting_fixed but reject greeting?
Show answer
In greeting_fixed the None check narrows name to str; in greeting name may be None when .title() is called.
Why does Java need a run-time check on array stores?
Show answer
Arrays are covariant, so the static type of an array variable may be more general than its real element type.
Name one language that is sound for safe code and one that is unsound by design.
Show answer
Sound: Standard ML or safe Rust. Unsound by design: TypeScript.
Lesson quiz · 5 questions
Score 60% or more to complete this lesson.
Create a free account to save your progress, take quizzes and earn a certificate.