LmCast :: Stay tuned in

Type Construction and Cycle Detection

Recorded: March 28, 2026, 4 a.m.

Original Summarized

Type Construction and Cycle Detection - The Go Programming Language

Skip to Main Content

Why Go arrow_drop_down

Press Enter to activate/deactivate dropdown

Case Studies

Common problems companies solve with Go

Use Cases

Stories about how and why companies use Go

Security

How Go can help keep you secure by default

Learn

Press Enter to activate/deactivate dropdown

Docs arrow_drop_down

Press Enter to activate/deactivate dropdown

Go Spec

The official Go language specification

Go User Manual

A complete introduction to building software with Go

Standard library

Reference documentation for Go's standard library

Release Notes

Learn what's new in each Go release

Effective Go

Tips for writing clear, performant, and idiomatic Go code

Packages

Press Enter to activate/deactivate dropdown

Community arrow_drop_down

Press Enter to activate/deactivate dropdown

Recorded Talks

Videos from prior events

Meetups
open_in_new

Meet other local Go developers

Conferences
open_in_new

Learn and network with Go developers from around the world

Go blog

The Go project's official blog.

Go project

Get help and stay informed from Go

Get connected

Why Go navigate_next

navigate_beforeWhy Go

Case Studies

Use Cases

Security

Learn

Docs navigate_next

navigate_beforeDocs

Go Spec

Go User Manual

Standard library

Release Notes

Effective Go

Packages

Community navigate_next

navigate_beforeCommunity

Recorded Talks

Meetups
open_in_new

Conferences
open_in_new

Go blog

Go project

Get connected

The Go Blog
Type Construction and Cycle Detection

Mark Freeman
24 March 2026

Go’s static typing is an important part of why Go is a good fit for production
systems that have to be robust and reliable. When a Go package is compiled, it
is first parsed—meaning that the Go source code within that package is converted
into an abstract syntax tree (or AST). This AST is then passed to the Go
type checker.
In this blog post, we’ll dive into a part of the type checker we significantly
improved in Go 1.26. How does this change things from a Go user’s perspective?
Unless one is fond of arcane type definitions, there’s no observable change
here. This refinement was intended to reduce corner cases, setting us up for
future improvements to Go. Also, it’s a fun look at something that seems quite
ordinary to Go programmers, but has some real subtleties hiding within.
But first, what exactly is type checking? It’s a step in the Go compiler that
eliminates whole classes of errors at compile time. Specifically, the Go type
checker verifies that:

Types appearing in the AST are valid (for example, a map’s key type must be
comparable).
Operations involving those types (or their values) are valid (for example,
one can’t add an int and a string).

To accomplish this, the type checker constructs an internal representation for
each type it encounters while traversing the AST—a process informally called
type construction.
As we’ll soon see, even though Go is known for its simple type system, type
construction can be deceptively complex in certain corners of the language.
Type construction
Let’s start by considering a simple pair of type declarations:
type T []U
type U *int

When the type checker is invoked, it first encounters the type declaration for
T. Here, the AST records a type definition of a type name T and a
type expression []U. T is a defined type; to represent
the actual data structure that the type checker uses when constructing defined
types, we’ll use a Defined struct.
The Defined struct contains a pointer to the type for the type expression to
the right of the type name. This underlying field is relevant for sourcing the
type’s underlying type. To help illustrate the
type checker’s state, let’s see how walking the AST fills in the data
structures, starting with:

At this point, T is under construction, indicated by the color yellow. Since
we haven’t evaluated the type expression []U yet—it’s still black—underlying
points to nil, indicated by an open arrow.
When we evaluate []U, the type checker constructs a Slice struct, the
internal data structure used to represent slice types. Similarly to Defined,
it contains a pointer to the element type for the slice. We don’t yet know what
the name U refers to, though we expect it to refer to a type. So, again, this
pointer is nil. We are left with:

By now you might be getting the gist, so we’ll pick up the pace a bit.
To convert the type name U to a type, we first locate its declaration. Upon
seeing that it represents another defined type, we construct a separate
Defined for U accordingly. Inspecting the right side of U, we see the type
expression *int, which evaluates to a Pointer struct, with the base type of
the pointer being the type expression int.
When we evaluate int, something special happens: we get back a predeclared
type. Predeclared types are constructed before the type checker even begins
walking the AST. Since the type for int is already constructed, there’s
nothing for us to do but point to that type.
We now have:

Note that the Pointer type is complete at this point, indicated by the color
green. Completeness means that the type’s internal data structure has all of its
fields populated and any types pointed to by those fields are complete.
Completeness is an important property of a type because it ensures that
accessing the internals, or deconstruction, of that type is sound: we have all
the information describing the type.
In the image above, the Pointer struct only contains a base field, which
points to int. Since int has no fields to populate, it’s “vacuously”
complete, making the type for *int complete.
From here, the type checker begins unwinding the stack. Since the type for
*int is complete, we can complete the type for U, meaning we can complete
the type for []U, and so on for T. When this process ends, we are left with
only complete types, as shown below:

The numbering above shows the order in which the types were completed (after the
Pointer). Note that the type on the bottom completed first. Type construction
is naturally a depth-first process, since completing a type requires its
dependencies to be completed first.
Recursive types
With this simple example out of the way, let’s add a bit more nuance. Go’s type
system also allows us to express recursive types. A typical example is something
like:
type Node struct {
next *Node
}

If we reconsider our example from above, we can add a bit of recursion by
swapping *int for *T like so:
type T []U
type U *T

Now for a trace: let’s start once more with T, but skip ahead to illustrate
the effects of this change. As one might suspect from our previous example, the
type checker will approach the evaluation of *T with the below state:

The question is what to do with the base type for *T. We have an idea of what
T is (a Defined), but it’s currently being constructed (its underlying is
still nil).
We simply point the base type for *T to T, even though T is incomplete:

We do this assuming that T will complete when it finishes construction
in the future (by pointing to a complete type). When that happens, base will
point to a complete type, thus making *T complete.
In the meantime, we’ll begin heading back up the stack:

When we get back to the top and finish constructing T, the “loop” of types
will close, completing each type in the loop simultaneously:

Before we considered recursive types, evaluating a type expression always
returned a complete type. That was a convenient property because it meant the
type checker could always deconstruct (look inside) a type returned from
evaluation.
But in the example above, evaluation of T returned an incomplete
type, meaning deconstructing T is unsound until it completes. Generally
speaking, recursive types mean that the type checker can no longer assume that
types returned from evaluation will be complete.
Yet, type checking involves many checks which require deconstructing a type. A
classic example is confirming that a map key is comparable, which requires
inspecting the underlying field. How do we safely interact with incomplete
types like T?
Recall that type completeness is a prerequisite for deconstructing a type. In
this case, type construction never deconstructs a type, it merely refers to
types. In other words, type completeness does not block type construction
here.
Because type construction isn’t blocked, the type checker can simply delay such
checks until the end of type checking, when all types are complete (note that
the checks themselves also do not block type construction). If a type were to
reveal a type error, it makes no difference when that error is reported during
type checking—only that it is reported eventually.
With this knowledge in mind, let’s examine a more complex example involving
values of incomplete types.
Recursive types and values
Let’s take a brief detour and have a look at Go’s
array types. Importantly, array types have a size,
which is a constant that is part of the type. Some
operations, like the built-in functions unsafe.Sizeof and len can return
constants when applied to certain values or
expressions, meaning they can appear as array
sizes. Importantly, the values passed to those functions can be of any type,
even an incomplete type. We call these incomplete values.
Let’s consider this example:
type T [unsafe.Sizeof(T{})]int

In the same way as before, we’ll reach a state like the below:

To construct the Array, we must calculate its size. From the value expression
unsafe.Sizeof(T{}), that’s the size of T. For array types (such as T),
calculating their size requires deconstruction: we need to look inside the type
to determine the length of the array and size of each element.
In other words, type construction for the Array does deconstruct T,
meaning the Array cannot finish construction (let alone complete) before T
completes. The “loop” trick that we used earlier—where a loop of types
simultaneously completes as the type starting the loop finishes
construction—doesn’t work here.
This leaves us in a bind:

T cannot be completed until the Array completes.
The Array cannot be completed until T completes.
They cannot be completed simultaneously (unlike before).

Clearly, this is impossible to satisfy. What is the type checker to do?
Cycle detection
Fundamentally, code such as this is invalid because the size of T cannot be
determined without knowing the size of T, regardless of how the type checker
operates. This particular instance—cyclic size definition—is part of a class of
errors called cycle errors, which generally involve cyclic definition of Go
constructs. As another example, consider type T T, which is also in this
class, but for different reasons. The process of finding and reporting cycle
errors in the course of type checking is called cycle detection.
Now, how does cycle detection work for type T [unsafe.Sizeof(T{})]int? To
answer this, let’s look at the inner T{}. Because T{} is a composite literal
expression, the type checker knows that its resulting value is of type T.
Because T is incomplete, we call the value T{} an incomplete value.
We must be cautious—operating on an incomplete value is only sound if it doesn’t
deconstruct the value’s type. For example, type T [unsafe.Sizeof(new(T))]int
is sound, since the value new(T) (of type *T) is never deconstructed—all
pointers have the same size. To reiterate, it is sound to size an incomplete
value of type *T, but not one of type T.
This is because the “pointerness” of *T provides enough type information for
unsafe.Sizeof, whereas just T does not. In fact, it’s never sound to operate
on an incomplete value whose type is a defined type, because a mere type name
conveys no (underlying) type information at all.
Where to do it
Up to now we’ve focused on unsafe.Sizeof directly operating on potentially
incomplete values. In type T [unsafe.Sizeof(T{})], the call to unsafe.Sizeof
is just the “root” of the array length expression. We can readily imagine the
incomplete value T{} as an operand in some other value expression.
For example, it could be passed to a function (i.e.
type T [unsafe.Sizeof(f(T{}))]int), sliced (i.e.
type T [unsafe.Sizeof(T{}[:])]int), indexed (i.e.
type T [unsafe.Sizeof(T{}[0])]int), etc. All of these are invalid because they
require deconstructing T. For instance, indexing T
requires checking the underlying type of T.
Because these expressions “consume” potentially incomplete values, let’s call
them downstreams. There are many more examples of downstream operators, some
of which are not syntactically obvious.
Similarly, T{} is just one example of an expression that “produces” a
potentially incomplete value—let’s call these kinds of expressions upstreams:

Comparatively, there are fewer and more syntactically obvious value expressions
that might result in incomplete values. Also, it’s rather simple to enumerate
these cases by inspecting Go’s syntax definition. For these reasons, it’ll be
simpler to implement our cycle detection logic via the upstreams, where
potentially incomplete values originate. Below are some examples of them:

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

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

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

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

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

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

// ... and a handful more

For each of these cases, the type checker has extra logic where that particular
kind of value expression is evaluated. As soon as we know the type of the
resulting value, we insert a simple test that checks that the type is complete.
For instance, in the conversion example type T [unsafe.Sizeof(T(42))]int,
there is a snippet in the type checker that resembles:
func callExpr(call *syntax.CallExpr) operand {
x := typeOrValue(call.Fun)
switch x.mode() {
// ... other cases
case typeExpr:
// T(), meaning it's a conversion
T := x.typ()
// ... handle the conversion, T *is not* safe to deconstruct
}
}

As soon as we observe that the CallExpr is a conversion to T, we know that
the resulting type will be T (assuming no preceding errors). Before we pass
back a value (here, an operand) of type T to the rest of the type checker,
we need to check for completeness of T:
func callExpr(call *syntax.CallExpr) operand {
x := typeOrValue(call.Fun)
switch x.mode() {
// ... other cases
case typeExpr:
// T(), meaning it's a conversion
T := x.typ()
+ if !isComplete(T) {
+ reportCycleErr(T)
+ return invalid
+ }
// ... handle the conversion, T *is* safe to deconstruct
}
}

