Skip to content
Beskid Beskid

Beskid

Jump to a Beskid service

Beskid

Jump to a Beskid service

Beskid blog · Compiler

The Type System Refactor Nobody Asked For (That Fixed Everything)

June 2026. The compiler gate kept failing on generic enum constructors, contextual inference, and Result match provenance. The fix was not a patch — it was a type system refactor that touched lowering, syntax, and dispatch. Six days of commits. Green gates at the end.

Published
← All posts

The gate was red. Not “flaky” red — structurally, stubbornly, this-is-a-design-problem red. Generic enum constructors failed to infer. Result match arms lost provenance. Contextual type parameters resolved to never when they should have resolved to T. None of these were new bugs. They had been open for weeks, accumulating comments like “probably related to the generic lowering pass” and “needs investigation of the syntax-parameter path.”

Nobody filed a ticket saying “refactor the type system.” Nobody put “generic inference coherence” on the sprint board. The refactor happened because the alternative — patching each bug individually, adding another conditional to the lowering pass, another special case to the syntax binder — had stopped working. The patches were colliding with each other. Fixing contextual inference broke enum match payloads. Fixing enum match payloads broke Result provenance. The type system had reached the point where additive changes produced subtractive correctness.

The commits tell the story. The refactor opened with 8f872067 and c320b6fb — a terse “Type system refactor” followed by “Bump compiler for type checker refactor and stop tracking .beskid LSP cache.” Two commits that, together, said: we are doing this properly, and we are cleaning up the LSP cache because stale caches and a new type checker are a bad combination. The .beskid directory had been accumulating cached type resolutions since the first LSP integration. The refactor changed the shape of those resolutions. Keeping the old cache around would have meant the LSP reporting types that no longer existed.

Then the wave hit. Fourteen commits over six days, each one an edge case that had been filed as a bug but was actually a symptom of the same underlying crack.

cc6a2478 — “lower bound enum match payloads.” The compiler could pattern-match an enum variant but couldn’t trace the payload type through the match arm. You could write when value is Option.Some(x) { ... } and x would have type ? — the compiler knew it was a payload but didn’t know what kind.

f6fc8861 — “materialize nominal syntax parameters.” Generic type variables that appeared in module-level syntax declarations were being erased before the lowering pass could see them. The syntax tree said List<T> but the lowering pass received List<?>. Nominal parameters — the T in List<T> — have to survive from parse through lowering. They weren’t.

6d88a2b8 — “infer contextual generic enum constructors.” This was the big one. Option.Some(42) in a context that expected Option<Int> should infer T = Int from context, not demand an explicit type annotation. Before the refactor, it didn’t. The compiler treated every enum constructor call as an island: resolve the variant, check the payload type, require it to be explicit. Contextual inference — “the surrounding expression expects Option<Int>, so Some(42) must be Some<Int>(42)” — simply did not exist. Adding it meant threading a type expectation through the expression tree, which meant touching every node kind.

735de85e — “resolve Output Syscall module binding.” A seemingly unrelated module-resolution bug that turned out to be tangled with how generic parameters were threaded through the module graph. The Output syscall is how a Beskid program writes to stdout. It lives in the Foundation module. Resolving it requires the module binder to walk imports, and that walk was losing generic context. The fix was one line in the binder and three days of realizing it was the binder.

6c0f8fe0 — “resolve imported payload enum layouts.” Enums imported across module boundaries had their payload layouts resolved at import time, before the generic context was available. Import http.Response<T> from the net module, and by the time your module saw it, T was erased. The refactor deferred layout resolution to use-site, and suddenly cross-module generic enums Just Worked.

dd0cdd93 — “retain imported Result match provenance.” This was the one that had been driving people crazy. A Result.Ok(value) imported from another module, matched in a when branch, and the compiler forgot it was a Result. The match arm treated it as an opaque value. The provenance — the chain of type identities that says “this value came from a Result<T, E> construction” — was being dropped during import folding. The refactor threaded provenance through the import path, and Result matches became reliable.

d05ab945 — “lower direct unit match-arm calls.” Match arms that returned unit values — when True { } — were being lowered to calls that didn’t exist. The lowering pass assumed every match arm produces a value; unit arms produce nothing, and the codegen had to handle that.

98fe8aaa — “authorize Foundation Output panic service.” The panic service is how a Beskid program says “this should never happen.” It needs authorization to link against the Foundation runtime. The refactor exposed that the authorization check was gated on a type path that the new generic lowering had restructured. One line. Same binder.

5d4d3b65 — “advance compiler generic lowering.” cbd99a3f — “advance generic syntax fixes.” 1b0e33c9 — “advance compiler runtime fixes.” 4d96edd1 — “finalize ansi csi smoke path on syntax lowering.” The ANSI CSI smoke path — terminal control sequences for colored output — was the canary. If syntax lowering could correctly thread generic parameters through a concrete, observable code path like “print red text to the terminal,” the refactor was working.

Each of these commits had been filed as a separate issue. Each looked like a feature request: “please make Result matches work,” “please infer generic enum constructors.” But the refactor revealed what they actually were: cracks in a type system that had been built additively and was now being asked to do things it was never designed for. The issues weren’t bugs in the implementation. They were bugs in the architecture.

The architecture had been additive from the start, and the additivity was not an accident — it was a deliberate choice made in the v0.1 days, when the compiler only needed to type-check concrete types. The original design had three structural decisions that made generic inference impossible without a refactor.

