Learn Zig Series (#134) - Type Checking

What will I learn?
- Why a program that parses perfectly can still be complete nonsense, and what a type checker actually is -- the stage that asks "does this mean something?" of every node;
- How to model the types of a tiny language as a Zig enum, and why a checker is really just one more pass over the AST we built last episode;
- How to walk an expression tree bottom-up to infer the type of every sub-expression, and reject
1 + truebefore it ever runs; - How a type environment (a scoped symbol table) records which names are defined and what type they hold, so
x + 1can be checked against thelet x = ...that introducedx; - How the source span we added last episode lets every type error point at exactly the line and column where it went wrong;
- How to test a checker by feeding it programs that must pass and programs that must fail, and asserting on the kind of failure;
- How C, Rust, Go and Zig itself structure their real type checkers, and why yours is the same shape scaled down;
- Three exercises to push your checker toward a real one before the next episode.
Requirements
- A working modern computer running macOS, Windows or Ubuntu;
- An installed Zig 0.14+ distribution (download from ziglang.org);
- The redesigned AST from episode 133 (the
Exprstruct with aspanand akindunion) and the visitor machinery fresh in mind -- today we write a real pass over exactly that tree; - Tagged unions from episode 6, pointers from episode 8, allocators from episode 7, and hash maps from episode 22;
- The ambition to learn Zig programming.
Difficulty
- Advanced
Curriculum (of the Learn Zig Series):
- Zig Programming Tutorial - ep001 - Intro
- Learn Zig Series (#2) - Hello Zig, Variables and Types
- Learn Zig Series (#3) - Functions and Control Flow
- Learn Zig Series (#4) - Error Handling (Zig's Best Feature)
- Learn Zig Series (#5) - Arrays, Slices, and Strings
- Learn Zig Series (#6) - Structs, Enums, and Tagged Unions
- Learn Zig Series (#7) - Memory Management and Allocators
- Learn Zig Series (#8) - Pointers and Memory Layout
- Learn Zig Series (#9) - Comptime (Zig's Superpower)
- Learn Zig Series (#10) - Project Structure, Modules, and File I/O
- Learn Zig Series (#11) - Mini Project: Building a Step Sequencer
- Learn Zig Series (#12) - Testing and Test-Driven Development
- Learn Zig Series (#13) - Interfaces via Type Erasure
- Learn Zig Series (#14) - Generics with Comptime Parameters
- Learn Zig Series (#15) - The Build System (build.zig)
- Learn Zig Series (#16) - Sentinel-Terminated Types and C Strings
- Learn Zig Series (#17) - Packed Structs and Bit Manipulation
- Learn Zig Series (#18b) - Addendum: Async Returns in Zig 0.16
- Learn Zig Series (#19) - SIMD with @Vector
- Learn Zig Series (#20) - Working with JSON
- Learn Zig Series (#21) - Networking and TCP Sockets
- Learn Zig Series (#22) - Hash Maps and Data Structures
- Learn Zig Series (#23) - Iterators and Lazy Evaluation
- Learn Zig Series (#24) - Logging, Formatting, and Debug Output
- Learn Zig Series (#25) - Mini Project: HTTP Status Checker
- Learn Zig Series (#26) - Writing a Custom Allocator
- Learn Zig Series (#27) - C Interop: Calling C from Zig
- Learn Zig Series (#28) - C Interop: Exposing Zig to C
- Learn Zig Series (#29) - Inline Assembly and Low-Level Control
- Learn Zig Series (#30) - Thread Safety and Atomics
- Learn Zig Series (#31) - Memory-Mapped I/O and Files
- Learn Zig Series (#32) - Compile-Time Reflection with @typeInfo
- Learn Zig Series (#33) - Building a State Machine with Tagged Unions
- Learn Zig Series (#34) - Performance Profiling and Optimization
- Learn Zig Series (#35) - Cross-Compilation and Target Triples
- Learn Zig Series (#36) - Mini Project: CLI Task Runner
- Learn Zig Series (#37) - Markdown to HTML: Tokenizer and Lexer
- Learn Zig Series (#38) - Markdown to HTML: Parser and AST
- Learn Zig Series (#39) - Markdown to HTML: Renderer and CLI
- Learn Zig Series (#40) - Key-Value Store: In-Memory Store
- Learn Zig Series (#41) - Key-Value Store: Write-Ahead Log
- Learn Zig Series (#42) - Key-Value Store: TCP Server
- Learn Zig Series (#43) - Key-Value Store: Client Library and Benchmarks
- Learn Zig Series (#44) - Image Tool: Reading and Writing PPM/BMP
- Learn Zig Series (#45) - Image Tool: Pixel Operations
- Learn Zig Series (#46) - Image Tool: CLI Pipeline
- Learn Zig Series (#47) - Build a Shell: Parsing Commands
- Learn Zig Series (#48) - Build a Shell: Process Spawning
- Learn Zig Series (#49) - Build a Shell: Built-in Commands
- Learn Zig Series (#50) - Build a Shell: Job Control and Signals
- Learn Zig Series (#51) - HTTP Server: Accept Loop and Parsing
- Learn Zig Series (#52) - HTTP Server: Router and Responses
- Learn Zig Series (#53) - HTTP Server: Static Files and MIME
- Learn Zig Series (#54) - HTTP Server: Middleware and Logging
- Learn Zig Series (#55) - ECS Game Engine: Architecture
- Learn Zig Series (#56) - ECS Game Engine: Component Storage
- Learn Zig Series (#57) - ECS Game Engine: Systems and Queries
- Learn Zig Series (#58) - ECS Game Engine: Terminal Rendering
- Learn Zig Series (#59) - Assembler: Instruction Encoding
- Learn Zig Series (#60) - Assembler: Two-Pass Assembly
- Learn Zig Series (#61) - Assembler: Disassembler and Binary Inspector
- Learn Zig Series (#62) - File Systems: Reading Directories and Metadata
- Learn Zig Series (#63) - File Watching: Detecting Changes
- Learn Zig Series (#64) - Process Management: Fork, Exec, Wait
- Learn Zig Series (#65) - Pipes and Inter-Process Communication
- Learn Zig Series (#66) - Shared Memory and Semaphores
- Learn Zig Series (#67) - Signal Handling Deep Dive
- Learn Zig Series (#68) - Unix Domain Sockets
- Learn Zig Series (#69) - Daemonization: Background Services
- Learn Zig Series (#70) - Timers and Scheduling
- Learn Zig Series (#71) - Resource Limits and Capabilities
- Learn Zig Series (#72) - System Call Wrappers
- Learn Zig Series (#73) - seccomp and Sandboxing
- Learn Zig Series (#74) - ptrace: Process Tracing
- Learn Zig Series (#75) - Reading Kernel State from /proc and /sys
- Learn Zig Series (#76) - Mini Project: Process Monitor
- Learn Zig Series (#77) - Mini Project: File Sync Tool - Part 1
- Learn Zig Series (#78) - Mini Project: File Sync Tool - Part 2: Delta Transfer
- Learn Zig Series (#79) - Mini Project: File Sync Tool - Part 3: Network Protocol
- Learn Zig Series (#80) - Mini Project: File Sync Tool - Part 4: Polish
- Learn Zig Series (#81) - UDP Sockets and Datagrams
- Learn Zig Series (#82) - DNS Resolver from Scratch
- Learn Zig Series (#83) - DNS Server Implementation
- Learn Zig Series (#84) - HTTP/1.1 Deep Dive
- Learn Zig Series (#85) - HTTP/2 Frames and Streams
- Learn Zig Series (#86) - TLS via C Interop
- Learn Zig Series (#87) - WebSocket Protocol
- Learn Zig Series (#88) - WebSocket Server
- Learn Zig Series (#89) - MQTT Messaging Protocol
- Learn Zig Series (#90) - Protocol Buffers Serialization
- Learn Zig Series (#91) - MessagePack Format
- Learn Zig Series (#92) - gRPC Service in Zig
- Learn Zig Series (#93) - SOCKS5 Proxy
- Learn Zig Series (#94) - NAT Traversal and Hole Punching
- Learn Zig Series (#95) - Mini Project: Chat Server - Protocol Design
- Learn Zig Series (#96) - Mini Project: Chat Server - Server Core
- Learn Zig Series (#97) - Mini Project: Chat Server - Client TUI
- Learn Zig Series (#98) - Mini Project: Chat Server - Rooms and History
- Learn Zig Series (#99) - Mini Project: DNS-over-HTTPS Proxy
- Learn Zig Series (#100) - Mini Project: Port Scanner
- Learn Zig Series (#101) - Mini Project: HTTP Load Tester - Part 1
- Learn Zig Series (#102) - Mini Project: HTTP Load Tester - Part 2
- Learn Zig Series (#103) - Mini Project: Reverse Proxy - Routing
- Learn Zig Series (#104) - Mini Project: Reverse Proxy - Load Balancing
- Learn Zig Series (#105) - Mini Project: Reverse Proxy - Health Checks
- Learn Zig Series (#106) - Linked Lists: Singly and Doubly
- Learn Zig Series (#107) - Skip Lists
- Learn Zig Series (#108) - B-Trees
- Learn Zig Series (#109) - Red-Black Trees
- Learn Zig Series (#110) - Tries: Prefix Trees
- Learn Zig Series (#111) - Bloom Filters
- Learn Zig Series (#112) - Cuckoo Filters
- Learn Zig Series (#113) - Ring Buffers: Lock-Free
- Learn Zig Series (#114) - Memory Pools
- Learn Zig Series (#115) - Slab Allocators
- Learn Zig Series (#116) - Sorting Algorithms in Zig
- Learn Zig Series (#117) - Binary Search Variations
- Learn Zig Series (#118) - Graph Representation
- Learn Zig Series (#119) - BFS and DFS
- Learn Zig Series (#120) - Dijkstra and A*
- Learn Zig Series (#121) - Topological Sort
- Learn Zig Series (#122) - Union-Find
- Learn Zig Series (#123) - LRU Cache
- Learn Zig Series (#124) - Consistent Hashing
- Learn Zig Series (#125) - Mini Project: Search Engine - Inverted Index
- Learn Zig Series (#126) - Mini Project: Search Engine - TF-IDF
- Learn Zig Series (#127) - Mini Project: Search Engine - Query Parser
- Learn Zig Series (#128) - Mini Project: Database Engine - Page Storage
- Learn Zig Series (#129) - Mini Project: Database Engine - B-Tree Index
- Learn Zig Series (#130) - Mini Project: Database Engine - SQL Parser
- Learn Zig Series (#131) - Lexing a Simple Language
- Learn Zig Series (#132) - Recursive Descent Parsing
- Learn Zig Series (#133) - AST Design and Traversal
- Learn Zig Series (#134) - Type Checking (this post)
Learn Zig Series (#134) - Type Checking
Last episode I ended on a promise disguised as a complaint. I wrote let x = 1 + true;, pointed out that it walks perfectly through every pass we had built -- the visitor visits it, the constant folder folds it, the pretty-printer prints it -- and yet it means absolutely nothing. You cannot add an integer to a boolean. The parser was happy. The traversals were happy. And the program was still wrong. I called catching that class of nonsense "the next brick", and said it would walk this exact tree with this exact visitor machinery but ask a sharper question of every node: not "what shape is this?" but "does this actually mean something?". This is that brick.
The stage that asks that question is the type checker, and it is the first place in a compiler where the machine starts to understand your program rather than merely re-arrange it. Up to now every pass has been about form: characters into tokens, tokens into a tree, tree into a cleaner tree. Type checking is the first pass about meaning. It is where 1 + true finally gets told "no", where a name used before it is defined finally gets caught, and -- crucially -- where the error message can say exactly where thanks to the span we bolted onto every node last episode. Let's dive right in!
Solutions to Episode 133 Exercises
As always, the three exercises first, with complete code. All three assume the redesigned Expr from last episode -- a struct with a span and a kind union whose variants include int, float, boolean, ident, unary, binary and call.
Exercise 1 -- A depth-measuring visitor. The task was to report the maximum nesting depth of an expression: 2 for 2 + 3, and 3 for 2 + 3 * 4. As I hinted, the flat visit(node) visitor genuinely cannot do this, because it has no notion of "how deep am I". So we write it as an explicit recursion in the spirit of printTree: each leaf is depth 1, and each internal node is one more than the deepest of its children.
fn maxDepth(e: *const Expr) usize {
return switch (e.kind) {
.int, .float, .boolean, .ident => 1,
.unary => |u| 1 + maxDepth(u.rhs),
.binary => |b| 1 + @max(maxDepth(b.lhs), maxDepth(b.rhs)),
.call => |c| blk: {
var deepest: usize = 0;
for (c.args) |arg| deepest = @max(deepest, maxDepth(arg));
break :blk 1 + deepest;
},
};
}
The whole trick is that depth is a value that flows up from the leaves, and the generic visitor only ever flows information sideways (into the visitor's own state). Any question whose answer is "computed from the children's answers" wants an explicit recursion that returns something, not a visit that mutates. The test pins both cases down:
test "maxDepth: 2 + 3 is depth 2, 2 + 3 * 4 is depth 3" {
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena.deinit();
const a = arena.allocator();
const sp = Span{ .line = 1, .col = 1 };
const two = try makeExpr(a, sp, .{ .int = 2 });
const three = try makeExpr(a, sp, .{ .int = 3 });
const four = try makeExpr(a, sp, .{ .int = 4 });
const shallow = try makeExpr(a, sp, .{ .binary = .{ .op = .plus, .lhs = two, .rhs = three } });
try std.testing.expectEqual(@as(usize, 2), maxDepth(shallow));
const mul = try makeExpr(a, sp, .{ .binary = .{ .op = .star, .lhs = three, .rhs = four } });
const deep = try makeExpr(a, sp, .{ .binary = .{ .op = .plus, .lhs = two, .rhs = mul } });
try std.testing.expectEqual(@as(usize, 3), maxDepth(deep));
}
Exercise 2 -- Extend constant folding to booleans and comparisons. Last episode fold only handled integer arithmetic. The task was to fold comparisons of two integer constants down to a boolean node (3 < 4 becomes bool true), and to fold and/or of two boolean constants, keeping the "return null, fall through to rebuild" discipline for anything that cannot fold. The additions live entirely inside the .binary arm:
fn foldBinary(op: TokenKind, l: *Expr, r: *Expr) ?Expr.Kind {
if (l.kind == .int and r.kind == .int) {
const lv = l.kind.int;
const rv = r.kind.int;
return switch (op) {
.plus => .{ .int = lv + rv },
.minus => .{ .int = lv - rv },
.star => .{ .int = lv * rv },
.lt => .{ .boolean = lv < rv },
.lte => .{ .boolean = lv <= rv },
.gt => .{ .boolean = lv > rv },
.gte => .{ .boolean = lv >= rv },
else => null,
};
}
if (l.kind == .boolean and r.kind == .boolean) {
const lb = l.kind.boolean;
const rb = r.kind.boolean;
return switch (op) {
.kw_and => .{ .boolean = lb and rb },
.kw_or => .{ .boolean = lb or rb },
else => null,
};
}
return null;
}
I pulled the decision out into its own helper that returns an optional Expr.Kind -- null means "cannot fold this, rebuild the node instead". That keeps the recursive fold tidy: it folds both children, asks foldBinary, and either stamps the folded kind onto a fresh node or rebuilds. The chained folding is the satisfying part -- 2 * 3 < 10 folds 2 * 3 to 6 first, then 6 < 10 to true, because the recursion handles the children before the parent:
test "2 * 3 < 10 folds all the way to bool true" {
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena.deinit();
const a = arena.allocator();
const sp = Span{ .line = 1, .col = 1 };
const two = try makeExpr(a, sp, .{ .int = 2 });
const three = try makeExpr(a, sp, .{ .int = 3 });
const ten = try makeExpr(a, sp, .{ .int = 10 });
const mul = try makeExpr(a, sp, .{ .binary = .{ .op = .star, .lhs = two, .rhs = three } });
const cmp = try makeExpr(a, sp, .{ .binary = .{ .op = .lt, .lhs = mul, .rhs = ten } });
const folded = try fold(a, cmp);
try std.testing.expect(folded.kind == .boolean);
try std.testing.expectEqual(true, folded.kind.boolean);
}
Exercise 3 -- Finding undefined variables, the first half. The task was a flat set-difference: walk a statement list, gather every name introduced by a let, gather every name used in an expression, and report any used name that was never defined. No scopes, no ordering -- just the naive version, because building the naive version first is the best way to feel why the real one (which we start today) needs more machinery. Two std.StringHashMap(void) sets do it:
fn collectUsed(e: *const Expr, used: *std.StringHashMap(void)) !void {
switch (e.kind) {
.ident => |name| try used.put(name, {}),
.unary => |u| try collectUsed(u.rhs, used),
.binary => |b| {
try collectUsed(b.lhs, used);
try collectUsed(b.rhs, used);
},
else => {},
}
}
fn firstUndefined(alloc: std.mem.Allocator, stmts: []const Stmt) !?[]const u8 {
var defined = std.StringHashMap(void).init(alloc);
defer defined.deinit();
var used = std.StringHashMap(void).init(alloc);
defer used.deinit();
for (stmts) |s| switch (s) {
.let => |l| {
try collectUsed(l.value, &used);
try defined.put(l.name, {});
},
.expr => |e| try collectUsed(e, &used),
};
var it = used.keyIterator();
while (it.next()) |key| {
if (!defined.contains(key.*)) return key.*;
}
return null;
}
I called this "a genuine miniature" of today's analysis, and you can already smell what is missing. It has no idea that let y = y + 1; uses y before it is defined -- it lumps all definitions and all uses into two flat bags and compares them, blind to order. That blindness is exactly the machinery gap today's real checker closes: it processes statements top to bottom, so a name only counts as defined once its let has actually been seen. On to the real thing.
What a type checker actually is
Strip away the intimidation and a type checker is a startlingly simple idea: walk the AST and give every expression a type, refusing to continue the moment two types that should agree do not. That is the whole game. 2 has type int. true has type boolean. 2 + 3 has type int, because + takes two integers and produces an integer. And 2 + true? That has no type -- there is no rule that says what integer-plus-boolean means -- so the checker stops and reports an error. Every type system you have ever fought with, from C's to Rust's, is this same loop wearing progressively heavier armour.
For our little language, the universe of types is tiny, so we model it as a plain enum. Three types: integers, floats, booleans. An enum is exactly right here -- a closed set of named alternatives (episode 6), cheap to copy and cheap to compare:
const Type = enum {
int,
float,
boolean,
fn name(self: Type) []const u8 {
return switch (self) {
.int => "int",
.float => "float",
.boolean => "boolean",
};
}
};
That name helper is not decoration -- it is what turns a machine-friendly Type.int into the human-friendly string a diagnostic prints. A real compiler's Type is a far richer thing (it carries struct fields, function signatures, generic parameters, lifetimes), but the role is identical: it is the answer to the question "what is this expression?", and the checker's job is to compute that answer for every node and make sure the answers are consistent.
Inferring the type of an expression
The checker mirrors the visitor's shape from last episode, but with one decisive difference: where walkExpr returned nothing and mutated a visitor, checkExpr returns a Type (or an error). It is a function from a node to its type, defined recursively over the tree. Leaves are trivial -- an integer literal is an int, no thought required. Internal nodes check their children first, then apply the rule for their operator. Here is the skeleton for the literal and unary cases:
const Checker = struct {
env: std.StringHashMap(Type),
err_span: Span = .{ .line = 0, .col = 0 },
err_msg: []const u8 = "",
const TypeError = error{ UndefinedVariable, TypeMismatch, OutOfMemory };
fn fail(self: *Checker, span: Span, msg: []const u8) TypeError {
self.err_span = span;
self.err_msg = msg;
return error.TypeMismatch;
}
fn checkExpr(self: *Checker, e: *const Expr) TypeError!Type {
switch (e.kind) {
.int => return .int,
.float => return .float,
.boolean => return .boolean,
.ident => |nm| return self.env.get(nm) orelse {
self.err_span = e.span;
self.err_msg = "use of undefined variable";
return error.UndefinedVariable;
},
.unary => |u| {
const rhs = try self.checkExpr(u.rhs);
return switch (u.op) {
.minus => if (rhs == .int or rhs == .float) rhs else self.fail(e.span, "unary '-' needs a numeric operand"),
.bang => if (rhs == .boolean) .boolean else self.fail(e.span, "unary '!' needs a boolean operand"),
else => self.fail(e.span, "unsupported unary operator"),
};
},
.binary => |b| {
const l = try self.checkExpr(b.lhs);
const r = try self.checkExpr(b.rhs);
return self.checkBinary(e.span, b.op, l, r);
},
}
}
};
Look at how the recursion carries the error out. Because checkExpr returns TypeError!Type, a try on the left operand of a binary node means: if checking the left child already failed, we never even look at the right one -- the error propagates straight up to the caller, carrying the span and message we stashed. That is Zig's error unions (episode 4) doing precisely what they are for. The orelse on the identifier case is the same idea in miniature: env.get(nm) returns an optional, and orelse is where "this name is not in scope" becomes a real, positioned error in stead of a silent null.
The rules live in one place
I split the binary operator rules into their own function on purpose. Every interesting type rule in this language is a statement about a binary operator -- arithmetic wants two matching numbers and gives that number back, comparison wants two matching numbers and gives a boolean, and/or want two booleans, equality wants two of anything so long as they match. Putting them in one switch makes the whole type system readable at a glance, and makes adding a new operator a one-line change:
fn checkBinary(self: *Checker, span: Span, op: TokenKind, l: Type, r: Type) Checker.TypeError!Type {
switch (op) {
.plus, .minus, .star, .slash => {
if (l == r and (l == .int or l == .float)) return l;
return self.fail(span, "arithmetic needs two operands of the same numeric type");
},
.lt, .lte, .gt, .gte => {
if (l == r and (l == .int or l == .float)) return .boolean;
return self.fail(span, "comparison needs two operands of the same numeric type");
},
.eq, .neq => {
if (l == r) return .boolean;
return self.fail(span, "'==' needs both sides to be the same type");
},
.kw_and, .kw_or => {
if (l == .boolean and r == .boolean) return .boolean;
return self.fail(span, "'and'/'or' need boolean operands");
},
else => return self.fail(span, "unsupported binary operator"),
}
}
Notice the design decision hiding in .plus: we demand l == r and numeric. That is a language design choice, not a law of nature -- it means 1 + 1.5 is an error in our language, because we refuse to silently promote an int to a float. C would happily promote; Zig itself refuses implicit widening between runtime i32 and f64 and makes you write the cast, which is exactly the philosophy I am copying here. "No hidden coercions" is a defensible, honest stance, and it makes the checker's rules dead simple to state and to test. If later you want mixed arithmetic, this one function is the only place you would loosen the rule -- the entire policy of the language sits in this single switch, which is the payoff of not scattering it across the tree walk.
Names and scope: the type environment
Expressions do not live in a vacuum -- they mention variables, and a variable's type comes from wherever it was introduced. That "wherever" is the type environment: a map from name to type that we fill in as we process let bindings and consult whenever we hit an identifier. We already leaned on it above (self.env.get(nm)); now let us drive it from statements. Statements are a second small union -- a let that introduces a name, or a bare expression:
const Stmt = union(enum) {
let: struct { name: []const u8, value: *Expr, span: Span },
expr: *Expr,
};
fn checkStmt(self: *Checker, s: *const Stmt) Checker.TypeError!void {
switch (s.*) {
.let => |l| {
const t = try self.checkExpr(l.value);
try self.env.put(l.name, t);
},
.expr => |e| {
_ = try self.checkExpr(e);
},
}
}
fn checkProgram(self: *Checker, stmts: []const Stmt) Checker.TypeError!void {
for (stmts) |*s| try self.checkStmt(s);
}
Here is the subtle, important thing, and it is exactly what exercise 3 was missing. We check the let's value before we put the name into the environment. So in let x = x + 1;, when we check x + 1, x is not in the environment yet, and the checker correctly reports x as undefined -- order matters, and processing statements top-to-bottom while updating the environment as we go is what gives us ordering for free. The flat two-bag version from exercise 3 could never see that, because it gathered all definitions before comparing. This is the machinery gap closing in real time.
The type of x is inferred, by the way -- notice there is no type annotation in let x = 2 + 3;. We compute the value's type (int) and bind the name to it. That is a baby version of the local type inference that Rust, Go, and modern C++ all do: you do not write the type, the compiler works it out from the initialiser. Our version is trivial because expressions have exactly one obvious type, but the shape -- "check the right-hand side, bind its type to the name" -- is the real thing.
Testing the checker: programs that must pass, and programs that must fail
A checker earns trust by rejecting exactly the wrong programs and accepting exactly the right ones, so its tests come in two flavours. First, the happy path: a small well-typed program must type-check cleanly, and the environment must end up holding the types we expect. We build let x = 2 + 3; let ok = x < 10; by hand and assert x is int and ok is boolean:
test "a well-typed program checks and infers the right types" {
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena.deinit();
const a = arena.allocator();
const sp = Span{ .line = 1, .col = 1 };
const two = try makeExpr(a, sp, .{ .int = 2 });
const three = try makeExpr(a, sp, .{ .int = 3 });
const sum = try makeExpr(a, sp, .{ .binary = .{ .op = .plus, .lhs = two, .rhs = three } });
const x_ref = try makeExpr(a, sp, .{ .ident = "x" });
const ten = try makeExpr(a, sp, .{ .int = 10 });
const cmp = try makeExpr(a, sp, .{ .binary = .{ .op = .lt, .lhs = x_ref, .rhs = ten } });
const stmts = [_]Stmt{
.{ .let = .{ .name = "x", .value = sum, .span = sp } },
.{ .let = .{ .name = "ok", .value = cmp, .span = sp } },
};
var checker = Checker{ .env = std.StringHashMap(Type).init(a) };
defer checker.env.deinit();
try checker.checkProgram(&stmts);
try std.testing.expectEqual(Type.int, checker.env.get("x").?);
try std.testing.expectEqual(Type.boolean, checker.env.get("ok").?);
}
Second, and just as important, the sad path: the program that must be rejected, and rejected for the right reason. This is where 1 + true finally gets its comeuppance. We assert not just that checking fails, but that it fails with error.TypeMismatch specifically, and that the recorded message is the arithmetic one -- because a checker that fails with the wrong error is as buggy as one that does not fail at all:
test "1 + true is rejected as a type mismatch" {
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena.deinit();
const a = arena.allocator();
const sp = Span{ .line = 3, .col = 9 };
const one = try makeExpr(a, sp, .{ .int = 1 });
const tru = try makeExpr(a, sp, .{ .boolean = true });
const bad = try makeExpr(a, sp, .{ .binary = .{ .op = .plus, .lhs = one, .rhs = tru } });
var checker = Checker{ .env = std.StringHashMap(Type).init(a) };
defer checker.env.deinit();
try std.testing.expectError(error.TypeMismatch, checker.checkExpr(bad));
try std.testing.expectEqual(@as(usize, 3), checker.err_span.line);
try std.testing.expectEqual(@as(usize, 9), checker.err_span.col);
}
That last pair of assertions is the whole reason we suffered through adding a span to every node last episode. The checker did not just say "type error somewhere" -- it handed back line 3, column 9, the exact spot the offending + was parsed from. A real compiler would feed that span, plus err_msg, into a diagnostic renderer that underlines the source line with a little caret. We built the plumbing; the pretty-printing is a formatting detail on top of it.
Where this sits among the grown-ups
None of this is a toy simplification of how real compilers work -- it is the same architecture with more cases bolted on, and the same vocabulary. In C, the type checker (in GCC or Clang's Sema) walks the AST computing a type for every expression node and issuing the diagnostics you know and love ("invalid operands to binary +"), gated by C's promotion rules, which are exactly the coercions we chose not to have. In Rust, rustc's HIR type-checking phase does local inference (that is what lets you skip most type annotations) followed by trait resolution, and its error messages carry Span values that are quite literally the same idea as ours, line and column of the offending code. In Go, the go/types package is a standalone type checker over the go/ast tree that any tool can call, and Go's own compiler tracks a source position on every node so go build can point at the right line -- I mentioned this last episode, and here is where that position finally earns its keep.
And Zig itself? Zig's type checking is unusual because so much of it happens at comptime during semantic analysis, where types are first-class values you can compute with (episode 32's @typeInfo is a peek into that machinery). But the core loop is the same one we wrote today: figure out the type of each expression, and refuse to compile when the types do not line up. The error: incompatible types: 'i64' and 'bool' that Zig throws at you is our error.TypeMismatch in a much bigger coat. What you have built here is the small, honest heart of the very thing every one of these compilers stands on.
Where this is heading
Take stock. We have a Type, a checkExpr that infers the type of any expression by walking the tree bottom-up, a checkBinary that holds the entire type policy of the language in one readable switch, a type environment that gives ordered scope to names, and a checkProgram that ties it together -- rejecting 1 + true and let x = x + 1; alike, each with a span pointing at the crime scene. The program has finally been asked "do you mean something?" and made to answer.
Here is what that unlocks. Once you know the type of every expression, you know things the earlier stages could only guess at: how many bytes each value needs, which machine operation a + should become (integer add versus float add), whether a comparison is signed. A parser cannot tell you that; a type checker can. That knowledge is precisely the input the next stage of this arc needs, because we are about to stop interpreting the tree by walking it and start compiling it down to something lower and faster -- a flat, linear sequence of simple instructions that a small machine can chew through without ever touching the tree again. The tree got us to meaning; the next brick turns meaning into something that runs. Build the checker, feed it a handful of good and bad programs until you trust its verdicts, and you will have the last tree-shaped stage before we go flat. ;-)
Exercises
Add a
stringtype and typed concatenation. Extend theTypeenum with astringvariant, teachcheckExprthat a string-literal node has typestring, and add a rule tocheckBinaryso that+applied to two strings is legal and produces astring-- while"a" + 1remains a type mismatch. Write one test that astring + stringchecks tostring, and one thatstring + intis rejected witherror.TypeMismatch.Report the two conflicting types in the message. Right now
checkBinaryfails with a generic "arithmetic needs two operands of the same numeric type". Changefail(or add a variant of it) so the recordederr_msgnames the actual offenders -- something like "cannot add int and boolean" -- by formatting the twoType.name()strings into an allocated string. Test that checking1 + trueproduces a message that contains both "int" and "boolean".Introduce block scoping. Our single flat environment means a name, once defined, is defined forever. Give the checker a stack of environments: pushing a new scope on entering a block, popping it on exit, and looking a name up by searching from the innermost scope outward. Then a name defined inside a block must be invisible after the block ends. You do not have
if/block statements in the AST yet, so add a minimalblock: []const Stmtstatement variant to drive it. Test that a namelet-bound inside a block is reported as undefined when used after the block closes.
Bedankt en tot de volgende keer -- one brick further down the road, and the tree finally starts to run! ;-)