· 8 min read ·

Inside Go 1.26's Type Checker: Incomplete Values, Cycles, and the Upstream Guard

Source: go

The Go type checker sits between the parser and code generation. It validates that every type in your source is structurally sound, confirms that operations on those types make sense, and rejects programs that would be impossible to compile correctly. For most Go code this work is invisible. In Go 1.26, the team significantly reworked a part of this machinery that handles cycles involving partially-constructed types, converting a handful of compiler panics into proper error messages and establishing a cleaner model for reasoning about recursive type definitions.

The surface change is minor for most Go programmers; unless you write genuinely unusual type definitions, nothing observable changes. The engineering behind it is worth understanding on its own terms.

How the type checker builds types internally

Go’s type checking logic lives in the go/types package, which is used not only by the compiler but by gopls, staticcheck, and the broader ecosystem of Go analysis tools. When the type checker encounters a type declaration, it constructs an internal representation: *types.Named for declared types, *types.Slice for slices, *types.Pointer for pointers, *types.Array for arrays, and so on.

For a simple pair of declarations:

type T []U
type U *int

The checker builds a Named for T whose underlying field points to a Slice. The Slice holds a reference to a Named for U, whose underlying type is a Pointer with base int. Construction is depth-first: a type is “complete” once all of its fields are populated and every type it references is also complete.

Recursive types fit naturally into this model. Consider:

type T []U
type U *T

When constructing the Pointer for *T, the checker only needs to store a reference to T. Pointers have a fixed size regardless of what they point to, so the checker can record the reference to the still-incomplete T, finish building *T and U, then circle back and complete T. The cycle resolves because pointer construction never needs to inspect T’s internal structure.

The same principle holds for slices, maps, channels, and interfaces. All of these carry references to element or value types without requiring concrete size or structural knowledge. A []T can be built while T is still under construction. The type checker exploits this to handle a wide range of recursive definitions without issue.

The category of cycles that cause problems

Array types in Go carry their size as a compile-time constant embedded in the type itself. That constant can be any expression that evaluates to a constant, including calls to unsafe.Sizeof, unsafe.Alignof, and len applied to array variables. This creates the conditions for a cycle that cannot be resolved:

type T [unsafe.Sizeof(T{})]int

To construct the Array, the checker must evaluate unsafe.Sizeof(T{}) to determine the array length. Evaluating that expression requires constructing a composite literal T{}, which requires inspecting T’s structure to determine what elements or fields it contains. But T’s structure is not yet known; it depends on the Array completing first. Neither can proceed without the other, and no ordering breaks the deadlock.

Compare this with a superficially similar definition that is valid:

type T [unsafe.Sizeof(new(T))]int

Here new(T) allocates a T and returns a *T. The unsafe.Sizeof of a pointer is always the pointer width, known without inspecting what the pointer points to. The checker never needs to look inside T, so this compiles without issue.

The distinction is precise: new(T) produces a value of type *T, and pointer size is independent of the pointed-to type. A composite literal T{} produces a value of type T directly, and T’s structure is needed to validate and size that literal.

Where the old code broke

Before Go 1.26, cycle detection for these size-determining expressions was implemented case by case, accumulated over time without a unifying framework. The logic handled some patterns correctly and missed others, with the missed cases causing the type checker to follow a cycle and eventually panic rather than produce a clean diagnostic.

Issues #75918, #76383, #76384, and #76478 are concrete examples: specific cycle patterns involving function call returns, type assertions, channel receives, and other expression forms caused compiler crashes. None of these are patterns you’d write in production code, but the compiler should report an error on any syntactically valid input, not crash.

The root issue was the lack of a systematic model. Bespoke detection logic is easy to write for the obvious cases and easy to miss for less common ones.

The incomplete value model

The Go 1.26 solution is organized around a precise conceptual distinction. A value is “incomplete” if its type has not yet finished construction. The central question becomes: which operations on an incomplete value require deconstructing its type?

Some expression forms produce a value of an incomplete type directly. These are the “upstreams” in the new model:

type T [unsafe.Sizeof(T(42))]int                      // conversion
type T [unsafe.Sizeof(f())]int                        // function call return
var i interface{}
type T [unsafe.Sizeof(i.(T))]int                      // type assertion
type T [unsafe.Sizeof(<-(make(<-chan T)))]int          // channel receive
type T [unsafe.Sizeof(make(map[int]T)[0])]int         // map index
type T [unsafe.Sizeof(*new(T))]int                    // pointer dereference

Each of these produces a value whose type is T, and T is still under construction. The downstream consumers of these values, passing them to functions, indexing them, slicing them, applying arithmetic, all potentially require inspecting T’s structure. The list of downstream operations is long and scattered throughout the checker.

