Step 04 · Typecheck and scope

The typechecker in typecheck.cpp is a single AST walk. Three responsibilities:

  1. Function table — collect every fn name and remember its arity.
  2. main requirements — must exist (E0411), must take zero parameters (E0412), no duplicate definitions (E0410).
  3. Per-function scope walk:
    • Variables must be let-introduced before use (E0300).
    • Assignment requires prior declaration (E0302).
    • Calls must resolve to a known function with matching arity (E0420, E0421).

That's it. There's no actual type checking because everything is i64. That's the right starting point: it disentangles "is it syntactically and semantically well-formed?" from "is it type-correct?". You can graft a real type system on top later (introduce i64/bool/str, unify across operators, infer generics) without touching the parser.

Scopes are sets, not stacks

std::unordered_set<std::string> scope(f.params.begin(), f.params.end());

Snapshotting the scope before each branch (auto sc1 = scope; ...) gives the right semantics without a linked structure. It's O(n × m) in pathological deep nesting but blazingly fast in practice.

A production typechecker uses a stack of scopes for shadowing (let x = 1; { let x = 2; print x; } print x; → 2 then 1). Our language doesn't allow shadowing — let x = 1; let x = 2; would quietly clobber, which is a UX bug we'd fix by adding an E0303 diagnostic.

Why typecheck before IR emission

  • Better errors. "Unknown function fbn" with caret at the call site is far nicer than llc: undefined symbol _fbn.
  • Performance. We don't waste time generating IR for code that's going to be rejected.
  • Layering. IR emission can assume the AST is well-formed — fewer if checks, simpler code.

The typechecker is also reused unmodified by the check subcommand, which is the building block for IDEs.