Go Type Checking

Type checking is the compiler phase that takes a purely syntactic AST — where an identifier is just a spelling — and proves the program means something well-typed: every operand has a type, every operation is defined on those types, every assignment is permitted, every call matches its signature, and every interface is genuinely satisfied. In the gc compiler this is the second phase, done by cmd/compile/internal/types2, which the compiler README describes as “a port of go/types… adapted to use the syntax package’s AST instead of go/ast.” The output is not a new tree but a decoration of the existing one: a side table mapping AST nodes to their resolved types and constant values, plus a fully built object graph of every declared name. If type checking fails, compilation stops here — no IR is ever produced.

Mental Model

A parser answers “is this grammatically a Go program?” The type checker answers the much harder question “is this a valid Go program?” — and to answer it, it must resolve names, infer types of expressions bottom-up, and verify rules top-down. Think of it as two interleaved processes running over the AST: identifier resolution (which declaration does the name x refer to?) and type assignment (what is the type of the expression x + 1?). Neither can finish without the other — you cannot type x + 1 until you have resolved x, and you sometimes cannot resolve a name until you know the type of the thing it is selected from (pkg.Foo vs value.Field).

The single most important data structure is the object. Every named entity — a variable, constant, type name, function, package, or struct field — becomes a types.Object (Var, Const, TypeName, Func, PkgName, Builtin, Nil, Label). An object has a name, a type, and a defining scope. Type checking is, in large part, the act of creating these objects and linking every identifier use to the object it denotes.

graph LR
    AST["Typed-untyped AST<br/>+ imported packages"]
    RES["Identifier resolution<br/>(build Scopes + Objects)"]
    INFER["Expression typing<br/>(bottom-up: assign a Type<br/>+ constant Value to every Expr)"]
    CHECK["Rule enforcement<br/>(assignability, op validity,<br/>interface satisfaction)"]
    OUT["types.Info:<br/>Defs, Uses, Types,<br/>Implicits, Selections"]
    AST --> RES --> INFER --> CHECK --> OUT
    INFER -.->|"selector needs<br/>receiver type"| RES

Diagram: type checking is not a single linear pass but a fixpoint over interleaved resolution and inference — selectors (a.b) force a detour back into resolution once the receiver’s type is known. The output is the types.Info map, the durable artifact every later phase and every tool consumes.

Mechanical Walk-through

What the checker proves

The Go language specification defines the rules; the checker is the executable form of those rules. The core ones:

  • Type identity. Two types are identical or different. Named (defined) types are always different from every other type, even structurally identical ones — type B0 A0 and type B1 []string are distinct even when A0 is []string. Unnamed type literals are identical iff structurally equivalent (spec, Type identity). This rule is why you cannot pass a MyInt where a plain int is expected without a conversion.
  • Assignability. A value x of type V is assignable to a variable of type T if any of: V and T are identical; they share an identical underlying type and at least one is not a named type; T is an interface and x implements T; x is nil and T is a pointer/func/slice/map/channel/interface; or x is an untyped constant representable as a T (spec, Assignability).
  • Operator validity. + is defined on numbers and strings but not on slices; < needs an ordered type; channel receive needs a channel. Each operator has a typed domain the checker enforces.
  • Constant evaluation. Untyped constants (1, 3.14, "x") carry arbitrary precision and only acquire a concrete type when assigned or used in a context that demands one. The checker evaluates constant expressions at compile time — const n = 1 << 62 is computed now, and overflow is a compile error.
  • Interface satisfaction. A concrete type satisfies an interface if its method set includes every method of the interface’s type set. Since Go 1.18 interfaces are type sets, not just method sets, and satisfaction is “is the concrete type an element of the interface’s type set?”

Identifier resolution and scopes

Go has lexical, block-structured scope (spec, Declarations and scope): predeclared identifiers in the universe block, package-level declarations in the package block, and function parameters/locals in nested block scopes. The checker builds a tree of types.Scope objects mirroring the block structure, inserts an Object for each declaration into the right scope, and resolves each identifier use by walking outward from its scope until a binding is found.

Two Go-specific wrinkles complicate this. First, package-level declarations are order-independent — a function may reference a var declared later in the file, or even in another file of the same package. The checker therefore cannot resolve names in a single top-to-bottom pass; it first collects all top-level objects, then types them, resolving forward references and detecting initialization cycles (var a = b; var b = a is a compile error). Second, short variable declarations (:=) can redeclare an existing variable in the same block as long as at least one new variable appears on the left and the type matches — field2, offset := nextField(...) reuses offset (spec example). The checker must distinguish redeclaration from a fresh declaration to get scoping right.

