AnyLearn
All lessons
Programmingadvanced

Semantic Analysis and Type Checking

The parser only checks syntax — it happily accepts `x = y + z` even if none of those names exist. Semantic analysis adds meaning: it builds symbol tables, resolves names, enforces scoping rules, and runs the type checker that catches the errors a grammar cannot. Learn what happens between the raw AST and the typed AST the IR generator gets.

Not signed in — your progress and quiz score won't be saved.
Lesson progress1 / 9

What the parser leaves undone

A syntactically valid program can still be garbage:

int foo() {
    return bar(x, x);   // bar? x? never declared
}
int main() {
    foo() + "hello";    // int + char* ?
    return;             // missing value in int-returning function
}

None of these are syntax errors — the grammar accepts them. They are semantic errors, and catching them is the job of semantic analysis. Semantic analysis walks the AST produced by the parser, builds supporting data structures, and either annotates the AST with type information or rejects the program with meaningful diagnostics.

The output — a typed AST — is what every downstream phase (IR generation, optimization) can trust. Without it, code generation would have to guess sizes, calling conventions, and memory layouts.

Full lesson text

All 9 steps on one page — for reading, reference, and search.

Show

1. What the parser leaves undone

A syntactically valid program can still be garbage:

int foo() {
    return bar(x, x);   // bar? x? never declared
}
int main() {
    foo() + "hello";    // int + char* ?
    return;             // missing value in int-returning function
}

None of these are syntax errors — the grammar accepts them. They are semantic errors, and catching them is the job of semantic analysis. Semantic analysis walks the AST produced by the parser, builds supporting data structures, and either annotates the AST with type information or rejects the program with meaningful diagnostics.

The output — a typed AST — is what every downstream phase (IR generation, optimization) can trust. Without it, code generation would have to guess sizes, calling conventions, and memory layouts.

2. Symbol tables and scopes

A symbol table maps names to their declarations (type, storage class, visibility). Because scoping rules vary across languages, the table is typically a stack of scopes rather than a flat map.

class Scope:
    def __init__(self, parent=None):
        self.parent = parent
        self.table: dict[str, Symbol] = {}

    def define(self, name: str, sym: Symbol):
        self.table[name] = sym

    def resolve(self, name: str) -> Symbol:
        if name in self.table:
            return self.table[name]
        if self.parent:
            return self.parent.resolve(name)  # walk up
        raise SemanticError(f"Undefined: {name}")

Entering a block ({) pushes a new scope; exiting (}) pops it. Function parameters go into the function's scope. resolve walks up the chain, implementing lexical scoping (inner names shadow outer ones). Closures require a persistent reference to the enclosing scope even after the enclosing function returns.

3. Name resolution and forward references

Name resolution binds each identifier use to its declaration. The order matters:

  • Single-pass languages (older C): a name must be declared before it is used. Forward declarations (int foo(int);) exist specifically to break cycles.
  • Two-pass languages (Java, most modern languages): the compiler makes a first pass to collect all declarations into the symbol table, then a second pass to resolve uses. Classes can call each other's methods without forward declarations.
  • Mutual recursion across files: linker symbols handle the final binding, but the compiler needs at least a type signature to type-check the call site.

C++'s name resolution is notoriously complex: dependent names in templates, ADL (argument-dependent lookup), and using directives interact in ways that require multiple disambiguation passes. This is a primary reason C++ compile times are so long.

4. Type systems: checking vs inference

Two broad approaches to assigning types to expressions:

PropertyType checkingType inference
AnnotationsProgrammer writes typesCompiler deduces them
Error locationAt the annotationAt the use site
LanguagesC, Java, GoHaskell, Rust, OCaml, ML
AlgorithmWalk AST, verifyUnification / constraint solving
ComplexityO(n)O(n) tree walkO(nα(n))O(n \cdot \alpha(n)) or worse

