Type Checking in Compilers

Consider a compiler facing the expression `x + f(y)`, where `x` is a string, `y` is an array, and `f` expects an integer argument. Nothing about the grammar of the language forbids writing this. It parses into a perfectly well-formed abstract syntax tree. Yet running it would be nonsense. Adding a string to whatever `f` returns is an error. Calling `f` with an array instead of an integer is also an error. No parser can catch these, because parsing only enforces shape, not meaning. Type checking is the compiler phase that catches exactly this class of error. It does so by walking the AST and asking, for every subexpression, "what kind of value does this produce, and is that kind allowed here?" This matters because type errors are the single largest category of preventable bugs in software. Catching them at compile time — rather than as a runtime crash in production — is one of the great practical payoffs of static analysis. A type checker is also what makes an Intermediate Representations trustworthy. Once a program has been type-checked, later phases can assume invariants (this is an integer, this pointer is non-null-typed, this function takes exactly two arguments). They can then skip runtime checks entirely. This is why statically typed languages can compile to code with far less defensive overhead than dynamically typed ones. The big picture is that type checking sits at the boundary between syntax and semantics. It consumes the AST produced by parsing and the scope structure built by Symbol Tables and Scope Resolution. It produces either a decorated AST (each node annotated with its type) or a list of type errors. It is fundamentally a tree traversal guided by a set of typing rules — a formal, often inference-rule-style specification of what types are legal where. The traversal is a form of structural induction: the type of a compound expression is computed from the types of its parts. This article gives the precise vocabulary of typing rules and judgments. It provides a full algorithm for checking an AST bottom-up using symbol table lookups. It includes a worked trace through a small but nontrivial program. Finally, it gives an honest accounting of where type systems succeed and where they are traded off for flexibility or performance.

Key takeaways
  • Type checking verifies that every operation in a program is applied to operands of a permitted type, using rules stated as a formal type system.
  • It operates on the AST, consulting a symbol table (built during scope resolution) to look up the declared types of identifiers.
  • The core mechanism is a bottom-up (synthesized) or bidirectional (synthesize + check) traversal that computes or verifies a type for every node.
  • Soundness — "well-typed programs don't go wrong" — is proved via a progress and preservation argument over the language's operational semantics.
  • Type checking is linear or near-linear in AST size for simple type systems, but full type inference (e.g., Hindley–Milner) is worst-case exponential, though fast in practice.
  • The output is a decorated AST that downstream phases (code generation, optimization) rely on to eliminate runtime type checks.
The core insight

Type checking is structural induction over the AST: compute or verify each node's type from its children's types plus symbol table lookups, using a fixed set of typing rules.

The idea

A type system is a collection of rules that assigns types — labels like `int`, `bool`, `string`, or `int → int` — to program expressions, and that rejects programs where these labels would be combined inconsistently. Formally, a typing rule is usually written as a judgment ``` Γ ⊢ e : τ ``` read "under type environment Γ, expression `e` has type `τ`." The environment Γ is exactly the information a symbol table provides: a mapping from identifiers to their declared types (and, for functions, their parameter and return types). Typing rules are given in inference rule form, with premises above a line and a conclusion below: ``` Γ ⊢ e1 : int Γ ⊢ e2 : int ───────────────────────────── (T-Add) Γ ⊢ e1 + e2 : int ``` This rule says: if `e1` and `e2` can both be shown to have type `int` under Γ, then `e1 + e2` has type `int` under Γ. A type checker is an algorithm that mechanically applies rules like this to derive (or fail to derive) a judgment for the root of the AST. There are two closely related but distinct tasks that go by the name "type checking":

  • Type checking proper: every expression is annotated with an expected type (as in explicitly-typed languages like Java or C), and the algorithm merely verifies consistency — a pure checking judgment `Γ ⊢ e ⇐ τ`.
  • Type inference: types are not all given, and the algorithm must reconstruct them — a synthesis judgment `Γ ⊢ e ⇒ τ`, as in ML or Haskell, where `let x = 3 in x + 1` has no explicit annotation on `x` at all.