Instead of returning an incomplete value, we return a special invalid operand,
which signals that the call expression could not be evaluated. The rest of the
type checker has special handling for invalid operands. By adding this, we
prevented incomplete values from “escaping” downstream—both into the rest of the
type conversion logic and to downstream operators—and instead reported a cycle
error describing the problem with T.
A similar code pattern is used in all other cases, implementing cycle detection
for incomplete values.
Conclusion
Systematic cycle detection involving incomplete values is a new addition to the
type checker. Before Go 1.26, we used a more complex type construction algorithm,
which involved more bespoke cycle detection that didn’t always work. Our new,
simpler approach addressed a number of (admittedly esoteric) compiler panics
(issues #75918, #76383,
#76384, #76478, and more), resulting in a more
stable compiler.
As programmers, we’ve become accustomed to features like recursive type
definitions and sized array types such that we might overlook the nuance of
their underlying complexity. While this post does skip over some finer details,
hopefully we’ve conveyed a deeper understanding of (and perhaps appreciation
for) the problems surrounding type checking in Go.

Previous article: //go:fix inline and the source-level inliner
Blog Index

Why Go

Use Cases

Case Studies

Get Started

Playground

Tour

Stack Overflow

Help

Packages

Standard Library

About Go Packages

About

Download

Blog

Issue Tracker

Release Notes

Brand Guidelines

Code of Conduct

Connect

Bluesky

Mastodon

Twitter

GitHub

Slack

r/golang

Meetup

Golang Weekly

Opens in new window.

Copyright

Terms of Service

Privacy Policy

Report an Issue

go.dev uses cookies from Google to deliver and enhance the quality of its services and to
analyze traffic. Learn more.
Okay

Type Construction and Cycle Detection – The Go Programming Language

Mark Freeman, 24 March 2026

Go’s static typing plays a crucial role in creating robust and reliable production systems, and the type checker is a key component of this. When a Go package is compiled, the source code is first parsed into an abstract syntax tree (AST). This AST then passes through the Go type checker, which verifies type validity and operations. This blog post delves into a significant improvement within the Go type checker, specifically addressing cycle detection, highlighting its impact on Go users while offering an insight into the underlying intricacies. The primary goal of this internal refinement was to mitigate corner cases and set the stage for future enhancements to Go’s type system, presenting a detailed, thorough, and complex explanation of the process.

The core function of the type checker is to eliminate errors at compile time by verifying type compatibility and ensuring the validity of operations. This is achieved through “type construction,” an internal representation process where the type checker analyzes the AST and creates an internal representation for each encountered type. This process isn’t immediately visible to the programmer, but it’s the foundational step in determining the correctness of the code.

Type construction involves traversing the AST to identify and represent types. Let's breakdown the process with a simple example: defining type `T` as `[]U` and type `U` as `*int`. The type checker constructs an internal representation for each, building up a hierarchy of type information. Each type is represented internally by a `Defined` struct which contains a pointer to the type expression to the right of the type name.

The process involves a depth-first traversal of the AST, creating type structures. Completeness is a critical property – a complete type signifies that its internal data structures are fully populated, insuring that operations on that type are sound. A complete type has all fields populated and any internally referenced types are themselves complete.

A more complex scenario arises with recursive types, like `Node` defined as `struct { next *Node }`. Building a recursive type requires careful attention to how types are constructed and linked. The type checker needs to anticipate and handle the cyclical nature of these definitions to prevent infinite loops of type construction. Cycle detection involves identifying situations where a type references itself or a circular dependency of types, halting the construction process to avoid an infinite recursion and a crash.

The key insight is that type completeness is not automatically achieved. The type checker must proactively verify that types are truly complete before attempting to deconstruct them – before operating on a type in a way that requires examining its internal structure. This is handled through techniques like cycle detection.

The cycle detection mechanism specifically targets scenarios where an incomplete type is used in a way that requires knowing its definitive type. This typically manifests when an operation attempts to deconstruct a partially constructed type, triggering a potential error. The type checker proactively checks for this situation, reporting a cycle error if detected, preventing more serious issues during runtime. These checks were implemented through several upstream expressions, offering a comprehensive approach to ensure consistency and security.

This improved approach dramatically reduced corner cases and enhances the stability of the Go compiler. The blog post highlights an often-overlooked aspect of Go’s type system—the meticulous work performed behind the scenes to ensure type safety and reliability. It provides a clear and detailed explanation of the logic behind the refinements.