Hindley-Milner (HM) is the classic inference algorithm: assign a fresh type variable τi\tau_i to each expression, emit constraints ($\tau_1 = \tau_2 \to \tau_3$), then unify. If unification succeeds, the principal (most general) type is inferred. HM is the basis of ML, Haskell, and OCaml's type systems.

Rust adds lifetimes and ownership on top of HM, making inference undecidable in the general case — the compiler uses a fixed-point iteration (borrow checker) instead.

5. Unification: the engine of type inference

Unification takes two type expressions and finds a substitution σ\sigma that makes them equal. It's a recursive algorithm:

unify(Int, Int)         -> {} (trivially)
unify(a, Int)           -> {a := Int}
unify(a -> b, Int -> c) -> unify(a,Int) ∪ unify(b,c)
                        -> {a := Int, b := c}
unify(Int, Bool)        -> FAIL (type error)
unify(a, List[a])       -> FAIL (occurs check: a inside itself)

The occurs check prevents infinite types: unifying aa with List[a]\text{List}[a] would produce List[List[List[]]]\text{List}[\text{List}[\text{List}[\dots]]]. Most Haskell compilers skip the occurs check by default for performance, accepting a tiny class of programs with infinite types. Unification is O(nα(n))O(n \cdot \alpha(n)) with union-find, where α\alpha is the inverse Ackermann function — effectively constant.

6. Semantic errors the parser cannot catch

A concrete list of errors that are syntactically valid but semantically wrong:

  • Undefined name: return x; when x is not declared in scope.
  • Type mismatch: int x = "hello"; — assigning char* to int.
  • Arity mismatch: foo(1, 2) when foo takes one argument.
  • Return type mismatch: int f() { return true; } — boolean vs. int.
  • Use before initialization: int x; return x; (C: undefined behavior; Rust: compile error).
  • Duplicate declaration: int x; int x; in the same scope.
  • Break outside loop: break; at file scope.
  • Non-constant array size in C89: int arr[n]; where n is a variable.