Most real compilers use a bidirectional mix: literals, operators, and calls to explicitly-declared functions synthesize their type upward from their subexpressions, while contexts with an expected type (e.g., a function body against its declared return type, or an `if`-branch against its sibling) check an expression against that expectation. This hybrid keeps the algorithm simple, syntax-directed (one rule per AST node shape), and — critically — decidable and efficient, unlike unrestricted inference for very expressive type systems. The other essential concept is type equivalence: the rule for when two type expressions denote "the same" type. Two common disciplines are structural equivalence (two record types are equal if their field names and types match, regardless of name) and nominal equivalence (two types are equal only if they were declared with the same name, as in Java classes). This choice is a design decision baked into the type checker's equality-testing subroutine, and it changes which programs are accepted.

How it works

The type checker is driven by the abstract syntax tree and the symbol table, both already built by earlier phases. Concretely: 1. Scope resolution has already run. Every identifier reference in the AST is linked (or linkable, via scoped lookup) to the declaration that introduces it, and each declaration node carries a declared or inferred type slot. 2. The checker walks the AST, most commonly in a single post-order (bottom-up) traversal for synthesis, augmented with an "expected type" parameter passed downward for checking contexts. 3. At each node, the checker looks up exactly one typing rule matching that node's syntactic shape (this is what makes the algorithm syntax-directed — no search over which rule to apply). 4. Every rule either succeeds, producing a type for the node (and possibly writing it into a decoration slot on the node), or fails, in which case a type error is reported, typically along with a recovery type (often a distinguished `error` type) so checking can continue and report multiple errors in one pass rather than stopping at the first. 5. Declarations update the symbol table as they are traversed (for languages with sequential, C-like scoping) or are pre-registered in a first pass (for languages with mutually recursive top-level declarations, like most functional languages, which need a pre-pass to collect all top-level signatures before checking any body). The heart of the algorithm is a big `case` dispatch, one arm per AST node kind. Here is a checker for a small imperative expression/statement language with integers, booleans, variables, functions, and arrays — enough to expose all the interesting cases (arithmetic, comparisons, function calls, assignment, arrays, and control flow). ```pseudocode // Types are one of: INT, BOOL, ARRAY(T), FUNC(paramTypes, returnType), ERROR function typeOf(node, env): // env: symbol table / type environment (Γ) switch node.kind: case IntLiteral: return INT case BoolLiteral: return BOOL case Identifier(name): entry = env.lookup(name) // uses scope-resolution linkage if entry == NOT_FOUND: report("undeclared identifier: " + name, node) return ERROR return entry.type case BinOp(op, left, right): tL = typeOf(left, env) tR = typeOf(right, env) if tL == ERROR or tR == ERROR: return ERROR // fault already reported downstream expected = signatureOf(op) // e.g. (+): INT×INT→INT, (<): INT×INT→BOOL if tL == expected.leftType and tR == expected.rightType: return expected.resultType report("operator " + op + " expects " + expected.leftType + ", " + expected.rightType + " but got " + tL + ", " + tR, node) return ERROR case ArrayIndex(base, index): tBase = typeOf(base, env) tIndex = typeOf(index, env) if tIndex != INT: report("array index must be INT, got " + tIndex, node) if tBase is ARRAY(elemType): return elemType if tBase != ERROR: report("indexing into non-array type " + tBase, node) return ERROR case Call(fname, args): entry = env.lookup(fname) if entry == NOT_FOUND or not (entry.type is FUNC(params, ret)): report(fname + " is not a callable function", node) return ERROR (params, ret) = entry.type if length(args) != length(params): report("arity mismatch calling " + fname, node) return ERROR for i in 0 .. length(args)-1: tArg = typeOf(args[i], env) if tArg != params[i] and tArg != ERROR: report("argument " + i + " of " + fname + " expects " + params[i] + " got " + tArg, node) return ret case Assign(target, value): tTarget = typeOf(target, env) // must be an lvalue: Identifier or ArrayIndex tValue = typeOf(value, env) if tTarget != ERROR and tValue != ERROR and tTarget != tValue: report("cannot assign " + tValue + " to " + tTarget, node) return VOID case If(cond, thenBranch, elseBranch): tCond = typeOf(cond, env) if tCond != BOOL and tCond != ERROR: report("if-condition must be BOOL, got " + tCond, node) checkStmt(thenBranch, env) checkStmt(elseBranch, env) return VOID case FuncDecl(name, params, retType, body): innerEnv = env.newChildScope() for p in params: innerEnv.declare(p.name, p.type) innerEnv.declare("__return__", retType) // used to check return stmts checkStmt(body, innerEnv) return FUNC(typesOf(params), retType) case Return(expr): tExpr = typeOf(expr, env) tExpected = env.lookup("__return__").type if tExpr != tExpected and tExpr != ERROR: report("return type mismatch: expected " + tExpected + " got " + tExpr, node) return VOID default: error("unhandled AST node kind: " + node.kind) ``` Two structural points deserve emphasis. First, the traversal for a value-producing node like `BinOp` synthesizes upward: it computes children's types first, then derives the parent's type — this is the post-order, "type-of" style. Second, a statement node like `If` uses a sibling helper `checkStmt` that has no return type of interest (statements aren't expressions) but still must recursively verify its substructure — this is why real checkers usually split `typeOf` (synthesis for expressions) from `checkStmt` (verification for statements), even though both share the same environment-threading discipline. The error-recovery discipline — propagating a special `ERROR` type instead of aborting — is what lets a single compilation pass report many independent type errors instead of stopping at the first one, and it is essential for usable error messages: without it, one bad variable reference could cascade into dozens of spurious secondary errors.

