# The Type Information Problem

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`:

```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:

```go
// 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:

```go
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`:

```go
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:

```go
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.*