Each requires context (symbol table, current scope, current function's return type) that a context-free grammar cannot encode. That's why semantic analysis is a separate phase.

7. Building the typed AST

After name resolution and type checking, the AST is annotated with types. In practice this means every expression node carries a type field:

@dataclass
class BinOpNode:
    op: str
    left: Expr
    right: Expr
    type: Type = None   # filled in by type checker

# Type checking a binary +
def check_binop(node: BinOpNode, scope: Scope) -> Type:
    lt = check_expr(node.left,  scope)
    rt = check_expr(node.right, scope)
    if lt != rt:
        raise TypeError(f"Cannot add {lt} and {rt}")
    node.type = lt      # annotate in place
    return lt

The typed AST is the contract between front-end and back-end. IR generation can call node.type.size_bytes() to know how many bytes to allocate, which calling convention to use, and whether an implicit integer promotion is needed. Implicit coercions (C's usual arithmetic conversions) are often made explicit as CastNode insertions during this phase.

8. Overloading and method resolution

Languages with overloading (C++, Java) add a resolution step on top of type checking: given a call f(1, 2.0), which f among f(int, int), f(double, double), f(int, double) is the best match?

C++ resolution rules:

  1. Build the candidate set: all f declarations visible at the call site (including via ADL).
  2. Build the viable set: candidates where arguments are convertible to parameters.
  3. Pick the best viable function by ranking implicit conversions (exact match > promotion > standard conversion > user-defined conversion).
  4. If there's a tie: ambiguous call, compile error.

This is expensive. Clang's overload resolution runs in O(ca)O(c \cdot a) where cc is candidates and aa is arguments, with complex tie-breaking logic. Virtual dispatch (runtime polymorphism) is resolved not here but at the call site via a vtable pointer — a topic for the IR phase.

9. What gets passed to the back-end

At the end of semantic analysis, the front-end hands the back-end:

  • A typed AST with every expression node annotated with its concrete type.
  • A symbol table (or its information embedded in AST nodes) giving storage class, linkage, and offset for every variable.
  • Implicit cast nodes inserted where the language requires arithmetic promotions.
  • Constant-folded literals where the front-end does early evaluation (sizeof(int)4).
  • A list of diagnostics — zero if the program is accepted.

Modern compilers (Clang, GCC, Rust) keep the typed AST in memory and feed it directly to IR lowering. Some (older GCC) serialized it to a .gcov intermediate file. The key invariant: the IR generator must never need to re-examine source text or re-resolve a name — the typed AST is a complete, self-contained program representation.

Check your understanding

The lesson ends with a 5-question quiz. Take it in the player above to see your score.

  1. A scope-stack symbol table calls `resolve("x")` and the name is not in the current scope. What happens next?
    • A compile error is raised immediately — names must be in the innermost scope.
    • The lookup walks up the parent chain until it finds `x` or exhausts all scopes.
    • The lookup searches all scopes in parallel and picks the one with the matching type.
    • The resolver returns `null` and lets the type checker handle the missing name.
  2. Hindley-Milner type inference assigns a fresh type variable to each expression and then does what?
    • Walks the AST top-down and propagates annotated types from the declaration site.
    • Emits equality constraints between type variables and solves them via unification.
    • Runs the program and observes the runtime types of each expression.
    • Queries the symbol table for declared types and checks them against usage.
  3. Why does the occurs check matter in unification?
    • It prevents binding a type variable to a less-general type, preserving polymorphism.
    • It detects when a type variable would be unified with a type that contains it, which would create an infinite type.
    • It ensures type variables are resolved before the occurs check runs.
    • It enforces that all type variables in a function signature are bound by the occurs check.
  4. Which of the following errors CANNOT be caught by a context-free grammar alone?
    • A missing closing parenthesis in `foo(1, 2`.
    • Using a variable `x` that was never declared in any enclosing scope.
    • A function call with the wrong number of arguments when the declaration is in a different file.
    • Both B and C — they require symbol-table context unavailable to a CFG.
  5. When the type checker inserts an implicit `CastNode` during semantic analysis, what is the primary reason?
    • To speed up IR generation by pre-computing type sizes.
    • To make implicit language-defined coercions (like C's integer promotion) explicit in the AST so IR generation can emit the correct instruction without re-reading the spec.
    • To convert all types to a canonical 64-bit integer for uniform code generation.
    • To satisfy the parser's grammar rules, which require explicit casts.

Related lessons

Programming
advanced

Parsing: Tokens to ASTs

The parser takes a flat token stream and recovers the hierarchical structure the programmer intended. Learn context-free grammars, recursive-descent parsing, operator precedence, the difference between a parse tree and an AST, and when LL versus LR parsers matter — including the classic dangling-else ambiguity.

9 steps·~14 min·audio
Programming
advanced

Lexical Analysis: Source to Tokens

Before a compiler can understand your code it has to chop it into meaningful pieces. Learn how scanners work, how regular expressions become finite automata, why maximal munch is the rule, and what a real token stream looks like — the foundation every later compiler phase depends on.

9 steps·~14 min·audio
Programming
advanced

IR, Optimization, and Code Generation

The typed AST is high-level — too high for a CPU. Learn why compilers lower to an intermediate representation first, what SSA form buys you, how classic optimizations (constant folding, dead-code elimination, CSE) transform IR, and how instruction selection and register allocation finally produce machine code.

9 steps·~14 min·audio
Programming
advanced

Dynamic analysis and debuggers

Reverse engineering by running the binary. Why dynamic analysis sees what static cannot, how debuggers and breakpoints actually work (INT3 vs hardware vs page-fault), tracing (strace, ltrace, dtrace, eBPF), dynamic binary instrumentation with Frida and PIN, the common anti-debug tricks, sandboxing with Unicorn and Qiling, and the static-dynamic loop that does the real work.

8 steps·~12 min