After parsing proves a program is well-formed, semantic analysis proves it is well-meaning — resolving every name to a declaration, enforcing scope, and assigning each expression a type. We build symbol tables, write typing rules as judgements, and reconstruct types from scratch with Hindley–Milner and unification
The parser handed us a tree that is syntactically legal. This deck makes it meaningful: every identifier bound, every operation type-checked, every static rule the grammar couldn't express now enforced.
Parsing answers “is this a sentence of the grammar?” Semantic analysis answers “does this sentence mean anything sensible?” It is the last phase of the front end — its output is an annotated, type-checked AST plus a populated symbol table.
These rules are context-sensitive — whether x is in scope depends on declarations elsewhere. Context-free grammars cannot express that, so it lives outside the parser.
Once this phase signs off, the rest of the compiler may assume the program is meaningful — IR generation never re-checks a type.
A language's semantics — what programs mean — splits into what can be decided before the program runs and what only emerges as it runs.
Rules checkable at compile time from the program text alone, without running it. The job of semantic analysis.
void function returnsWhat a well-formed program does when executed — defined by an operational / denotational / axiomatic semantics, enforced at run time.
The more a language decides statically, the more errors caught before shipping — but the more the programmer must spell out. A null-dereference is dynamic in Java, but moved static in Kotlin/Rust via the type system. Pushing checks left (compile-time) is a recurring theme of modern language design.
Every snippet below is a perfectly valid parse tree — grammatically flawless. Each is rejected only by semantic analysis, because the offending property is context-sensitive.
int main(void) {
y = 3; // undeclared 'y'
int x = "hello"; // type error: char* → int
int x = 7; // redeclaration of 'x'
foo(1, 2); // arity: foo takes 1 arg
return; // missing return value
}
The grammar happily accepts all five lines — assignment, declaration and call are each well-formed productions.
| Error | Why the parser misses it |
|---|---|
| Undeclared name | Needs the set of names in scope — not in the CFG |
| Type mismatch | Needs each subexpression's type |
| Redeclaration | Needs to know what's already bound here |
| Wrong arity | Needs the callee's signature |
| Missing return | Needs control-flow reachability |
| Break outside loop | Needs surrounding context |
“Variable declared before use” is the classic example of a non-context-free property. You'd need a context-sensitive grammar to encode it — impractical, so we use ad-hoc checks plus a symbol table instead.
The symbol table maps names to everything the compiler knows about them. Lookups must be fast and frequent, so it is usually a hash table — or a stack of them, one per scope.
| Field | Holds |
|---|---|
| name | the identifier (the hash key) |
| kind | var / function / type / param / field |
| type | int, (int,int)→bool, … |
| scope | nesting level / owning block |
| location | stack offset / register / global addr |
| attrs | const, mutable, visibility, line/col |
insert(name, entry) on declaration; lookup(name) on use — searching the current scope, then enclosing scopes outward. enterScope() / exitScope() push/pop tables.
struct Symbol {
std::string name;
Kind kind; // Var, Func, Type, ...
Type* type;
int scopeLevel;
Location loc; // offset / reg / addr
Attrs attrs; // const, mut, visibility
};
// A scope is one hash table; scopes nest.
struct Scope {
std::unordered_map<std::string, Symbol> table;
Scope* parent; // enclosing scope (or null)
};
Symbol* lookup(Scope* s, const std::string& n) {
for (; s; s = s->parent)
if (auto it = s->table.find(n); it != s->table.end())
return &it->second; // first hit wins → shadowing
return nullptr; // undeclared
}
Nested blocks form a tree of scopes. At any point the live scopes are a stack, root at the bottom. lookup walks the stack outward; the innermost binding wins — that is shadowing.
The two x bindings coexist; the reference resolves to whichever scope is nearer on the stack.
Binding connects each use of a name to the declaration it refers to. Resolution is the algorithm that finds that declaration — and the rules for it are surprisingly subtle.
A name binds to the nearest enclosing declaration in the program text — decidable at compile time. The dominant rule (C, Java, Rust, ML, Scheme…).
A name binds to the most recent definition on the call stack at run time. Rare — early Lisp, Bash, Emacs Lisp specials. Hard to reason about; mostly abandoned.
A use that precedes its declaration. Handled by a separate declaration-gathering pass (or two passes): collect all top-level names first, then resolve bodies — so mutual recursion just works.
One identifier can name different things in different namespaces (C: tags vs ordinary vs labels). With overloading, a name maps to a set of entries; resolution picks one by argument types.
Under dynamic scope, what x means depends on who called you — you can't read a function in isolation. Lexical scope makes a binding determinable from the source alone, which is essential for both humans and closures.
Knuth (1968) gave a formal way to attach meaning to a grammar: decorate each production with attributes and semantic rules that compute them. This is the theory behind syntax-directed translation.
Computed bottom-up: a node's value is built from its children. A node's type from its operands; a constant's value. Flow up the tree.
Computed top-down: a node's value comes from its parent and/or siblings. The current scope, the expected type, an indentation level. Flow down & across.
Uses only synthesized attributes. Evaluable in a single bottom-up pass — exactly what a bottom-up (LR) parser can do on reductions.
Synthesized + inherited, but each inherited attribute depends only on the parent and on left siblings. Evaluable in one left-to-right depth-first pass — fits top-down (LL) / recursive-descent parsing. Every S-attributed grammar is L-attributed.
In practice few compilers run a full attribute-grammar engine; they hand-write tree walks. But the vocabulary — what flows up, what flows down, and the legal dependency order — is exactly how we reason about an AST traversal.
Attaching a synthesized .type attribute to an expression grammar gives a one-pass bottom-up type-checker. Each rule combines its children's types into its own.
E → E1 + E2 { E.type =
unify(E1.type, E2.type) }
E → E1 * E2 { E.type =
numeric(E1.type, E2.type) }
E → ( E1 ) { E.type = E1.type }
E → num { E.type = int }
E → real { E.type = float }
E → id { E.type =
lookup(id.name).type }
.type is synthesized — this grammar is S-attributed, so an LR parser can type-check during reduction.
The product of type checking is a typed AST: every expression node carries a resolved type, every identifier a pointer into the symbol table, and implicit conversions are made explicit as Coerce nodes.
The int result of n + 1 must become a double to store in r. The checker inserts a Coerce node so the back end never has to re-derive the conversion.
Each node gains: a resolved type, a symbol reference for identifiers, and sometimes a constant-folded value or an effect annotation.
A type system is a tractable, syntactic method for proving the absence of certain bad behaviours by classifying values. Languages sit at different points on several independent axes.
| Axis | One end | Other end | Examples |
|---|---|---|---|
| When checked | Static — at compile time | Dynamic — at run time | Rust / C++ vs Python / JS |
| Strength | Strong — few implicit holes | Weak — free reinterpretation | Haskell / Python vs C |
| Manifest? | Manifest — annotations written | Inferred — reconstructed | Java vs ML / Haskell |
| Identity | Nominal — by name | Structural — by shape | Java / Rust vs TypeScript / Go ifaces |
Orthogonal axes, endlessly confused. Python is dynamically but strongly typed ("a" + 1 raises, never silently coerces). C is statically but weakly typed (casts reinterpret bits). “Strong” is informal; “static” is precise.
Two records with identical fields are the same type structurally, but different types nominally unless one declares it implements the other. Nominal catches “accidental” matches; structural is more flexible.
Milner's 1978 slogan is the promise a type system makes: if the checker accepts a program, certain run-time errors cannot happen. Soundness is proved as two lemmas.
A well-typed term is either a value or it can take an evaluation step — it never gets “stuck” in a meaningless state (like adding a function to an integer).
If a well-typed term takes a step, the result is still well-typed, with the same type. Types are an invariant of execution.
Together they guarantee a well-typed program never reaches a stuck state — the formal content of “don't go wrong”. (Wright & Felleisen, 1994.)
ArrayStoreException)Type-checking a Turing-complete property is undecidable, so every sound, decidable system is necessarily incomplete — it must reject some safe programs. Good design minimises the surprising rejections.
Type rules are written as inference rules: premises above the line, conclusion below. The judgement Γ ⊢ e : τ reads “in context Γ, expression e has type τ”. Γ is the typing environment — the symbol table, formally.
bool; both branches the same τTo type an expression is to build a derivation tree of these rules with the expression at its root. A type checker is a proof searcher; a type error is a failure to find any derivation.
To type λx:σ. e, extend the context: if Γ, x:σ ⊢ e : τ then Γ ⊢ λx:σ.e : σ→τ. Entering a binder extends Γ — exactly enterScope.
Type if b then x else 0 in context Γ = { b : bool, x : int }. We compose the rules into a derivation tree — leaves are axioms, the root is the goal.
if's three premises are satisfied: guard bool, both arms intintHad the else arm been "hi", the two branch premises (int vs string) couldn't both equal one τ — no derivation exists, so it's a type error.
The rules above need types written down. Hindley–Milner (HM) reconstructs them: with no annotations it finds the most general type, if one exists. Discovered independently by Hindley (1969) and Milner (1978); Milner's Algorithm W is the implementation.
-- No annotations anywhere...
compose f g x = f (g x)
-- W infers the principal type:
-- compose :: (b -> c) -> (a -> b) -> a -> c
twice f x = f (f x)
-- twice :: (a -> a) -> a -> a
id x = x
-- id :: a -> a (a is a fresh type variable)
HM guarantees a principal type: a single most-general type from which every other valid type is an instance. id's principal type is a → a; Int → Int is just one instance.
HM trades power for decidability: no first-class polymorphism (rank-N), no subtyping. Add those and full inference becomes undecidable — hence annotations creep back in.
Unification (Robinson, 1965) is HM's engine: given two type terms, find the most general substitution making them equal — or fail. It solves the equality constraints W generates.
unify(t1, t2):
t1, t2 = resolve(t1), resolve(t2) # follow bound vars
if t1 == t2: return # done
if t1 is a var α: occurs_check(α, t2)
bind α := t2; return
if t2 is a var β: occurs_check(β, t1)
bind β := t1; return
if t1 = C(a1..an) and t2 = C(b1..bn): # same constructor
for i: unify(ai, bi) # arity must match
return
fail("cannot unify " + t1 + " with " + t2)
Before binding α := t, verify α does not occur inside t. Binding α := α → β would build an infinite type. The occurs-check is exactly what rejects the untypable λx. x x.
int vs bool, or wrong arity) → type errorUnification returns the mgu — the substitution that commits to nothing more than necessary. That minimality is precisely why HM yields principal types.
Type vars are mutable cells (union-find); resolve path-compresses. This is why HM is near-linear despite the recursion.
Infer the type of apply f x = f x. Give fresh variables, generate the one constraint f imposes, and unify.
f : α x : β (fresh)
apply : α → β → γ (γ fresh, result)
the call f x demands:
α = β → γ ← f used as a function
unify(α, β → γ):
α is a var, occurs-check(α, β→γ) ✓ (α ∉ β→γ)
bind α := β → γ
substitute back:
apply : (β → γ) → β → γ
generalise free vars β, γ → ∀β γ. ...
apply :: (b -> c) -> b -> c ✓ principal
HM's polymorphism lives at let. When a binding is generalised, its free type variables are closed over into a type scheme ∀α. τ — each use then gets fresh copies (instantiation).
let id = \x -> x in
(id 3, id True)
-- id generalised to ∀a. a -> a
-- use 1: instantiate a := Int → id 3
-- use 2: instantiate a := Bool → id True
-- BOTH type-check ✓
-- λ-bound params are NOT generalised:
\id -> (id 3, id True) -- TYPE ERROR
-- one monomorphic α can't be both Int and Bool
At a let, quantify over type variables free in τ but not free in the environment Γ — those are still constrained elsewhere and must stay fixed.
At each use of a scheme ∀α.τ, replace the bound α with fresh variables — so distinct uses don't constrain each other.
Generalising arbitrary expressions is unsound with mutable references (a polymorphic ref leaks). ML restricts generalisation to syntactic values — the famous value restriction.
Subtyping S <: T means an S may stand in wherever a T is expected (Liskov substitution). The hard question: how does subtyping lift through type constructors?
| Variance | Rule (if S <: T) | Where |
|---|---|---|
| Covariant | F<S> <: F<T> | read-only, return types |
| Contravariant | F<T> <: F<S> | fn argument positions |
| Invariant | neither direction | mutable cells |
(A→B) <: (C→D) iff C <: A (contravariant in the argument) and B <: D (covariant in the result). Arguments flip; results follow.
If Cat <: Animal made List<Cat> <: List<Animal>, you could insert a Dog into the aliased List<Animal> and break the List<Cat>. Java's covariant arrays have exactly this hole — checked at run time via ArrayStoreException.
+T / -T declaration-site varianceout / in on generic params? extends / ? super“Polymorphism” — one name, many behaviours — comes in distinct flavours (Strachey, Cardelli–Wegner). Each is resolved differently by the checker.
One body, any type, uniformly: List<T>, fn id<T>(x:T)->T. Generics / ML polymorphism. The code can't inspect T.
Impl: erasure (Java/HM) or monomorphisation (Rust/C++ templates).
Different code per type, chosen by type. Overloading & type classes / traits: (+) on Int vs Float, Show a.
Impl: the compiler passes a hidden dictionary of methods.
Inclusion polymorphism via S <: T — the OO kind. A Circle works wherever a Shape does, dispatched dynamically.
Impl: vtables / dynamic dispatch.
Haskell's classes are principled overloading: a class declares an interface (class Eq a where (==) :: a→a→Bool); instances supply it; constraints (Eq a =>) thread through inference. Rust traits are the same idea with coherence rules.
With C++/Java overloading the checker ranks candidates by how well argument types match (exact > promotion > conversion), preferring the most specific. Ambiguity — two equally good candidates — is an error. This interacts badly with full inference, so HM languages avoid raw overloading.
Beyond accepting or rejecting, the checker sometimes inserts work: implicit conversions that bridge nearly-compatible types — the source of much convenience and many bugs.
int → long → double, value-preserving, usually safe & silentdouble → int, lossy; many languages require an explicit castCoerce nodes from slide 10C's integer promotions & usual arithmetic conversions are a notorious footgun — unsigned/signed mixing flips comparisons. Strongly-typed languages keep coercions few and explicit.
An alias (typedef, type X = Y) is the same type under a new name — interchangeable, no safety added. A nominal newtype (Haskell newtype, Rust tuple-struct) is a distinct type, so Metres can't be mixed with Seconds.
One can model coercions as a subtype relation with an inserted conversion function. Scala's implicit conversions and C++ converting-constructors formalise exactly this — powerful, and easy to abuse.
Type checking is the headline, but the same phase runs a battery of other flow-sensitive analyses — many are small dataflow problems over the AST/CFG.
Every local is written on all paths before it is read. Java & C# reject reads of possibly-unassigned variables.
Code after a return is unreachable; a non-void function must return on every path. A forward CFG dataflow check.
match must cover every case of a sum type; missing variants warn or error. Rust/ML/Swift enforce this — a key safety win.
You may not assign to a const / final; the LHS of = must be an lvalue (has an address), not a temporary.
Effect systems track I/O / exceptions / async in the type (Koka, checked exceptions, async colouring) — what a function may do, not just return.
Use-before-init of globals, cyclic static init, this escaping a constructor — all caught here.
These reuse the symbol table and run as AST/CFG traversals — the attribute-grammar machinery from earlier, applied to control flow rather than types.
The richest semantic checks push the type system to encode memory safety, resource ownership and even logical specifications.
& shared or one &mut, never both'a are type variables bounding how long a borrow is validTypes that depend on values: Vec n A — a vector of statically-known length n. append provably returns length n+m. Idris, Agda, Coq, Lean. Type-checking subsumes proof-checking.
A base type plus a predicate: { v:Int | v > 0 }. An SMT solver discharges the obligations. Liquid Haskell, F*, Dafny — lighter than full dependent types, still catches off-by-one & division-by-zero statically.
All three move properties that were dynamic (a crash, a leak, a bad index) into the static type system — the same “shift-left” principle that motivated type checking in the first place, taken to its limit.
A type system is only as good as its error messages. The cruel irony: the more inference a system does, the harder good diagnostics become — the error surfaces far from its cause.
Int with Bool” names the symptom, not the bugt47 → t48)// Elm / Rust set the bar: span-precise, fix-it
error[E0308]: mismatched types
--> src/main.rs:3:18
|
3 | let x: i32 = "hello";
| --- ^^^^^^^ expected `i32`,
| | found `&str`
| expected due to this
= help: try parsing with `.parse()`
After an error, don't cascade: assign the broken node an error type (⊥) that unifies with anything and suppresses downstream complaints. One mistake → one message, not fifty.
Hand-run Algorithm W on map f xs = case xs of [] -> []; (y:ys) -> f y : map f ys and confirm the principal type (a->b) -> [a] -> [b].
Intermediate Representations — lowering the typed AST into three-address code & SSA, control-flow graphs, and the IR that the optimiser will chew on.