Figure
Type-checking pass: AST decorated bottom-up with types via symbol-table lookups
Symbol Table (Γ) x : INT y : ARRAY(INT) f : FUNC([INT], INT) (scope: current fn) lookup + : INT x : INT f(..):INT y[0] : INT post-order: leaves resolved first, then each parent's type is synthesized upward

Complexity & correctness

Time complexity. For a syntax-directed checker with no unification (i.e., every node's type is either explicitly annotated or derivable purely from its children — the setting of the pseudocode above), the algorithm visits each AST node exactly once and does O(1) work per node beyond symbol table access. If the symbol table supports O(1) expected-amortized lookup (hash map) or O(log n) worst-case (balanced tree keyed per scope), the whole checker runs in ``` O(n) expected/amortized, n = number of AST nodes O(n log n) worst case with balanced-tree symbol tables ``` This is a direct consequence of the traversal structure: `typeOf` and `checkStmt` recurse exactly once per child, so the recursion tree has the same size as the AST, and each call does a bounded amount of local work (a signature lookup, an arity check, an equality test between two already-computed type representations). Type equality itself must be examined carefully: for simple base types it is O(1), but for structural equivalence of recursive types (records containing records, or recursive algebraic data types) it requires care to avoid infinite loops on cyclic type definitions — standard fixes are memoized equality checks or comparing against a "seen pairs" set, keeping it linear in the size of the types being compared rather than the whole program.

Type inference is a different story. Full Hindley–Milner-style inference (as in ML, Haskell, OCaml) does not require every subexpression to be annotated; it generates type constraints and solves them via unification with a substitution. Algorithm W runs in low-degree polynomial time on almost all real programs, but the classic result (Kanellakis, Mairson, Mitchell, 1991) is that let-polymorphic type inference is EXPTIME-complete in the worst case — nested `let`-bindings can force exponential blow-up in the size of the most general (principal) type. This worst case essentially never arises in hand-written code, which is why ML-family compilers feel fast in practice; but it is the honest theoretical complexity, and it is the reason some compilers cap generalization depth or add annotations to bound blow-up in generated code.

Correctness. The relevant correctness property for a type system is usually stated as type soundness, decomposed (following the standard Wright–Felleisen approach) into two lemmas about the language's small-step operational semantics `e → e'`:

  • Progress: if `∅ ⊢ e : τ` (e is well-typed in the empty environment) then either `e` is a value, or there exists `e'` such that `e → e'`. Informally: a well-typed program is never stuck — it never reaches a state where no rule applies to a non-value (this rules out exactly the "add a string to a number at runtime" failure).
  • Preservation (a.k.a. subject reduction): if `Γ ⊢ e : τ` and `e → e'`, then `Γ ⊢ e' : τ`. Informally: evaluation never changes an expression's type.

Together, progress and preservation give, by induction on the length of an evaluation sequence, the informal slogan "well-typed programs don't go wrong": starting from a well-typed term, every step either produces a value or another well-typed term, so the program can never get stuck in an ill-defined state (like trying to call a non-function or index a non-array). This is a genuine mathematical theorem for a given formal type system and operational semantics — it is proved once, by the language designer, by structural induction over the typing derivation and case analysis on the last rule used; the type checker's job is simply to mechanically decide the premise `Γ ⊢ e : τ` for a candidate program, so that the soundness theorem's guarantee actually applies to it. It is worth being honest about the limits: soundness says nothing about whether the program is correct with respect to its intended behavior — only that it will not crash with a type error at runtime. A well-typed program can still divide by zero, index out of bounds (if the type system doesn't track array lengths), or loop forever. Type checking is a discipline that eliminates one specific, large class of runtime failures, not a full correctness proof of program behavior.

Worked example

Consider this small program in the language handled by the pseudocode checker above: ``` func f(n: INT) -> INT { return n + 1 } func main() -> INT { x: INT = 3 y: ARRAY(INT) = [10, 20, 30] return x + f(y[0]) } ``` Assume scope resolution has already produced a global symbol table with `f : FUNC([INT], INT)`, and that entering `main`'s body creates a child scope with `x : INT`, `y : ARRAY(INT)`, and `__return__ : INT`.

StepNode visitedRule appliedLookup / checkResult type
1`n` (in `f`'s body)Identifier`env.lookup("n")` → INT (param)INT
2`1`IntLiteral—INT
3`n + 1`BinOp(+)INT × INT matches signature of `+`INT
4`return n + 1`Returncompare INT (child) vs `__return__`=INTOK, VOID
5`x`Identifier`env.lookup("x")` → INTINT
6`y`Identifier`env.lookup("y")` → ARRAY(INT)ARRAY(INT)
7`0`IntLiteral—INT
8`y[0]`ArrayIndexbase is ARRAY(INT), index is INT ✓INT
9`f(y[0])`Call`f : FUNC([INT], INT)`; 1 arg vs 1 param; arg type INT == param type INT ✓INT
10`x + f(y[0])`BinOp(+)INT × INT matches `+`INT
11`return x + f(y[0])`Returncompare INT (child) vs `__return__`=INTOK, VOID

Every node resolves cleanly: the checker accepts the program, and the decorated AST now carries a type at every node, ready for a code generator that, e.g., selects an integer-add instruction directly at step 10 without emitting any runtime type dispatch. Now perturb the program to introduce two independent errors, to see the error-recovery discipline in action: ``` func main() -> INT { x: INT = 3 y: BOOL = true return x + f(y) // (1) f expects INT, got BOOL } // (2) '+' between INT and whatever f(y) is ``` Tracing: at the `Call` node, `f`'s param type is INT but the argument `y` has type BOOL — this arity is fine (1 vs 1) but the type mismatch is reported, and per the pseudocode, the call still returns its declared return type `INT` (not `ERROR`) because the call itself is well-formed even though one argument was wrong — a design choice that avoids also flagging the outer `+` as broken due to a propagated `ERROR`. The `BinOp(+)` node then sees `x : INT` and `f(y) : INT` and passes cleanly. The compiler reports exactly one diagnostic — "argument 0 of f expects INT got BOOL" — rather than cascading into a second, redundant complaint about `+`. This single design decision (return the declared type from a call even when an argument was ill-typed, rather than `ERROR`) is precisely what keeps error messages focused instead of an avalanche of noise.

Common pitfalls & traps

PitfallWhy it happensConsequence
Checking before scope resolution finishesForward references (functions calling each other, or class members referencing sibling classes) need a pre-pass to register all top-level signaturesSpurious "undeclared identifier" errors on perfectly valid mutually-recursive code
Propagating errors as a crash instead of an `ERROR` sentinel typeTreating the first mismatch as fatalCompiler reports only one error per run, terrible developer experience
Confusing structural vs. nominal type equalityImplementer picks one arbitrarily without checking the language specPrograms the language should accept get rejected, or vice versa (e.g., two unrelated but field-identical records treated as interchangeable)
Forgetting implicit coercions / wideningsRule for `+` only checks exact type equality, ignoring language rules like `int → float` promotionValid, idiomatic code (`3 + 2.5`) is rejected as a type error
Infinite loop on recursive type equality checksComparing two recursive record types field-by-field without memoizing visited pairsType checker hangs or stack-overflows on legitimate recursive data types (e.g., a linked-list node type referencing itself)
Treating array bounds or null-ness as "type" problemsOverloading the type checker to also do bounds/flow analysis it wasn't designed forEither false confidence (type checker can't actually catch these) or a bloated, slow checker mixing concerns

Pioneers & history

Origins · 1 minRobin Milner and how this was discovered
RM
Robin Milner
1934–2010 · University of Edinburgh, designer of ML and LCF, developer of the polymorphic type inference algorithm underlying modern statically-typed functional languages

Robin Milner's work on the ML language in the 1970s produced the type discipline — let-polymorphism plus principal-type inference — that made it possible to write untyped-looking code that is nonetheless fully and automatically type-checked, without sacrificing soundness. His 1978 paper "A Theory of Type Polymorphism in Programming" gave the algorithm now called Algorithm W, and the associated soundness result is often summarized by his own aphorism: "well-typed programs cannot 'go wrong.'" This framing — type safety as a theorem about a formal semantics, not a folk claim — set the standard for how every subsequent type system, from Java generics to Rust's borrow-and-type checker, is expected to be justified.

Origins · 1 minhow this was discovered
History
1958
— first typed high-level languages (ALGOL family) distinguish integer and real arithmetic at compile time.
1965
— Christopher Strachey's foundational lectures on abstraction and type structure shape the theory of programming languages.
1969
— Tony Hoare's "Notes on Data Structuring" and early formal semantics work connect types to program correctness proofs.
1978
— Robin Milner publishes let-polymorphic type inference (Algorithm W) for ML, the basis of Hindley–Milner type systems.
1991
— Kanellakis, Mairson, and Mitchell prove worst-case EXPTIME-completeness of ML-style let-polymorphic inference.

Where this matters

Every mainstream compiler for a statically typed language — GCC and Clang for C/C++, javac for Java, rustc for Rust, the OCaml and Haskell (GHC) compilers, TypeScript's `tsc` — implements a type-checking pass structured almost exactly as described here: an AST (or a lightly desugared version of it) walked with a symbol-table-backed environment, producing either a decorated tree or diagnostics. TypeScript is a particularly visible industrial case: it type-checks a superset of JavaScript without changing runtime semantics at all, purely to catch bugs before deployment, and its checker is a bidirectional synthesize/check algorithm operating over a structural type system with the specific complications of unions, intersections, and generics. Type checking is also the load-bearing wall behind performance: a JIT compiler or an ahead-of-time optimizer can eliminate runtime tag checks, dynamic dispatch, and boxing exactly to the extent that static types guarantee the relevant invariants — this is why moving from a dynamically typed language (Python, JavaScript) to a statically typed one (or adding gradual typing, as in TypeScript, mypy, or Julia's type inference) is a standard lever for both correctness and speed in large systems. Beyond conventional compilers, type checking underlies proof assistants (Coq, Agda, Lean), where "type checking the proof term" is verifying the mathematical proof, via the Curry–Howard correspondence between types and propositions — the same core algorithm from this article, applied to a language expressive enough to encode logic itself. It is also the reason a decorated AST is a prerequisite for generating a reliable Intermediate Representation: IR generation can assume every operation is well-typed and skip inserting runtime type guards that a dynamically typed target would require.

