Go has a reputation for a simple type system. Compared to Rust’s lifetime annotations or Haskell’s type class machinery, Go types often feel refreshingly straightforward. That simplicity, though, lives at the surface. The type checker underneath has to solve some genuinely hard problems, and one of them shipped a better solution in Go 1.26.
The Go blog post on type construction and cycle detection walks through the internals of how Go’s type checker builds its internal representation of types and detects invalid recursive cycles. The post is worth reading on its own, but it leaves some context implicit. What follows is a deeper look at the problem, why the old solution was fragile, and what the new approach actually buys you.
Type Construction Is Not Trivial
Before the type checker can verify that operations are valid, it has to build an internal representation for every type it encounters. This process, called type construction, is conceptually simple for non-recursive types.
Consider:
type T []U
type U *int
The checker traverses this depth-first. It starts constructing T as a Slice, which requires U. It constructs U as a Pointer, which requires int. int is a predeclared type and already complete. The pointer completes, then U completes, then T completes. Each type’s internal fields get populated in order.
Recursion changes the picture:
type T []U
type U *T
Now constructing U requires T, which is still mid-construction. The type checker handles this by pointing U’s base type field directly at the incomplete T struct. When T eventually completes, U is already correct because it holds a pointer to the same struct. The types complete simultaneously by way of shared references.
This works because a pointer type does not need to inspect its base type in order to be complete. Knowing that U is *T is enough to describe U fully, even while T is still being built. The pointer indirection absorbs the incompleteness.
Where Cycles Actually Break
The problem appears when the type definition requires the type’s own size or layout during construction. The canonical example:
type T [unsafe.Sizeof(T{})]int
This is a valid-looking array type. Arrays need a concrete element count, and unsafe.Sizeof returns the size of a value. The size of T{} is the size of T itself, but T is an array whose length is determined by that size. Neither can be known without the other. This is a genuine cycle error.
The type checker must catch this and report a compilation error. It must also do so without crashing.
Before Go 1.26, detecting this relied on bespoke cycle detection logic scattered across different parts of the type checker. It worked in many cases but not all. Issues #75918, #76383, #76384, and #76478 are among the panics that resulted from the gaps. A compiler panic on malformed user code is never acceptable behavior, even for arcane type definitions.
The Incomplete Value Model
The Go 1.26 fix introduces a cleaner conceptual framework around what the blog calls incomplete values.
An incomplete value is a value whose type has not finished being constructed. These arise naturally during type checking, and most of the time they cause no problem. The type checker produces an incomplete value for T while building T, and as long as nothing tries to deconstruct that value’s type, everything proceeds correctly.
Deconstruction is the key word. Some operations require knowing the underlying layout of a type:
- Composite literal construction (
T{}) - Indexing into an array or slice
- Accessing struct fields
- Dereferencing
- Type conversions
- Type assertions
- Receiving from a channel
Others do not. unsafe.Sizeof(new(T)) is fine during T’s construction: it computes the size of a pointer, which is always a fixed machine word regardless of what T contains. The type T is never inspected. But unsafe.Sizeof(T{}) constructs a composite literal, which requires knowing T’s underlying fields. If T is incomplete, that is undefined territory.
The new approach identifies two categories of sites in the type checker:
Upstream sites are expressions that produce potentially incomplete values. These include conversions, function calls, type assertions, channel receives, map indexing, and dereferences. When the value being produced has an incomplete type, these are the sites where the incomplete value originates.
Downstream sites are operations that consume values in a way that requires deconstructing the type. If an incomplete value from an upstream reaches a downstream, you have a cycle error.
The fix adds completeness checks at upstream sites. Before an incomplete value escapes into the expression tree, the checker verifies the type is complete:
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 safe to deconstruct from here
}
}
The invalid operand is a sentinel. Downstream operations that receive invalid skip further processing. This stops the incomplete value from ever reaching code that would try to inspect a type that does not yet exist.
The beauty of this approach is that it turns a search problem into a local check. The old bespoke cycle detection had to reason globally about how values flowed through the expression tree. The new model just asks, at each upstream site: is this type complete? If not, stop here.
Why Pointer Recursion Is Still Fine
This model explains intuitively why pointer-based recursive types do not cause problems:
type Node struct {
value int
next *Node
}
During construction of Node, the next field’s type is *Node. Constructing a pointer type does not require deconstructing its base type. The pointer’s size is always a machine word. The type checker points the pointer struct at the incomplete Node, and when Node finishes, the pointer is retroactively correct. No completeness check fires because no downstream deconstruction happens.
Slices and maps work similarly. A slice header is three words regardless of element type. A map is a pointer to a runtime hash table. Neither requires inspecting the element type’s layout at construction time.
Arrays are the exception. An array’s size in memory is len * sizeof(element). Constructing an array type requires knowing the element’s size, which requires the element type to be complete. That is why direct recursive array types:
type T [10]T // invalid
are rejected by the spec, and why unsafe.Sizeof(T{}) in an array length expression creates an unresolvable cycle.
The Broader Fix
One thing the blog post mentions that deserves emphasis: this change was not primarily about handling user-facing edge cases. Users who write type T [unsafe.Sizeof(T{})]int probably made a mistake and deserve a clear error message. The fix makes sure they get that message rather than a compiler panic.
The deeper motivation is simplification. The previous type construction algorithm carried several special cases to handle cycles in ways that did not generalize cleanly. Each special case was a potential gap. The incomplete value model is a single principle applied uniformly. Fewer special cases mean fewer panics and fewer surprises when future language features push into the same territory.
Go 1.26 also removed restrictions on self-referential generic types, allowing constraints like:
type Adder[A Adder[A]] interface {
Add(A) A
}
This kind of recursive type parameter constraint would have stressed the old cycle detection even further. A cleaner foundation in the type construction layer makes these features safer to ship.
What Other Languages Do
Rust handles recursive types through explicit indirection requirements. A type like enum List { Cons(i32, List), Nil } is rejected because its size cannot be computed, and the error message explicitly tells you to use Box<List> instead. The constraint is enforced at the spec level, not deep inside the type checker.
TypeScript’s structural type system can express recursive types freely because structural compatibility does not require knowing sizes, only shapes:
type Tree<T> = {
value: T;
children: Tree<T>[];
}
The TypeScript checker has its own cycle detection for infinite structural expansion, but since TypeScript does not compute memory layouts, the unsafe.Sizeof-style problem simply does not arise.
C++ templates perform cycle detection lazily: a template is instantiated on demand, and cycles are caught when instantiation does not terminate. This can produce extraordinarily deep error messages and sometimes compiler timeouts rather than clean errors.
Go’s approach sits between these. The type system is nominal rather than structural, so the checker maintains explicit construction state for each named type. That state is what makes the incomplete value model tractable: the checker always knows which types are currently being built and can check completeness in O(1) per upstream site.
What This Means in Practice
For the vast majority of Go programmers, Go 1.26 changes nothing visible. The refinement only affects code that triggers pathological type cycles, which is not something you do accidentally. The compiler is slightly more stable, particularly under the kinds of automated code generation and malformed input that stress test tools send through the type checker.
For tooling authors working with go/types directly, the more uniform behavior means fewer edge cases to work around. Static analysis tools, IDEs, and code generators that invoke the type checker on arbitrary input benefit from the improved robustness without any changes on their end.
The real significance is architectural. The Go team explicitly frames this as preparation for future improvements, not as a feature in itself. When you are working on a compiler, consolidating ad-hoc logic into a principled model is the kind of investment that pays off quietly over several releases. The incomplete value model gives type construction a cleaner internal contract, and that contract will matter when more complex type features land.