Most Go developers interact with the type checker as a black box that yells at them when they make mistakes. It catches bad map key types, mismatched assignments, invalid conversions. The feedback loop is so reliable that you stop thinking about the machinery underneath. Go 1.26 made a significant change to that machinery, specifically to how the type checker handles cycle detection during type construction, and it is worth understanding what that means and why it took this long to get right.
What Type Construction Actually Does
When the Go compiler encounters a package, it first parses source into an abstract syntax tree, then hands that AST to the type checker. The type checker’s job includes constructing internal representations for every type it encounters. This process, called type construction, is deceptively involved.
The internal representation of a type like []int is a Slice node wrapping an Int node. For *int, it is a Pointer node wrapping Int. A user-defined type like type T []int is represented as a Defined node with an underlying Slice node. A type is considered “complete” when all its internal fields are populated and all types it transitively references are also complete.
The completion sequence for simple types is straightforward:
type T []U
type U *int
Here, *int completes first (the Pointer node just wraps the already-complete int). Then U completes because its underlying type is complete. Then []U completes because U is complete. Then T completes because its underlying slice type is complete.
Now introduce a cycle:
type T []U
type U *T
This is valid Go. The key insight is that pointer types are special: a Pointer node does not need its element type to be complete in order to be complete itself, because a pointer always has the same size and representation regardless of what it points to. So the type checker can close this cycle by constructing U as a pointer to an incomplete T, then finishing T (which references U, now complete), and then retroactively marking everything as complete.
This is the general principle: some type constructors can work with incomplete types, and others cannot.
When Cycles Become Illegal
The problematic cases arise when a type’s own construction depends on knowing something about itself that requires construction to already be finished. The canonical example from the Go 1.26 blog post is:
type T [unsafe.Sizeof(T{})]int
This tries to define T as an array whose length is determined by the size of T{}, a value of type T. But to compute unsafe.Sizeof(T{}), you need to know how large T is. And to know how large T is, you need to know its array length. The cycle is genuinely circular with no pointer-style escape hatch.
Contrast that with:
type T [unsafe.Sizeof(new(T))]int
This is valid. new(T) returns a *T, a pointer, and all pointers have the same size regardless of what they point to. The cycle is cut by the pointer indirection.
So the rule is conceptually clean: you can reference an incomplete type through a pointer (or other indirections that do not require knowing the underlying size or structure), but you cannot reference an incomplete type in a way that requires deconstructing its value.
Why Detection Was Hard
The tricky part is not defining the rule; it is enforcing it systematically across every expression form the type checker encounters. An unsafe.Sizeof call can contain arbitrary expressions. Each expression form has its own semantics with respect to type completeness:
- Composite literals like
T{}requireTto be complete, because constructing a value requires knowing the field layout. - Conversions like
T(42)requireTto be complete in the same way. - Function calls like
f()wherefreturnsTproduce an incomplete value ifTis incomplete. - Type assertions like
i.(T)produce an incomplete value. - Channel receives like
<-chwherechis of typechan Tproduce an incomplete value. - Map index expressions like
m[k]wheremismap[K]Tproduce an incomplete value. - Pointer dereferences like
*pwherepis*Tproduce an incomplete value.
The last one is subtle. *p dereferences a pointer, which should be safe, but the result is a value of type T. If T is incomplete, passing that value to unsafe.Sizeof creates the same circular dependency.
Before Go 1.26, cycle detection for these cases was implemented in an ad-hoc way, scattered across different parts of the type checker, with each expression handler partially trying to detect its own problematic cases. The result was a collection of bugs: compiler panics on certain edge cases that had never been caught, documented in issues like #75918, #76383, #76384, and #76478 among others. Each of these was a case where the type checker encountered a cycle it did not know how to handle and simply crashed.
The Systematic Fix
The Go 1.26 improvement centralizes cycle detection by identifying the class of expressions that can produce incomplete values (the blog post calls these “upstream expressions”) and inserting a completeness check at each one. When an expression produces a value and that value’s type is incomplete, the type checker reports a cycle error and returns an invalid operand, preventing any further processing that would cause a panic or incorrect output.
In pseudocode, the pattern looks roughly like:
func checkExpression(expr syntax.Expr) operand {
result := evaluateExpr(expr)
if !isComplete(result.typ()) {
reportCycleError(result.typ())
return invalidOperand
}
return result
}
The key is that this check happens at the expression level, not in some central dispatch or at the point where unsafe.Sizeof is processed. By the time the size computation runs, the value has already been validated. Any incomplete type that tries to sneak through via an expression boundary is caught before it causes trouble.
This approach is in contrast to trying to detect cycles by tracking which types are currently being constructed, which is the more naive approach and is what led to the scattered, incomplete prior implementation. The problem with tracking construction state is that you need to propagate that state through every code path, and it is easy to miss one.
Comparison With Other Languages
The difficulty Go faces here is partly a consequence of its design choice to allow size-dependent types (arrays whose length is a compile-time constant expression) combined with a rich expression language for those constants. This is not a universally shared problem.
Rust allows recursive types only through indirection, enforced at the structural level: a type like struct Foo { data: Foo } is a compile error because it would require infinite storage. The pointer version struct Foo { data: Box<Foo> } is fine. Rust’s type checker does not need to evaluate expressions to detect this because the rule is purely structural.
TypeScript handles recursive types extensively in its structural type system, where cycles through interface references are valid because all types are reference-like at the type level. The JavaScript runtime semantics mean you never have the array-length-from-value problem.
Haskell and other ML-family languages use equirecursive or isorecursive type systems where recursive types are normal and explicitly handled by the type system from first principles. The problem Go has does not arise there because arrays with computed lengths are not part of those type systems in the same way.
Go sits in a space where it has both a structural nominal type system and compile-time constant expressions powerful enough to express cycles, which means cycle detection cannot be purely structural; it has to involve expression evaluation.
What This Means for Go Developers
For most Go code, nothing changes. The patterns that trigger these cycles are genuinely arcane and most developers will never write them intentionally. The improvement is about making the compiler robust against the edge cases, not about enabling new programming patterns.
The value is in what this enables downstream. The blog post notes explicitly that this refinement reduces corner cases and sets up future improvements to Go. A type checker that behaves predictably and systematically at its edges is easier to extend. When the Go team wants to add a new language feature that interacts with type construction, they can reason about its behavior without worrying about whether a subtle cycle case will cause a panic in some unexpected code path.
That kind of internal cleanup is often the unglamorous work that makes ambitious features possible later. Go has been deliberate about language stability and coherence from the beginning, and this change fits that tradition: fix the foundation so the floor does not shift under whatever gets built on top of it.
The full write-up of the change, including more detailed examples and the reasoning behind each expression form, is on the Go blog. If you work on Go tooling, write analysis passes with go/types, or are just curious about how type systems deal with self-referential definitions, it is worth reading in full.