The upstream guard

The fix places a completeness check at each upstream expression form rather than at every downstream consumer. For a conversion expression T(42):

func callExpr(call *syntax.CallExpr) operand {
    x := typeOrValue(call.Fun)
    switch x.mode() {
    case typeExpr:
        T := x.typ()
        if !isComplete(T) {
            reportCycleErr(T)
            return invalid
        }
        // T is complete; safe to proceed
    }
}

The same structure appears at function call returns, type assertions, channel receives, map index expressions, and pointer dereferences. Before a potentially incomplete value is handed back to the rest of the checker, completeness is verified. If the type is not complete, a cycle error is reported and a special invalid sentinel operand is returned instead.

The invalid operand is what makes this approach coherent. Every downstream consumer in the type checker short-circuits on invalid inputs, so the error stops propagating cleanly without cascading panics or confusing secondary diagnostics. One completeness check at the production site covers every downstream use automatically.

This is a pattern worth noting beyond Go specifically. In any system where you have producers and consumers of potentially invalid values, placing the invariant check at the producer rather than at every consumer results in simpler code and more exhaustive coverage. The number of upstream expression forms in Go’s grammar is finite and small; the number of ways to consume a value is much larger and more dispersed.

How other languages handle recursive types

Go’s approach sits at a specific point in the design space, and comparing it to other languages clarifies what tradeoffs the Go team is making.

Rust has the same prohibition on infinite-size types at the structural level:

// Error: recursive type 'Node' has infinite size
struct Node { next: Node }

// Solution: explicit heap indirection
struct Node { next: Option<Box<Node>> }

Rust requires the programmer to be explicit about indirection via Box<T>, Rc<T>, or other owned pointer types. The type checker rejects direct infinite-size recursion before code generation. Rust’s trait solver handles a separate class of cycles through coinductive reasoning for certain recursive trait bounds, which is more sophisticated machinery, but that addresses type system expressiveness rather than size validity.

TypeScript takes a markedly different approach because it uses structural typing. Recursive type aliases are fully supported:

type Json = string | number | boolean | null | Json[] | { [key: string]: Json };
type Tree<A> = { value: A; children: Tree<A>[] };

TypeScript’s checker handles these through lazy evaluation. Recursive references are represented as deferred computations forced only when needed for a specific check. The checker detects non-productive cycles like type Bad = Bad and rejects them, but any recursive type alias that converges to a structural description is valid. The tradeoff is checker complexity and, for deeply recursive types, performance. TypeScript’s type checker is correspondingly more complex than Go’s.

Haskell makes the distinction at the language level. type synonyms are non-recursive, much like Go’s = type aliases. data and newtype declarations are always cycle-safe because every data constructor is a natural indirection layer:

data List a = Nil | Cons a (List a)  -- data constructor is the indirection
newtype Fix f = Fix { unFix :: f (Fix f) }  -- captures general recursion

Haskell’s type unification uses an occurs check during inference to reject infinite types; if you try to unify a type variable a with [a], the occurs check rejects it. GHC’s type family extension applies a depth limit to prevent non-terminating type-level computations from hanging the compiler.

Go’s design is the most conservative. Direct recursive definitions that imply infinite size are rejected. Recursive definitions through any indirect layer (pointers, slices, maps, channels, interfaces) compile without issue. The rules follow directly from what the language’s type system needs to know at compile time, which makes them easy to reason about even if the implementation behind them is subtle.

The broader significance

The concrete output of this work is that a set of rare but real compiler panics on valid-looking Go code are now proper error messages. Programmers building tooling on top of go/types, custom linters, code generators, static analyzers, get a more robust foundation that handles edge cases without crashing.

The Go team’s framing in the blog post is that this simplification reduces corner cases in the type checker, setting up future improvements. What those improvements might be is left unspecified, but the pattern from Go’s development history is consistent: infrastructure cleanup tends to precede language or tooling additions. A type checker with a clean, documented model for incomplete values is easier to extend than one with accumulated special cases and undocumented invariants.

The incomplete value problem is also a good illustration of why compiler internals are harder than they look. Go’s type system is intentionally simple; the language specification is short and the mental model for most programmers is straightforward. But “simple type system” and “simple type checker” are different things. Recursive types, constant expressions that compute sizes, and the interaction between them create a state space that requires careful bookkeeping to navigate correctly. The upstream guard model is a clean solution to that problem, and the fact that it took Go 1.26 to get there reflects how subtle the edge cases are rather than any oversight in the original implementation.

Was this interesting?