7.5 Frontend Architecture
A compiler frontend turns source text into a checked, well-linked program representation. Its phases have different responsibilities, and keeping those boundaries clear makes both features and diagnostics manageable:
source -> tokens -> CST/AST -> desugared AST
-> declarations/scopes -> resolved AST
-> types and semantic checks -> typed AST
-> IR loweringEach arrow is a contract. The parser must never invent a symbol binding. The resolver must not decide machine instructions. The type checker should receive an AST whose names already resolve, so it can report “argument 2 has type string” instead of repeatedly asking what a name means.
A Pass Should State Its Input, Output, and Errors
For every pass, write three things: input representation, output representation, and error invariants. The lexer outputs ordered tokens and lexical diagnostics. The parser outputs a tree plus syntax errors. The resolver outputs bindings plus name errors. The type checker outputs types plus semantic errors. A pass may continue after recoverable errors, but it must produce explicit error nodes/types so the next pass does not crash or create a storm of duplicate messages.
Laying those contracts out as a table makes them concrete:
| pass | input | output | error kind |
|---|---|---|---|
| lexer | source text | ordered tokens | lexical error |
| parser | tokens | CST/AST | syntax error |
| resolver | AST | resolved AST + bindings | name error |
| type checker | resolved AST | typed AST | semantic error |
| lowering | typed AST | IR | (no new frontend errors) |
Each row's output is the next row's input; the moment any row starts doing the next row's job (for example, the parser inventing a binding) the boundary is broken.
Do not use a giant mutable “compiler context” as the only interface. It becomes unclear which facts are valid at which point. Prefer a small immutable compilation unit, a diagnostic sink, interners/arenas with clear ownership, and typed pass results. Cache only after you can explain invalidation: a cached type result is invalid when the expression, its binding, or a type declaration it depends on changes.
Diagnostics and Testing Cross the Whole Frontend
Good diagnostics need facts from many passes: parser ranges, resolver declaration locations, type checker expected/actual types, and import origins. Store structured diagnostics rather than immediately printing strings. This lets CLI, editor, JSON, and test snapshots render the same fact consistently.
Test every layer alone, then test handoffs. Lexer tests should assert token spans; parser tests should snapshot AST shape; resolver tests should assert symbol IDs and shadowing; type tests should assert both type and error ranges. Finally, use end-to-end programs that deliberately contain one error at each layer. A frontend is trustworthy when its phases agree about both valid programs and invalid ones.
Architecture Hint
When a feature feels hard, draw the data it must add to every phase. If adding import requires parser changes, resolver edges, symbol origins, diagnostics, and type visibility but no backend changes, that map protects you from changing unrelated modules.
Follow One Program Through the Passes
For import math; let r = math.pi; print(r);, lexing creates token kinds and ranges. Parsing builds an import declaration, a let declaration, member access, and a call. Module resolution binds math; name resolution binds r; type checking discovers the type of pi and verifies the call. Each stage adds one kind of fact without rewriting facts owned by another stage.
When math.pi fails, the frontend should distinguish several cases: the module is missing, the module loaded but does not export pi, pi exists but is private, or math was shadowed by a local value. These require different resolver facts and different repair advice. A single string-based lookup cannot produce that quality of diagnostic.
Error Tolerance Is an Architectural Choice
An IDE wants a useful tree for let x = ; print(x);, while a batch compiler may stop after a threshold. Both need explicit recovery artifacts: missing-expression nodes, error symbols, and error types. Later passes should propagate these quietly where possible. If the resolver already knows x is invalid, the type checker should avoid producing five unrelated messages about it.
Pass Boundaries to Review
- Does every pass accept inputs with the documented recovery nodes?
- Are diagnostics structured data until the final rendering layer?
- Can a failed pass return a partial but internally consistent result?
- Is cache invalidation tied to the semantic facts a result actually depends on?