Skip to main content

Command Palette

Search for a command to run...

The Type Information Problem

When your transpiler knows the types but your editor doesn't

Updated
6 min read

If you're building a language that compiles to native code, the path to editor tooling is well-understood: run your type checker, expose the results. But if your language transpiles to another high-level language, you're stuck in an awkward middle ground. Your source language has types. Your target language has types. And they don't talk to each other.

GALA transpiles to Go. When a user writes val x = Some(42) in their editor, the LSP needs to know that x is Option[int]. But there's no Go source file to analyze yet -- the transpiler hasn't run. And even after it runs, the generated Go uses different names, wraps values in Immutable[T], and structures code in ways that don't map cleanly back to the original source positions. This is the type information gap, and every transpiled language has to solve it.

The naive approach: running go/types on the output

The first thing you'd try -- and the first thing we tried -- is to transpile the file, then run go/types on the generated Go AST. Go has excellent type-checking infrastructure; why not leverage it?

It works, sort of. You get types, but they're Go types, not GALA types. A GALA val name = "hello" becomes a Go name := std.NewImmutable[string]("hello"), and go/types tells you the type is std.Immutable[string]. Now you need a reverse-mapping layer to turn that back into string for display. A GALA Option[int] becomes std.Option[int] in Go. A sealed type variant with three fields generates a struct with a _variant uint8 discriminator. Every generated pattern needs a corresponding un-generation rule.

Worse, go/types needs complete, compilable Go code. If the user is mid-edit and the transpiler produces a partial or broken Go file, the type checker fails entirely -- no partial results, no graceful degradation. For an LSP that needs to respond to every keystroke, "all or nothing" is the wrong tradeoff.

We needed a different approach.

The solution: the transpiler already knows

Here's the insight that changed the design: the transpiler already resolves every type during transformation. When it encounters val x = Some(42), it infers that Some(42) produces Option[int], records that in its internal scope, and uses that information to generate the correct Go code. The type information exists -- it's just trapped inside the transformer's mutable state, discarded after the Go AST is emitted.

The fix was surgical. We added a parallel data structure to the transformer that records every variable's resolved type as a side effect of the normal transformation process. No separate analysis pass, no reverse mapping, no additional type checker. The transpiler does its job and the LSP reads the results.

The core of it lives in scope.go:

func (t *galaASTTransformer) addVal(name string, typeName transpiler.Type) {
    if t.currentScope != nil {
        t.currentScope.vals[name] = true
        t.currentScope.valTypes[name] = typeName
    }
    t.recordLSPVarType(name, typeName)
}

func (t *galaASTTransformer) recordLSPVarType(name string, typeName transpiler.Type) {
    if t.lspVarTypes == nil || typeName == nil || typeName.IsNil() {
        return
    }
    key := name
    if t.lspCurrentFunc != "" {
        key = t.lspCurrentFunc + "." + name
    }
    t.lspVarTypes[key] = typeName
}

Every call to addVal or addVar -- the same functions the transpiler uses to track variables for code generation -- now also records the type into lspVarTypes. The recording is conditional: if lspVarTypes is nil (normal compilation, not an LSP invocation), the function returns immediately. Zero overhead for the non-LSP path.

How types are scoped

The scoping scheme is deliberately simple. A variable inside a function is keyed as funcName.varName. A top-level variable is keyed as just varName. That's it.

The transformer tracks the current function name via lspCurrentFunc, set at the entry of each function declaration and restored on exit:

// In declarations.go, inside the function declaration handler:
prevFunc := t.lspCurrentFunc
t.lspCurrentFunc = name
defer func() { t.lspCurrentFunc = prevFunc }()

This means if you have a function calculateTotal with a local val tax = price * 0.1, the key in lspVarTypes is "calculateTotal.tax". A top-level val config = loadConfig() is just "config".

On the LSP side, lookupVarType mirrors this convention. It takes the enclosing function name (determined by scanning backwards from the cursor position for a func declaration) and tries the scoped key first, falling back to unscoped:

func lookupVarType(varTypeMap map[string]string, funcName, varName string) string {
    if varTypeMap == nil {
        return ""
    }
    if funcName != "" {
        if typStr, ok := varTypeMap[funcName+"."+varName]; ok {
            return typStr
        }
    }
    if typStr, ok := varTypeMap[varName]; ok {
        return typStr
    }
    return ""
}

This doesn't handle shadowing within nested scopes (a variable in an inner block with the same name as one in the outer function body), and that's a conscious trade-off. Full scope tracking would require mapping source positions to AST nodes, which adds complexity for a case that rarely matters in practice.

The LSP pipeline: from keystrokes to cached types

The TransformForLSP method is the bridge between the transpiler and the LSP. It calls the regular Transform, then copies out the accumulated lspVarTypes:

func (t *galaASTTransformer) TransformForLSP(richAST *transpiler.RichAST) (*transpiler.TransformResult, error) {
    fset, file, transformErr := t.Transform(richAST)
    // Always return collected var types, even on error (partial results)
    varTypes := make(map[string]transpiler.Type, len(t.lspVarTypes))
    for name, typ := range t.lspVarTypes {
        varTypes[name] = typ
    }
    return &transpiler.TransformResult{
        Fset:     fset,
        File:     file,
        VarTypes: varTypes,
    }, transformErr
}

Note the comment: "even on error (partial results)." If the transpiler fails halfway through a file -- because the user is still typing -- we still get types for every variable processed before the error. This is the critical difference from the go/types approach. Partial results are the default, not a special case.

The LSP server calls this inside analyzeFile, which runs on every document change (after a 300ms debounce). The returned VarTypes map gets cleaned up and cached:

if result != nil && result.VarTypes != nil {
    typeMap := make(map[string]string, len(result.VarTypes))
    for name, typ := range result.VarTypes {
        typeMap[name] = cleanGoTypeForDisplay(typ.String())
    }
    h.mu.Lock()
    h.varTypes[uri] = typeMap
    h.mu.Unlock()
}

The cleanGoTypeForDisplay function handles the impedance mismatch between GALA's internal type representation and what a user expects to see. It strips std. prefixes and unwraps Immutable[T] to T -- the generated Go uses Immutable wrappers to enforce value semantics on val bindings, but the user doesn't need to know that.

What this enables

With a single map[string]string per open document, the LSP gets:

Inlay hints. When the user writes val x = someExpression(), the editor shows : ReturnType as a ghost annotation after x. The inlay hint handler walks visible lines, matches val/var declarations without explicit types, and looks up the variable name in varTypes.

Dot completion. When the user types x., typeAtDot resolves x to its type via the same lookupVarType function, then looks up that type's methods and fields in the RichAST to build the completion list.

Hover information. Same lookup, different presentation -- show the type in a hover tooltip instead of a completion menu.

All three features share the exact same type resolution path. There's no separate type-checking logic for completions vs. hints vs. hover. If the transpiler resolves a type, every LSP feature sees it.

The trade-off

This design couples the LSP tightly to the transpiler's internals. If the transpiler changes how it represents types, every LSP feature is affected. But the alternative -- maintaining a separate type checker -- would mean maintaining two sources of truth that inevitably drift apart. For a language where the transpiler is the type checker, piggybacking on its results isn't coupling; it's architectural honesty.

The bigger limitation is that lspVarTypes only captures variables. Expression types (what's the type of list.Map(f).Filter(g)?) aren't in the map; those get resolved on the fly by typeAtDot walking the chain and consulting the RichAST's method signatures. That's a story for another post.


Next in the series: how dot-completion resolves method chains when the type of each step depends on the previous one.

GALA From the Ground Up

Part 1 of 7

A ten-part technical blog series exploring GALA — a modern programming language that brings sealed types, pattern matching, monadic error handling, and immutable-by-default semantics to the Go ecosystem. Each post takes a single concept, shows the problem it solves with real code, compares it to idiomatic Go, and is honest about trade-offs. Written for Go developers who want more expressiveness and functional programmers who want Go's runtime.

Up next

22x Faster Builds: Inside GALA's Compilation Performance Journey

How profiling, batch analysis, and content-addressed caching took multi-file compilation from 44.7s to 2.0s