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):

\[\frac{x : \tau \in \Gamma}{\Gamma \vdash x : \tau} \qquad \frac{\Gamma, x : \tau_1 \vdash e : \tau_2}{\Gamma \vdash \lambda x{:}\tau_1.\, e \;:\; \tau_1 \to \tau_2} \qquad \frac{\Gamma \vdash e_1 : \tau_1 \to \tau_2 \quad \Gamma \vdash e_2 : \tau_1}{\Gamma \vdash e_1\ e_2 : \tau_2}\]
\[\frac{\Gamma \vdash e_1 : \mathtt{bool} \quad \Gamma \vdash e_2 : \tau \quad \Gamma \vdash e_3 : \tau}{\Gamma \vdash \mathtt{if}\ e_1\ \mathtt{then}\ e_2\ \mathtt{else}\ e_3 : \tau} \qquad \frac{\Gamma \vdash e_1 : \mathtt{int} \quad \Gamma \vdash e_2 : \mathtt{int}}{\Gamma \vdash e_1 + e_2 : \mathtt{int}}\]

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, built rule by rule, with a printed derivation
# 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}")
Output
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 int
A typing derivation tree for (λx:int. x + 1) 2. At the bottom, the conclusion "⊢ (λx:int. x + 1) 2 : int" uses the application rule. Its left premise is "⊢ λx:int. x + 1 : int → int", derived by the abstraction rule from "x:int ⊢ x + 1 : int", which in turn uses the addition rule with leaves "x:int ⊢ x : int" and "x:int ⊢ 1 : int". The right premise is "⊢ 2 : int".
A typing derivation: each node is a judgement, justified by the rule for its expression form

Soundness: 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.

Testing progress and preservation on 3,000 random well-typed terms
# 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}")
Output
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.

Rust checks pattern matches for exhaustiveness at compile time (real rustc output)
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)));
}
Output
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.

mypy rejects using an Optional value without checking for None, and accepts the narrowed version
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
Output
$ 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.

Java's covariant arrays type-check statically but need a run-time check (real JVM output)
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]);
    }
}
Output
run-time check caught it: java.lang.ArrayStoreException: java.lang.Integer
names[0] is still Ada
TypeScript accepts this program because of any, and at run time the "number" result is a string (tsc and Node output)
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
Output
$ 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
  1. Context after the two lambdas: f : int → bool, x : int.
  2. f x: f has type int → bool and x has type int, so f x : bool, as the condition requires.
  3. Then-branch x : int. Else-branch x + 1 : int, since both operands are int.
  4. 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
  1. Suppose x : τ. For x x to be well typed, x must be a function, so τ = τ1 → τ2.
  2. Its argument is x itself, of type τ, so the parameter type must match: τ1 = τ.
  3. Then τ = τ → τ2, an equation in which τ appears inside itself.
  4. 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
  1. Java treats String[] as a subtype of Object[] (array covariance), so the assignment o = s type-checks.
  2. The static type of o[0] is Object, so storing an Integer type-checks too.
  3. 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.

  1. 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.

  2. What is the type of λx:bool. λy:int. y?

    Show answer

    bool → int → int.

  3. 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.

  4. 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.

  5. 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.

  6. 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.

  7. In the Rust example, what did the compiler report?

    Show answer

    A non-exhaustive match error, because the Triangle case was not handled.

  8. 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.

  9. 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.

  10. 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.

Take the quiz