Type inference for generics

Before Go 1.18 every expression’s type was either written or trivially derived. Generics (Go 1.18) introduced real type inference: a call Min(3, 5) to func Min[T cmp.Ordered](a, b T) T must infer T = int. The checker does this with a constraint-solving algorithm — it gathers equations from typed function arguments (function-argument inference) and from constraint type sets (constraint type inference), then unifies them, finding a substitution for each type parameter that makes all the equations hold (Go blog, type inference). Go 1.21 broadened this materially: in 1.18 all inferred type parameters had to come from a single function, so you could not pass a generic, uninstantiated or partially instantiated function as an argument; 1.21 lifted that, so a generic function can be handed to another (possibly generic) function and type parameters from both are inferred together (Go blog, type inference). Go 1.21 also changed how multiple untyped numeric constants matched against one type parameter resolve — instead of erroring on the apparent conflict, the checker now picks the default type appearing later in the order int, rune, float64, complex128, matching ordinary constant-expression rules. The algorithm is one of the most intricate parts of types2.

The output: types.Info

The checker does not rewrite the AST. It populates a types.Info (in the standard library) or the equivalent in types2, a bundle of maps keyed by AST nodes:

  • Defs — maps each defining identifier to the Object it introduces.
  • Uses — maps each referencing identifier to the Object it denotes.
  • Types — maps each expression to a TypeAndValue (its type, and its constant value if any).
  • Selections — maps each a.b selector to a Selection recording whether it is a field or method, and the index path through embedded structs.
  • Implicits, Scopes, InitOrder — auxiliary facts (e.g. the package-level variable initialization order).

This map is the durable artifact. In the compiler, “noding” reads it to attach types to IR nodes; in external tooling, every analyzer that needs “what type is this?” consults it.

Code and Specification Examples

Running the standard-library type checker

package main
 
import (
	"fmt"
	"go/ast"
	"go/importer"
	"go/parser"
	"go/token"
	"go/types"
)
 
func main() {
	const src = `package p
var x = 21
var y = x * 2
const big = 1 << 62`
 
	fset := token.NewFileSet()
	f, _ := parser.ParseFile(fset, "p.go", src, 0) // 1
 
	conf := types.Config{Importer: importer.Default()} // 2
	info := &types.Info{
		Defs:  make(map[*ast.Ident]types.Object),       // 3
		Types: make(map[ast.Expr]types.TypeAndValue),
	}
	pkg, err := conf.Check("p", fset, []*ast.File{f}, info) // 4
	if err != nil {
		fmt.Println("type error:", err)
		return
	}
	for id, obj := range info.Defs {
		if obj != nil {
			fmt.Printf("%s: %s\n", id.Name, obj.Type()) // 5
		}
	}
	_ = pkg
}

Line 1 parses to an AST — type checking requires a parsed tree as input. Line 2 builds a Config; the Importer resolves import statements (the checker needs the exported types of imported packages to type-check uses of them). Line 3 allocates the result maps — the checker only fills maps you provide, so requesting Defs and Types says “I want these facts.” Line 4 runs the checker over the file set; a non-nil err means the program is ill-typed and compilation would stop. Line 5 prints each declared name with its inferred type — x and y come out as int, and big as untyped int with a constant value, demonstrating that the checker both assigns types and evaluates constants.

A program that fails type checking

package p
 
func f() int {
	var s []int
	return s + 1        // (a) invalid operation: + on slice
}
 
type Meters int
func g(m int) {}
func h() {
	var d Meters = 5
	g(d)                // (b) cannot use d (Meters) as int
}

Case (a): the parser is perfectly happy — s + 1 is a grammatical BinaryExpr. The type checker rejects it because + has no definition for a slice operand. Case (b): Meters is a defined type, so by the type-identity rule it is different from int; even though their underlying types match, d is a named type and int is the parameter type, so the “neither is named” branch of assignability does not apply. The fix is an explicit conversion g(int(d)). Both errors illustrate the division of labor: syntax is fine, semantics is not.

The spec rule, in code

