· 8 min read ·

Completeness and Cycles: What Go 1.26's Type Checker Improvements Reveal About a Deceptively Simple System

Source: go

Go has a reputation for being simple. That reputation is mostly earned, and the type system is a large part of why. There are no higher-kinded types, no typeclasses, no variance annotations, no implicit conversions. You write a type, and the compiler checks it. Most Go programmers never think much about how that checking works under the hood.

But Go 1.26 shipped a meaningful improvement to the type checker, specifically to the part that handles type construction and cycle detection. The change is invisible to nearly all Go users, fixed a handful of compiler panics from esoteric edge cases, and was described by the Go team as “setting up for future improvements.” That combination of traits is worth paying attention to. Infrastructure work that cleans up corner cases and opens doors usually signals something more significant than the commit message implies.

To understand what changed, you have to understand what the type checker is actually doing when it constructs a type.

From AST to Internal Representation

When the Go compiler processes a package, the source code is first parsed into an abstract syntax tree. The type checker then traverses that AST and builds internal data structures representing each type it encounters. This process is called type construction.

For simple cases, construction is straightforward:

type T []U
type U *int

The type checker encounters T, sees it’s a slice type, and builds a Slice struct with a pointer to U’s element type. It then encounters U, sees it’s a pointer type, and builds a Pointer struct pointing at int. Since int is a predeclared type that’s always complete, U completes immediately. Then T completes because its element type U is now complete.

The terminology here matters. A type is complete when its internal representation is fully populated and all types it references are also complete. Completion propagates bottom-up: leaf types complete first, then the types that depend on them.

This is a depth-first process, and it works cleanly for linear dependency chains.

When Types Form Cycles

Recursive types create a complication. Consider:

type T []U
type U *T

Here T depends on U and U depends on T. A naive depth-first construction would spin forever. The type checker handles this by allowing pointers to incomplete types during construction. When it’s building U and encounters T (which hasn’t completed yet), it stores a pointer to the incomplete T struct, under the assumption that T will complete eventually. When the cycle closes, both types complete simultaneously.

This works because referencing an incomplete type during construction doesn’t require deconstructing it. The type checker is just storing a pointer; it doesn’t need to inspect T’s internal layout to do that. Type checks that do require deconstruction, like verifying that a map key type is comparable, are deferred until all types in scope are complete.

Pointer-based recursion is the canonical case that every systems language has to handle. C allows it through forward declarations. Rust allows Box<T> recursion because the pointer itself has a known size. Go allows it for the same fundamental reason: a pointer is always the same size regardless of what it points at.

The Case That Breaks Things

The trouble starts when size matters. Consider:

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

This declares T as an array whose length is determined by the size of a T value. For the array to complete, the type checker needs to evaluate unsafe.Sizeof(T{}). But unsafe.Sizeof on a composite literal requires knowing the size of T. And the size of T depends on the length of the array. And the length of the array depends on unsafe.Sizeof(T{}). Nothing can complete first.

This is a genuine cycle error, not just a depth-first ordering problem. The type checker should report it cleanly. Before Go 1.26, in some variations of this pattern, it would panic instead.

Compare this to the safe version:

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

Here the argument to unsafe.Sizeof is a pointer to T, not a T value directly. Pointers always have the same size regardless of their target type. The type checker can evaluate unsafe.Sizeof(new(T)) without knowing anything about T’s layout. The cycle doesn’t actually need to be resolved to compute the answer.

The distinction is between an incomplete value, whose type is still being constructed, and an operation that is safe to perform on such a value. new(T) with an incomplete T is safe because you’re not deconstructing T. A composite literal T{} is not safe because you are.

The Upstream Detection Pattern

The Go 1.26 improvement introduces a systematic way to catch these violations. The core insight is that incomplete values can only enter the computation from a fixed set of expression forms, which the blog post calls upstreams. By inspecting Go’s grammar, you can enumerate them exhaustively:

// Conversion
type T [unsafe.Sizeof(T(42))]int

// Function call returning T
func f() T
type T [unsafe.Sizeof(f())]int

// Type assertion
var i interface{}
type T [unsafe.Sizeof(i.(T))]int

// Channel receive
type T [unsafe.Sizeof(<-(make(<-chan T)))]int

// Map access
type T [unsafe.Sizeof(make(map[int]T)[42])]int

// Pointer dereference
type T [unsafe.Sizeof(*new(T))]int