First, the syntax pipeline and the type checker were separate passes with a one-way data flow. The parser produced a syntax tree. The binder resolved names in that tree. The type checker walked the resolved tree and assigned types. Information flowed left to right: parse → bind → type-check. Generic inference requires information to flow backward — the type expected by a surrounding expression has to inform the type assigned to an inner expression. Contextual inference is, by definition, a bidirectional information flow. The original architecture had no channel for it.

Second, the lowering pass assumed all type variables were explicit — supplied by the programmer at the call site. The lowering pass was written when Beskid had no generics at all. When generics were added in v0.2, the lowering pass was patched to handle the syntax of type parameters (List<T>) but not the semantics of type inference (what is T when the programmer didn’t write it?). The lowering pass could lower Option.Some<Int>(42) but not Option.Some(42). The assumption that “the programmer always writes the type” was baked into every function signature in the lowering pass. Untangling it meant rewriting the signature of the lowering visitor itself.

Third, and most consequentially, provenance was not part of the type representation. A type in the pre-refactor compiler was a pair: a kind (enum, struct, primitive) and a set of type arguments. Where that type came from — which module defined it, which import path it traveled through, which generic context it was resolved in — was not recorded. For monomorphic types, provenance doesn’t matter. Int is Int whether it’s defined in your module or imported from Foundation. But for generic types, provenance is everything. Result<T, E> imported from the corelib and Result<T, E> defined locally are the same type only if the compiler remembers they are the same type. Without provenance, the compiler sees two structurally identical but semantically unrelated types and refuses to unify them. Result match arms lose their connection to the Result type because the compiler forgot they were ever connected.

These three decisions — one-way pipeline, explicit-only lowering, provenance-free types — were not mistakes. They were the right decisions for a v0.1 compiler that only needed to handle concrete types. They became wrong when Beskid grew generics. The refactor did not add features. It removed the architectural decisions that made features impossible.

The shape of the fix was itself instructive. The lowering visitor signature changed from lower(expr: Expr) -> Ir to lower(expr: Expr, expected: Option<Type>) -> Ir — a single extra parameter that carried the type expectation through every node in the expression tree. The type representation grew a provenance field: Type { kind: TypeKind, args: Vec<Type>, provenance: Provenance } where Provenance was an enum of Local, Imported(ModuleId), and Resolved(CanonicalPath). Two data structure changes, one new parameter, and suddenly the compiler knew where every type came from and what every expression was expected to become. Fourteen commits were not fourteen features. They were fourteen places where those two data structures had been hiding the same assumption: that types are self-evident and inference is someone else’s problem.

The .beskid cache burn was the final confirmation. The old cache had been serializing types without provenance — just the kind and the args, a lossy format that couldn’t survive the transition. When c320b6fb stopped tracking the cache, it wasn’t a cleanup. It was an admission that the old format was wrong and the new format was incompatible. The LSP server started cold — no cache, no precomputed resolutions, just the new type checker and the source text. It was slower for the first hour. It was correct from the first keystroke.

The lesson of this six-day wave is not subtle. Type systems are not additive. You cannot bolt generic inference onto a type checker that was designed for monomorphic dispatch. You cannot add contextual parameter resolution after the lowering pass was written to assume all type variables are explicit. You cannot thread provenance through imports if provenance was never part of the type representation. You either design generics into the type system from the beginning, or you pay the refactor tax later — with interest. The tax, in this case, was six days of commits, fourteen edge cases, and one .beskid cache directory that had to be burned to the ground. Worth it.

The .beskid LSP cache cleanup was not incidental. The refactor changed how the type checker resolved module-level generic parameters, which meant any cached resolution from the old checker was poison. A stale cache would have the LSP reporting that Option.Some(42) has type Option<?> while the compiler — correctly, after the refactor — infers Option<Int>. The editor says one thing, the compiler says another, and the developer trusts neither. One of the commits in this wave — c320b6fb — made the cache untracking explicit: the cache is not a convenience feature. It is a correctness dependency. When the type checker changes, the cache changes with it or the LSP lies to you.

The gate went green on day six. Not because the bugs were patched — patches would have been two-line fixes and a prayer, each one introducing the next bug. The gate went green because the type system was made coherent. Generic enum constructors infer because the inference path exists now. Result matches retain provenance because provenance is part of the type representation now. Cross-module generic enums work because layout resolution happens at use-site. The refactor nobody asked for was the refactor that fixed everything.

One postscript worth noting: the refactor didn’t add a single new compiler flag or language feature. No new syntax. No new keyword. No new annotation that the programmer has to write. The programmer experience after the refactor is strictly simpler than before — fewer type annotations, fewer explicit generic parameters, fewer “why doesn’t the compiler know what type this is” moments. The complexity moved from the user-facing surface into the compiler internals. That is the direction complexity should move. A good type system makes the programmer’s job easier and the compiler writer’s job harder. A bad type system does the reverse. The Beskid type checker got harder to maintain in June 2026. Every Beskid program got easier to write. That trade is never even and it’s always correct.

Cross-reference Book chapter “Compiler is not your therapist” — the control flow and generics sections describe the design principles this refactor codified. The therapist metaphor is not a joke: the compiler will not guess what you meant. It will check what you wrote. But it can only do that if the type system gives it the right information at the right time. After June 2026, it does.