type (
	A0 = []string   // alias: A0 IS []string
	A1 = A0         // A1 IS []string
	B0 A0           // defined type: distinct from []string
	B1 []string     // defined type: distinct from B0 and []string
)

A0 and A1 are type aliases (=), so they are merely other spellings of []string — identical to it and to each other. B0 and B1 are type definitions, each creating a brand-new named type that is different from []string and from each other (spec example). The checker tracks this distinction precisely; conflating aliases with definitions is a frequent source of confusion.

Common Misunderstandings

“Type checking and parsing are the same phase.” They are sharply separate. Parsing answers grammar; type checking answers meaning. A program can parse cleanly and still have dozens of type errors. The AST is the input to type checking, not its product.

types2 is go/types.” They are parallel implementations. go/types (standard library, stable, public API) works on go/ast. types2 (cmd/compile/internal, unstable, internal) works on the compiler’s syntax AST and is the one the compiler actually uses. The Go team keeps them in close lockstep — types2 started as a port and bug fixes are mirrored — but they are distinct codebases. Tooling uses go/types; the compiler uses types2.

“Untyped constants have a type.” They have a default type (int, float64, rune, complex128, string, bool) that materializes only when context forces it. Until then 1 is “untyped int” with arbitrary precision — which is why var x float64 = 1 and var y int = 1 both work from the same literal, and why 1 << 62 does not overflow an int at the point of declaration of an untyped constant.

“If it compiles, the types are fully known at runtime in the same form.” Type checking is a compile-time proof. At runtime, the type information that survives is the runtime type descriptors used by interfaces and reflection — a different, smaller representation. The rich types.Info graph is a compiler/tooling artifact, not a runtime structure.

“Interface satisfaction is checked by name.” Go interfaces are satisfied structurally — a type satisfies io.Reader if it has a Read([]byte) (int, error) method, with no implements keyword and no declared relationship. The checker computes method sets and verifies the type set membership; this is the basis of Go’s duck-typed interfaces.

Alternatives and When to Choose Them

For analyzing types outside the compiler, go/types is the only real choice — it is the public, supported API, and the golang.org/x/tools/go/packages loader exists to feed it parsed-and-imported package graphs with minimal boilerplate. Reach for go/packages + go/types whenever an analyzer needs more than syntax: any check involving “what does this method return” or “does this satisfy that interface” is a go/types query.

Other languages take different stances the checker’s design contrasts with: nominal-only systems (Java, C#) require explicit implements; full structural systems (TypeScript) make even non-interface types structurally compatible. Go is deliberately in between — nominal for defined types, structural for interface satisfaction — and the type-identity rules above are exactly where that line is drawn.

You would not implement your own type checker; the surprising-amount-of-subtlety (generics inference, constant evaluation, initialization-cycle detection) is why go/types is thousands of lines. Build on it.

Production Notes

go/types powers go vet, staticcheck, gopls, golangci-lint, and essentially every serious Go static analyzer. The golang.org/x/tools/go/analysis framework hands each analyzer a *types.Package, the types.Info, and the AST together, so analyzers can reason about types without re-running the checker. The official golang/example/gotypes tutorial is the canonical learning resource for this API.

Type checking is also a measurable slice of compile time. The compiler’s “noding” via Unified IR was partly motivated by making type information flow efficiently between packages — exported type data is serialized once and re-read, so a package’s dependents do not re-type-check it. For very large builds, the cost of type-checking and importing dominates, which is why the build cache and incremental compilation matter so much.

Version notes (as of Go 1.26, May 2026)

The relationship between the two checkers is stable: types2 remains, in the words of the current compiler README, “a port of go/types to use the syntax package’s AST instead of go/ast (compiler README). They are kept in close lockstep — fixes are mirrored — but stay distinct because they operate on different AST representations and because go/types is a frozen public API while types2 is free to change internally. The Go 1.26 release made no change to the type-inference algorithm; its only language-level changes (the self-referential generic-type relaxation and new() taking an expression) are minor and orthogonal to inference (Go 1.26 release notes). The one upcoming type-checking change to watch: the gotypesalias GODEBUG (added in Go 1.22) is slated for removal in Go 1.27, after which go/types will always represent a type alias as an Alias node rather than collapsing it to its aliasee, regardless of GODEBUG or the module’s go language version (Go 1.26 release notes, go/types). That makes the alias-vs-definition distinction discussed above explicit in the type graph itself, which analyzers walking types.Info should account for.

See Also