Ask the tutor: How would this type-checking algorithm change to support parametric polymorphism (generics), and why does that push the complexity story toward unification-based inference?

Is type checking the same thing as type inference?

No — they are related but distinct. Type checking verifies that a program's types are consistent, generally when the programmer (or an earlier pass) has already supplied enough annotations that every judgment can be checked directly against an expectation. Type inference reconstructs missing type information, solving constraints (often via unification) to find types the programmer never wrote down, as in ML's `let x = 3 in x + 1`. Most real compilers use a bidirectional hybrid: some contexts synthesize a type upward from subexpressions, others check a subexpression against a type flowing downward from context. "Type checking" is often used loosely to mean the whole phase, including whatever inference it performs internally.

Why can't the parser itself catch type errors?

A parser only enforces context-free syntactic structure — that tokens form a legal derivation of the grammar, like ensuring parentheses match and operators have the right arity in the tree shape. Whether `x + f(y)` is meaningful depends on the declared types of `x`, `f`, and `y`, which is contextual, semantic information that context-free grammars cannot express (this is the reason type checking is called a semantic analysis phase, not a syntactic one). Some very restrictive type disciplines can be partially folded into a context-sensitive grammar, but this is impractical for real languages and universally avoided in production compilers.

What does "structural" versus "nominal" type equivalence mean in practice?

