The type checker resolves receiver types, method dispatch targets, expression types. It writes those resolutions into side tables — expr_types, handle_types, actor_types. Codegen reads from those tables.
The original design had a safety net: if codegen couldn’t find a type in the table, it would try to reconstruct it from the AST. Look at the identifier name, check what it looked like, make a reasonable guess.
Here’s what that looked like at a method call dispatch site:
// Try the resolved type table first
if (auto resolved = expr_types.find(mc.receiver->id)) {
return dispatch_on(*resolved);
}
// Fall back: infer from AST identifier name
return dispatch_by_name(mc.receiver->ident.name);This looks reasonable at first. It is not. It means the compiler will produce output regardless of whether the type checker’s work actually made it through — and if the fallback guesses wrong, you get code that compiles, links, runs, and does the wrong thing.
The fallback existed because there were cases where it was needed. Which meant there were cases where checker output wasn’t reaching codegen reliably, and nobody knew exactly which ones or why.
The bug that made it undeniable
A receiver type was being dropped between the checker and codegen when a specific combination of generic bounds was present. The checker resolved it correctly — the right type was in the table at the end of the checker pass. By the time codegen ran, the table entry was gone. The AST fallback made a guess. The guess was wrong. The output compiled and ran and produced garbage.
Finding it required adding logging to the dispatch site to record every time it fell back instead of using a resolved type. The first run against a medium-sized test file came back with 23 fallback events. Most of them were probably fine. The one that wasn’t was invisible until I was looking at wrong output and had no other theory.
Failing closed instead
The fix is to remove the fallback and require checker-authoritative information everywhere codegen needs it. If it’s not there, fail with a diagnostic:
auto resolved = expr_types.find(mc.receiver->id);
if (!resolved) {
emit_diagnostic(mc.receiver->span, "internal: unresolved receiver type at codegen boundary");
return nullptr; // Fail closed
}
return dispatch_on(*resolved);Three dispatch sites got this treatment: handle receivers, actor receivers, and dynamic trait receivers. Each one now returns an error rather than a guess when the type information is missing. Each one has a regression test that deliberately erases the relevant expr_types entry and confirms the compiler rejects the program instead of silently miscompiling it.
The error says “internal” because these diagnostics should not appear in normal usage — they indicate a bug in the compiler, not in the user’s code. (Whether that’s the right way to surface a compiler bug is a separate question I’ve been avoiding.)
Range loop signedness
The fallback removal surfaced a second problem that had been lurking independently.
The type checker tracks whether a range bound is signed or unsigned. That metadata was crossing into codegen through a side channel that sometimes got lost during multi-file compilations. When it was absent, codegen defaulted to signed. Wrong sign → wrong loop semantics → programs that pass the test suite on small positive inputs and quietly do the wrong thing on anything near zero.
The fix for the signedness bug is one line — return nullptr instead of continuing with a default assumption. Finding it took the same logging approach as the receiver type issue: instrument codegen to record every time it emitted an instruction without corresponding type metadata.
That log had 23 entries on the same test file. Range loop signedness was one of them. Without the logging, it would have been invisible until someone hit a case where the default assumption was wrong.
The serialization boundary
There’s another half to this. When checker output is serialized and passed to codegen, unresolved Ty::Var entries — type variables that inference never resolved — should be caught there, not passed downstream. The original code let them through, on the assumption that codegen would either handle them gracefully or never encounter them.
check_program now strips unresolved entries and emits diagnostics for them before serialization. Codegen never sees an unresolved type variable. If the checker produces one, it’s a compile error at the checker boundary.
The principle is simple enough to write in one line: checker output must be concrete and complete. Unresolved Ty::Var or missing metadata are boundary failures, not downstream reconstruction work. (I added this to LESSONS.md as checker-output-boundary so I’d remember why the check exists when I read the code six months from now.)
The commit count
Around 50 commits across the v0.3.0 cycle touched this pattern. Most are one-liners — add the null check, emit the diagnostic, delete the fallback. The regression test infrastructure for the receiver dispatch site was larger, about 500 lines, because it needed a way to deliberately corrupt the type table and verify that corruption produced an error rather than bad output.
The v0.3.0 release post described all of this as “codegen hardening” in a single paragraph. It earned more than that, but the paragraph wasn’t wrong — the work is unglamorous. It fixes things that weren’t visibly broken. The bugs it prevents are the kind you find by adding logging and discovering 23 unexpected entries, most of which probably never would have caused a problem you could reproduce.
Probably.