· 6 min read ·

Type Completeness and the Cycle Problem in Go's Compiler

Source: go

Go’s type checker has to solve a problem that looks straightforward until you examine it closely. When the compiler encounters type T []U, it needs to build an internal representation of T before it necessarily knows what U is, because U might be defined later in the same package. The checker handles this by creating incomplete types: forward references that get filled in once the referenced type is processed. Most of the time, this works without incident. The trouble starts when types form cycles, and that is where Go 1.26, as described on the Go blog, made a significant improvement.

What Type Construction Involves

The Go type checker processes types depth-first. For a pair of definitions like:

type T []U
type U *int

the checker creates a Defined node for T, then recursively builds the Slice type for []U, then a Defined node for U, then a Pointer type for *int. Each type starts incomplete and transitions to complete once all its internal fields are populated with complete types.

The foundational property that makes this tractable is that type construction never needs to deconstruct types. When the checker builds []U, it stores a pointer to U’s node. It does not yet care what U’s underlying structure is. This is what makes mutually recursive types workable:

type T []U
type U *T

Here, T points to a Slice whose element type is U, and U points to a Pointer whose base type is T. Both types are incomplete when the checker first encounters them. The checker lets *T’s base type point to an incomplete T, on the assumption that T will complete later. When construction finishes, both types complete simultaneously. The mutual reference is valid because construction only stores references; it never reads underlying type structure.

Where Cycles Become Illegal

A specific class of type expressions breaks this model: cases where you must know a type’s full underlying structure to complete the type itself. The canonical example is:

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

To determine the array length, the checker must evaluate unsafe.Sizeof(T{}). That requires creating a zero value of type T, which requires deconstructing T’s underlying structure. But T cannot be complete until the array length is resolved. Neither can complete without the other, and no sequence of steps breaks the dependency.

Compare that with:

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

This definition is valid. A pointer always occupies the same amount of memory regardless of the pointee type, so evaluating unsafe.Sizeof(new(T)) does not require reading T’s underlying structure. The distinction is precise: the illegal case tries to operate on a value of an incomplete defined type in a way that depends on that type’s memory layout.

This is the category of error Go’s type checker must catch cleanly. The definition does not look dangerous at a glance, but it encodes a genuine logical impossibility.

The Upstream-Downstream Model

Before Go 1.26, cycle detection in the type checker was handled through a collection of special cases accumulated over time. This approach had gaps. The Go issue tracker documented several situations where the checker would panic instead of emitting a proper diagnostic: issues #75918, #76383, #76384, and #76478 among others. A compiler panic on user-provided code, however obscure that code might be, is always a correctness failure.

The 1.26 improvement introduces a systematic framework built around what the blog post calls upstream and downstream expressions. Upstream expressions are the specific syntactic sites where a value of a potentially incomplete defined type can be produced: explicit conversions like T(42), function calls, type assertions, channel receive operations, map index expressions, and pointer dereferences. Downstream expressions are the operations that consume such values in ways that require reading the type’s underlying structure: indexing, slicing, passing as a function argument, and similar.

Rather than tracking the full dependency graph at every point during construction, the checker now inserts a completeness test at each upstream expression site. If the type in question is not yet complete, the checker reports a cycle error and returns a special invalid operand:

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 deconstruct below
  }
}

The invalid operand propagates through subsequent checks in a way that suppresses cascading false errors. Without this, a single cycle would cause a chain of dependent checks to fail with misleading diagnostics. With it, the checker reports the root cause once and continues without panicking.

The key observation the approach relies on is that an incomplete type can only reach a downstream operation if something upstream first produced a value of that type. Placing the completeness check at the upstream site covers all possible downstream uses in one step, without requiring the checker to enumerate every possible way an incomplete value might be deconstructed later.

How Other Languages Handle This

Rust takes a structurally different approach. Its type system disallows recursive types that would have infinite size, and it enforces this at the syntax level:

struct Node {
    next: Node,  // error: recursive type `Node` has infinite size
}

The standard fix is to introduce explicit pointer indirection with Box<T>:

struct Node {
    next: Option<Box<Node>>,
}

Because Box<T> is always pointer-sized regardless of T, the compiler can determine the size of Node without recursing into itself. Rust does not need sophisticated cycle detection for this class of problem because the type system’s sizing rules make infinite-size types a syntax error rather than a semantic one. The programmer annotates indirection explicitly, which eliminates the ambiguity Go faces.

TypeScript operates under a fundamentally different set of constraints. Its structural type system allows recursive type aliases without restriction:

type JSONValue =
  | string
  | number
  | boolean
  | null
  | JSONValue[]
  | { [key: string]: JSONValue };

This works because TypeScript does not compute value sizes; its type compatibility is structural and purely logical. The TypeScript compiler does track which type aliases are currently being expanded to prevent infinite loops during subtype checking, but the failure mode is a depth limit or an “excessively deep” error, not a panic or a layout cycle. There is simply no memory layout to get wrong.

Go sits between these two. It is nominally typed and values have concrete memory layouts, so the checker must resolve sizes for types like arrays. At the same time, it supports recursive types through pointer indirection without requiring the programmer to annotate anything beyond what the type definition already expresses. The challenge is distinguishing the legitimate recursive definitions from the impossible ones, and doing so without resorting to programmer-visible annotations or panicking on edge cases.

The Broader Significance

The types of code that triggered the pre-1.26 panics were unusual: output from code generators, reflection-heavy packages, and code that exercises the boundaries of what the type system permits. Unusual does not mean invalid, though, and the language specification makes no exception for code that is merely uncommon. If a definition is syntactically and semantically legal, the compiler must handle it without crashing.

The upstream-downstream framework provides a uniform place to insert completeness checks across the entire type checker, rather than patching individual cases as they are reported. That uniformity is what makes the fix durable. If a new expression form is added to Go in the future, the framework provides a clear model for where to add the appropriate guard; there is no need to audit the entire checker for places an incomplete value might slip through.

Go’s development has been deliberately conservative since generics arrived in 1.18, with many improvements tightening internal consistency rather than expanding the user-facing surface area. Changes like this one, invisible to the vast majority of programmers but essential for compiler correctness, are part of what makes that conservatism sustainable. A solid foundation is the prerequisite for building anything more on top of it, and cycle detection that panics instead of diagnosing is not a solid foundation.

Was this interesting?