This article is Part 4 of the Compiler Design series. Part 1 · Part 2 — Lexical Analysis · Part 3 — Syntax Analysis
Consider this C code:
int x = "hello" + 3.14;
It is syntactically valid — it matches every grammar rule. The parser produces a perfectly well-formed AST. But it is nonsense: you cannot add a string literal to a floating-point number and store the result in an integer. No grammar rule catches this; it takes a different kind of analysis entirely.
That analysis is semantic analysis — Stage 3 of compilation.
What Semantic Analysis Does
The semantic analyser receives the AST from the parser and walks through it, enforcing the language’s meaning rules rather than its structural rules. These include:
- Type checking — are operands compatible? Is an int being assigned where a float is expected?
- Scope resolution — is every name actually declared? Is it accessible from where it’s used?
- Function call validation — are the right number and types of arguments being passed?
- Control flow rules — is a
returnstatement inside a function? Is abreakinside a loop? - Definite assignment — is a variable used before it’s initialised?
The output is an annotated AST — the same tree structure, but with type information attached to every node. This enriched tree is what the IR generator consumes next.
The Symbol Table
The workhorse of semantic analysis is the symbol table — a data structure that records every named entity in the programme: variables, functions, types, constants.
For each identifier, the symbol table stores:
- Name — the identifier string (
x,add,MAX_SIZE) - Type — the declared type (
int,float,fn(int, int) → int) - Scope — where the name is visible (
global,local:main,local:add) - Address — the memory location or parameter position
- Line — where it was declared (for error messages)
- Initialised? — whether it has a value yet
The symbol table is built during semantic analysis but persists through every subsequent stage. Code generation uses it to determine where each variable lives in memory.
What Happens Without a Declaration
When the semantic analyser encounters a name that doesn’t appear in the symbol table, it reports an undeclared identifier error. This is one of the most common compiler errors and one of the most useful: the programme literally cannot proceed because the compiler has no idea what type the name has or where it lives in memory.
int result = sum(a, b); // error: 'sum' undeclared
Type Checking
Type checking is semantic analysis’s most visible job. The analyser walks each node in the AST and verifies that the types of sub-expressions are compatible with the operation being applied.
Static vs Dynamic Type Checking
Static type checking (C, Java, Rust, TypeScript) happens at compile time. Every expression has a type that can be determined before the programme runs. The compiler rejects type-inconsistent programmes outright.
Dynamic type checking (Python, JavaScript, Ruby) happens at runtime. Types are attached to values, not variables, and checked only when an operation is actually performed.
Compiled languages are overwhelmingly statically typed because static checking moves entire categories of bugs from runtime crashes to compile-time errors — where they’re far cheaper to fix.
Type Coercion and Promotion
Some type mismatches are automatically resolved. When you add an int and a float in C, the int is promoted to a float before the operation — the semantic analyser inserts an implicit conversion node into the AST. This is distinct from an error; it’s a defined language rule.
int a = 5;
float b = 2.5;
float c = a + b; // a promoted to float; valid
An actual type error is something the language defines as illegal:
int* p = "hello"; // pointer-to-int cannot hold string literal
Scope Resolution
A scope is a region of the programme where a name is visible. Most languages use lexical scoping (also called static scoping): the scope of a name is determined by where it’s declared in the source text, not by the call stack at runtime.
When the semantic analyser encounters a name, it searches scopes from innermost to outermost:
- The current block (local variables)
- The enclosing function (parameters and locals of outer blocks)
- The global scope (global variables and function declarations)
If the name is found, its declaration is used. If not, it’s an undeclared-identifier error. If a name in an inner scope matches one in an outer scope, the inner name shadows the outer one — the outer declaration is temporarily invisible.
Why Scope Rules Matter
Scope rules prevent:
- One function’s local variables from interfering with another’s
- Accidentally using a variable before it enters scope
- Name collisions across large codebases (different modules can reuse names freely)
Common Semantic Errors
| Error type | Example | What’s wrong |
|---|---|---|
| Undeclared identifier | x = z + 1; | z never declared |
| Type mismatch | int x = "hi"; | string cannot be stored in int |
| Wrong argument count | add(1, 2, 3) | add takes two parameters |
| Wrong argument type | sqrt("hello") | sqrt expects a numeric argument |
| Use before initialisation | int x; y = x + 1; | x has no value yet |
| Return type mismatch | int f() { return "ok"; } | function declared to return int |
| Break outside loop | break; at top level | break only valid inside loops |
Each of these is syntactically valid — the parser accepts them all. Only semantic analysis catches them.
The Annotated AST
The output of semantic analysis is an AST where every node carries type information. For x = 5 + y (where x is int and y is float):
Assign (=) :int
├── x :int
└── Add (+) :float ← implicit promotion: result is float
├── 5 :int ← widened to float
└── y :float
The type annotations tell the IR generator exactly what kind of operation to emit at each step — integer addition, floating-point addition, or a conversion between the two. Without this information, the code generator would have no idea which machine instruction to use.
What Comes Next
With a type-checked, annotated AST, the compiler moves to Intermediate Code Generation — Stage 4. The front end’s job is complete: the source code has been tokenised, parsed, and validated for both structure and meaning. The back end now takes over, starting by converting the AST into a machine-independent intermediate representation that the optimiser can work on.