Building a Reliable Transpiler: Lessons from 80+ Bug Fixes
What breaks when you map functional programming onto Go's type system, and how testing infrastructure keeps it fixed
Senior Software Engineer at Snowflake. Expert in scalable architecture, cloud tech, and security. Passionate problem-solver and mentor.
GALA has shipped 46 releases in two months. Along the way, we have fixed over 80 bugs in type inference, code generation, cross-package resolution, and build tooling. This post is an honest account of what went wrong, how we caught it, and what we learned about building a transpiler that people can actually trust.
The Challenge
Transpilers occupy an unusual spot in the compiler landscape. A traditional compiler owns the entire pipeline from source to machine code. A transpiler maps one language's semantics onto another language's type system, and any mismatch between the two becomes a bug.
GALA maps functional concepts -- sealed types, pattern matching, immutability, monadic types -- onto Go's type system. Go has no sum types, no pattern matching, no Option[T]. Every one of these features must be lowered into valid Go code that the Go compiler accepts. The type inference engine must reason about GALA's types (sealed variants, generic monads, lambda parameter inference) while also querying Go's type system (return types of os.ReadFile, field types of http.Request).
This two-language gap is where most of our bugs lived.
Categories of Bugs
Type Inference (35+ fixes)
Type inference bugs were the most common category. GALA's type system is richer than Go's -- it has sealed types, generic type parameter inference, lambda parameter inference from context, and Hindley-Milner-style unification for complex generic chains.
Chained method calls were a recurring source of issues. Consider:
val result = Some(42).Map((x) => x * 2).GetOrElse(0)
The transpiler must:
- Infer that
Some(42)producesOption[int] - Resolve
MaponOption[int]with afunc(int) intlambda - Infer the lambda parameter
xasintfrom the method signature - Determine that
MapreturnsOption[int] - Resolve
GetOrElseonOption[int]returningint
If any step fails, the chain breaks. Early versions would lose the type at step 4, causing GetOrElse to resolve against Option[any] and generate incorrect Go code.
Void lambdas were another painful area. Go functions that take func(T) callbacks (no return value) need different treatment than func(T) U callbacks. The transpiler must detect when a lambda is expected to return nothing and avoid wrapping its body in a return statement. Getting this wrong produced Go code that failed to compile with "too many return values."
Block lambda return types required special handling. When a lambda has a block body:
val result = list.Map((x) => {
val doubled = x * 2
return doubled
})
The return type must be inferred from the last expression or explicit return statement inside the block. This interacts with generic type parameter substitution -- if Map is Map[U](f func(T) U) Array[U], the transpiler must unify U with the block's return type.
Code Generation (20+ fixes)
Code generation bugs produced syntactically valid but semantically wrong Go code.
Unused imports were a constant annoyance. GALA auto-imports the std package for Option, Try, etc. But if a file uses only Println (which maps to fmt.Println), the transpiler might emit an import "martianoff/gala/std" that nothing references. The Go compiler rejects unused imports, so the generated code would not compile.
The fix was an ImportManager that tracks which imports are actually referenced during code generation and only emits those. This sounds simple, but the original implementation had 4 separate import tracking subsystems that had grown organically -- one for explicit user imports, one for auto-imports, one for sealed type companions, and one for Go interop helpers. Unifying these into a single ImportManager was one of the larger refactoring efforts (covered below).
IIFE wrapping for if-expressions and match-expressions required careful handling. GALA's if and match are expressions that produce values, but Go's if and switch are statements. The transpiler wraps them in immediately-invoked function expressions:
// GALA: val x = if (cond) "yes" else "no"
// Go:
x := func() string {
if cond {
return "yes"
}
return "no"
}()
Getting the return type of these IIFEs wrong -- especially when the branches had different levels of type specificity -- caused type mismatches in the generated Go.
Cross-Package Resolution (15+ fixes)
Multi-file packages and cross-package imports introduced a class of bugs around name resolution.
Dot imports (. "package/path") bring all exported names into scope. The transpiler must know which names came from dot imports versus local definitions, because the generated Go code needs explicit package prefixes for non-dot imports. Early versions would sometimes lose track of a name's origin, generating json.Codec when the user had import . "martianoff/gala/json" (which should produce just Codec).
Sibling file scanning -- extracting type information from other files in the same package -- had to be carefully scoped. The scanner must extract type declarations, method signatures, and sealed type definitions from siblings, but it must NOT scan files from main or test packages when analyzing a library package. A bug where sibling scanning included main package files caused type corruption in library packages.
Build System (10+ fixes)
Bazel-specific bugs were their own category.
Symlinks and junctions on Windows caused issues with Bazel's sandboxing. GALA genrules need filesystem access to the Go SDK for type inference, requiring tags = ["no-sandbox"]. But Bazel still creates symlink trees for inputs, and Windows junction handling in Go's filepath.Walk behaved differently than on Linux.
Cache races in Bazel's parallel execution could cause two genrules to write the same cache file simultaneously. The fix was content-addressed caching with atomic writes (write to a temp file, then rename).
The Testing Infrastructure
Catching these bugs before users hit them required multiple layers of testing.
Compilation Gate Test
Every example in the examples/ directory (216 files as of today) has a corresponding expected-output file. The compilation gate test transpiles every example, compiles the generated Go, runs it, and compares the output. If any example fails to compile or produces wrong output, the gate fails.
This is the single most valuable test. It catches regressions that unit tests miss because unit tests test individual phases, while the gate test exercises the full pipeline end-to-end.
Type Inference Regression Suite
A dedicated test suite with 14+ cases targeting specific type inference scenarios that have broken in the past:
- Generic method chains with lambda parameter inference
- Sealed type pattern matching with nested extractors
- FoldLeft accumulator type inference from zero value
- Block lambda return type propagation through generic boundaries
- Void lambda detection for Go callback functions
Each case is a minimal .gala file that exercises one inference path. When a type inference bug is fixed, a regression case is added to prevent it from recurring.
Metadata Validation Pass
Setting GALA_VALIDATE_METADATA=1 enables a post-analysis validation pass that checks:
- All type references resolve to concrete types (no leftover
NilTypeoranyplaceholders) - All method calls resolve to known methods on the resolved type
- Sealed type variants have correct discriminator values
- Generic type parameters are fully substituted
This catches bugs where the analyzer produces metadata that looks plausible but is subtly wrong -- the kind of bug that only manifests as incorrect Go output or a confusing Go compiler error.
Battle Test Workflow
A CI workflow that builds and runs all examples, the standard library tests, and several multi-file showcase projects. It runs on both Linux and Windows to catch platform-specific issues (Windows path separators, symlink handling, GOROOT propagation).
Bug Stories
Void Lambda Error Swallowing
In version 0.22.0, we discovered that Go functions accepting func(T) callbacks could silently swallow errors. Consider:
val items = SliceOf("file1.txt", "file2.txt")
items.ForEach((name) => os.Remove(name))
os.Remove returns an error, but ForEach takes func(string) -- a void callback. The transpiler was happily compiling this, discarding the error return. In Go, ignoring an error return is at least visible in the code (_ = os.Remove(name)). In GALA, the void lambda made it invisible.
The fix had two parts. First, the transpiler now rejects void lambdas where the body expression returns an error type, producing a compile error: "void lambda discards error return from os.Remove; use Try or handle the error explicitly." Second, we added FromError(error) Try[Void] to the standard library, providing a clean way to convert Go error returns into GALA's type-safe error handling:
items.ForEach((name) => {
FromError(os.Remove(name)).OnFailure((e) => {
Println(s"Failed to remove $name: ${e.Error()}")
})
})
This is a case where a bug fix led to a language feature. The Void type and FromError constructor exist because a real interop edge case demanded them.
ImportManager Unification
By version 0.22.0, the transpiler had accumulated four separate import tracking systems:
- User imports -- explicit
importdeclarations in the source file - Auto-imports -- automatically added for
std,fmt, etc. - Sealed type imports -- added when pattern matching references companion objects from other packages
- Go interop imports -- added when the transpiler generates calls to Go standard library functions
Each system maintained its own state, its own deduplication logic, and its own output formatting. Bugs would appear when two systems disagreed: the auto-import system would add "fmt" and then the user import system would also add "fmt", producing a duplicate import that Go rejects.
The unification in 0.23.0 replaced all four with a single ImportManager that:
- Accepts imports from any source (user, auto, sealed, interop)
- Deduplicates by import path
- Tracks actual usage during code generation
- Emits only imports that are referenced in the output
The refactoring touched 12 files and required updating every code path that previously used one of the four systems. The result was a net reduction of ~400 lines of code and elimination of an entire class of "duplicate import" and "unused import" bugs.
The 22x Performance Gain
The compilation performance story (covered in detail in the companion post) is also a reliability story. The file-at-a-time transpilation model was not just slow -- it was fragile. Each file's analysis was an independent computation, and if two files in the same package inferred slightly different metadata for a shared type (due to different analysis orderings or cache states), the generated Go files could be inconsistent.
Batch transpilation eliminated this class of bugs entirely by ensuring all files in a package see the same analysis result.
What Makes Transpiler Bugs Unique
Transpiler bugs differ from traditional compiler bugs in one important way: the error message comes from the wrong compiler.
When GALA generates incorrect Go code, the error is reported by the Go compiler -- not GALA. The user sees a Go error pointing at generated code they did not write. The mental model required to debug this ("GALA generated wrong Go, which means my GALA source triggered an edge case in the type inference engine") is harder than debugging a direct compiler error.
This is why GALA invests heavily in catching bugs before code generation. The metadata validation pass, the type inference regression suite, and the compilation gate test all exist to ensure that if something goes wrong, it fails in the GALA transpiler with a GALA-level error message, not in the Go compiler with a cryptic message about generated code.
Current State
As of version 0.25.2:
- 216 verification examples compile and produce correct output
- 14 type inference regression cases guard against known failure modes
- Full CI on Linux and Windows catches platform-specific issues
- Metadata validation available for development builds
- Zero known open bugs in the type inference engine (though new ones will certainly appear as usage grows)
The transpiler is not done. Complex generic type inference across package boundaries still has rough edges. The interaction between Go's type system and GALA's sealed types occasionally produces surprising results. But the testing infrastructure means that when bugs appear, they are caught quickly, fixed with a regression test, and stay fixed.
Try It
The GALA playground is at https://gala-playground.fly.dev. For local development:
gala build ./...
gala test ./...
If you find a bug, the best report is a minimal .gala file that fails to compile or produces wrong output. That becomes a regression test, and regression tests are how transpilers get reliable.