Each of these expression forms can produce a value of an incomplete type. The fix adds a completeness check at each upstream: before allowing the produced value to flow downstream into further computation, verify that the type is complete. If it is not, report a cycle error and return an invalid operand to stop the incomplete value from propagating further.

The implementation pattern looks roughly like this across all the upstream cases:

func callExpr(call *syntax.CallExpr) operand {
    x := typeOrValue(call.Fun)
    switch x.mode() {
    case typeExpr:
        // This is a conversion: T(expr)
        T := x.typ()
        if !isComplete(T) {
            reportCycleErr(T)
            return invalid
        }
        // Safe to proceed; T is complete and can be deconstructed
    }
}

The elegance of this approach is that it’s local and composable. Each upstream site is responsible for checking its own output. Downstream code doesn’t need to carry incomplete-value flags or check them at each operation. The boundary is clean.

Why Other Languages Handle This Differently

It’s worth noting how other statically typed systems languages approach the recursive type problem, because Go’s constraint is somewhat unusual.

In Rust, recursive types must use indirection (Box<T>, Rc<T>, &T) because Rust needs to compute the size of every type at compile time, and a directly recursive type would have infinite size. Rust’s rules are enforced at the type level: the compiler will reject struct Foo { inner: Foo } with a clear error, and the fix is always to introduce a pointer.

In C and C++, you can have arrays with a length derived from sizeof, but the language doesn’t allow the kind of self-referential array size that Go’s unsafe.Sizeof makes theoretically expressible. C’s sizeof is a compile-time operator over fully defined types, and a type isn’t fully defined until its closing brace.

Go’s situation is more complex because unsafe.Sizeof operates in expression context during type construction, and the type being sized can be the type currently under construction. The language doesn’t categorically forbid it (the pointer case is safe), so the type checker has to reason more carefully about what’s actually being asked.

This is a place where Go’s commitment to having a small but expressive unsafe package creates genuine implementation complexity. The unsafe package is deliberately low-level, and its interactions with the type system require the compiler to be more careful than it would need to be with a purely safe language.

The Panics That Prompted This

The immediate motivation for the Go 1.26 work was a cluster of compiler panic reports, including issues #75918, #76383, #76384, and #76478, among others. These were all variations on the incomplete-value theme: type declarations that hit edge cases in the previous type construction algorithm, causing the compiler to crash rather than emit a clean error message.

Compiler panics on user code are always a high-priority problem, even when the code causing them is pathological. A compiler that panics instead of giving an error has failed at its most basic job. These cases were admittedly esoteric, the kind of code that appears in fuzzer outputs or particularly creative attempts to stress the language, but they were real crashes on valid (if unusual) inputs.

The previous implementation used what the Go team describes as a more complex algorithm with more bespoke cycle detection that didn’t always work. The replacement is both simpler and more systematic. That’s a common outcome when you find the right abstraction: the code gets shorter and the corner cases collapse.

What This Sets Up

The Go team notes that this change was intended to reduce corner cases and set up future improvements. That framing is interesting without being specific, and the blog post doesn’t elaborate further.

The most plausible reading is that a cleaner type construction algorithm makes it easier to add features that depend on the type checker’s internals. Improvements to type inference, better error messages for type errors, or any kind of incremental compilation work all need a reliable type construction foundation. The previous bespoke approach was technical debt that would have compounded as new features were built on top of it.

There’s also a tooling dimension. The go/types package exposes Go’s type checker to external tools, and it’s used by virtually every significant Go analysis tool: gopls, staticcheck, golangci-lint, and many others. Improvements to the underlying type construction algorithm benefit the entire ecosystem of tools that depend on it, not just the compiler.

The Lesson in the Detail

Go’s type system really is simple at the level users interact with it. There are no surprises in everyday type checking. But simple user-facing semantics don’t necessarily mean simple implementation, particularly in the interactions between low-level features like unsafe and the core type construction machinery.

The Go team’s approach here, systematic enumeration of upstream sites and local completeness checks, is a good example of finding a problem’s natural structure and exploiting it. The set of expression forms that can produce incomplete values is finite and derivable from the grammar. Once you recognize that, the fix becomes straightforward to specify and implement.

That kind of clarity, the moment where a complex problem reveals its underlying shape, is usually what allows both the code and the corner cases to simplify at the same time. The Go 1.26 type checker work is a quiet example of it.

Was this interesting?