Structural equivalence declares two types equal if their internal structure matches — for example, two record types with identical field names and types are interchangeable even if declared separately, as in TypeScript or OCaml. Nominal equivalence declares two types equal only if they share a declared name/identity, even if structurally identical — as in Java, where two classes with the same fields are still distinct types unless one extends the other. The choice affects both expressiveness (structural typing supports flexible "duck typing"-like interop) and safety (nominal typing prevents accidental interchangeability of semantically different but structurally similar types, like `Meters` and `Feet` both being floats).

How does type checking interact with function overloading and operator overloading?

Overloading means one name (`+`, or a function identifier) has multiple type signatures, so the checker must pick which signature applies based on argument types — this turns a simple lookup into an overload resolution step: gather all candidate signatures for the name, filter to those whose parameter types match (allowing any permitted implicit conversions), and select the best match by a well-defined specificity ordering. If more than one candidate remains equally good, or none do, this is itself a type error ("ambiguous call" or "no matching overload"). This is considerably more complex than the single-signature case in the article's pseudocode, and is a major source of subtlety in C++ and Java compilers.

Can a program pass type checking and still crash at runtime?

Yes, and this is expected, not a bug in the type checker. Type soundness only guarantees the absence of type errors — no "message not understood," no calling a non-function, no adding incompatible types. It says nothing about array-bounds violations (unless the type system tracks lengths, as in dependently typed languages), null pointer dereferences (unless the type system tracks nullability, as in Kotlin or Rust's `Option`), integer overflow, or division by zero. Each of these requires either a richer type system (refinement types, dependent types) or a separate runtime check; type checking eliminates one large, well-defined class of bugs, not all bugs.

Why is type checking usually done in a single pass over the AST rather than iteratively?

For languages where every declaration must precede its use (classic C-style scoping), a single post-order pass suffices because every identifier's type is already in the symbol table by the time it's referenced. Languages allowing mutual recursion (functions or classes referencing each other regardless of declaration order) need a preliminary pass that registers all top-level signatures into the symbol table before any body is checked, effectively making it a two-pass algorithm: a shallow signature-collection pass, then a full checking pass. Some very expressive type systems (with type-level computation) additionally need an internal fixpoint/worklist loop to resolve mutually recursive type definitions, which is where the neat "single traversal" story gets more intricate.

What happens to the decorated AST after type checking finishes?

The type-annotated tree is handed to the next phase, typically the generator that lowers it into an Intermediate Representation such as three-address code or an SSA-form control-flow graph. Because every node now carries a definite type, this phase can select correctly-sized instructions (32-bit vs. 64-bit arithmetic), choose the right calling convention, and — crucially — omit runtime type tags and dispatch checks that a dynamically typed language would need, since the soundness of the type system guarantees those checks are unnecessary. This is the direct link between a compile-time analysis and measurable runtime performance.

Before this

Where this leads