Understanding the Go compiler: The Type Checker

Understanding the Go compiler: The Type Checker

In the previous posts, we explored the scanner—which converts source code into tokens—and the parser—which takes those tokens and builds an Abstract Syntax Tree.

In future posts, I’ll cover the Intermediate Representation (IR)—how the compiler transforms the AST into an intermediate lower-level form. But before we can get there, we need to talk about two crucial intermediate steps: type checking (this post) and the Unified IR (which I’ll cover in a separate post soon).

You might wonder: why can’t we go straight from the AST to IR? Here’s the thing—the AST is just structure. It tells us that fmt.Println("Hello world") is a function call with one argument, but it doesn’t tell us whether fmt is a valid package, whether Println actually exists, or whether the string argument is the right type. The AST has no idea if your code makes sense.

That’s where the type checker comes in. Its job is to verify that your program is valid. It resolves names (what does fmt refer to?), checks types (does this function accept a string?), and ensures all the rules of Go’s type system are followed. Only after the code passes type checking can the compiler safely transform it into IR.

Let me show you what that actually means in practice.

What Does Type Checking Actually Do?

Type checking is more than just checking types. It’s a comprehensive validation phase that handles several critical tasks.

First, it resolves identifiers—connecting each name to its declaration. When you write fmt, the type checker figures out that’s the package you imported. When you write Println, it looks up that name in the fmt package and confirms it exists.

Second, it checks types—making sure operations are valid for their types. Can you add a string to an int? Nope, and the type checker will tell you that. Can you pass a string to a function expecting an io.Reader? Also nope.

Third, it validates initialization—figuring out the order to initialize package-level variables without creating cycles. If variable a depends on b, then b needs to be initialized first.

Fourth, it handles generics—instantiating generic types with concrete type arguments and validating that those types satisfy the constraints.

And finally, it detects errors—reporting mismatched types, undefined names, invalid operations, and more.

Think of the type checker as your code’s fact-checker. The parser already confirmed your code has valid structure, but now the type checker asks: “Does this actually make sense according to Go’s rules?”

Let’s see what happens when we type-check our hello world program:

package main

import "fmt"

func main() {
    fmt.Println("Hello world")
}

The type checker needs to answer several questions:

  • Is main a valid package name? ✓
  • Does the fmt package exist? ✓
  • Is Println a function in fmt? ✓
  • Can Println accept a string argument? ✓
  • Is main a valid entry point (right signature)? ✓

Only after all these checks pass does the compiler move forward.

Now, answering all those questions isn’t as simple as making one pass through the code. The type checker needs a sophisticated strategy to handle Go’s complexity.

The Big Picture: Type Checking Phases

The Go type checker isn’t a single pass. It’s a carefully orchestrated multi-phase process that handles Go’s complexity—forward references, recursive types, initialization dependencies, and generics.

Here’s the basic flow: the type checker first validates the package structure (initFiles), then scans all declarations to create placeholders (collectObjects), type-checks those declarations but skips function bodies (packageObjects), and then goes back to check the function bodies (processDelayed). After that, there are a few cleanup phases to handle things like initialization order and unused imports.

Why so many phases? Because Go allows forward references and recursive types—you can reference something before it’s declared, and the compiler needs to handle that gracefully.

Let’s walk through the first four phases in detail—those are where the interesting work happens—using our hello world program to see exactly what the type checker does at each step.

Phase 1: Initialization and Setup

The type checker starts in src/cmd/compile/internal/types2/check.go:468. It creates a Checker object—the main state machine for type checking—and begins working through the phases.

First up is initFiles, which validates the package structure:

func (check *Checker) initFiles(files []*syntax.File) {
    // Verify all files have the same package name
    // Extract Go version from build tags
    // Store file-specific state
}

For our hello world program, this phase:

  • Confirms the file declares package main
  • Records the Go version (from build tags or default)
  • Resets internal state for a fresh type-checking run

Nothing too exciting here—just making sure the basics are in order. Once the setup is complete, the type checker can move on to more interesting work.

Phase 2: Collecting Objects

With initialization done, the type checker enters collectObjects (src/cmd/compile/internal/types2/resolver.go:202). This phase scans through the AST and creates objects for every declaration—constants, variables, types, and functions—without type-checking them yet.

Think of this as taking inventory. The type checker walks through the entire AST and says: “I see an import here, a function there, a type over there.” It creates placeholder objects for everything and stores them in a map.

Here’s what happens with our hello world:

Import declaration:

case *syntax.ImportDecl:
    // Validate import path
    path, err := validatedImportPath(s.Path.Value)

    // Import the package
    imp := check.importPackage(s.Path.Pos(), path, fileDir)

The type checker sees import "fmt", validates the path, and uses the Importer (configured during setup) to load the fmt package. This gives the type checker access to fmt’s exported names—including Println. (We’ll explore how the compiler reads and processes these exported definitions in the Unified IR post.)

Function declaration:

case *syntax.FuncDecl:
    // Create Func object
    f := NewFunc(d.Pos(), pkg, name.Value, nil)
    check.objMap[f] = &declInfo{
        file:  fileScope,
        fdecl: d,
    }

The type checker sees func main(), creates a Func object for it, and stores it in objMap. Notice that it doesn’t check the function body yet—it just records “there’s a function called main” and moves on.

At the end of this phase, the type checker has a complete inventory of declarations but hasn’t validated any of their details. Now that we know what exists, we can start checking whether it’s actually valid.

Phase 3: Type-Checking Declarations

This is where things get interesting. packageObjects (src/cmd/compile/internal/types2/resolver.go:628) is where the real type checking begins—but only for package-level declarations. Function bodies are still skipped.

The type checker processes declarations in three sub-phases:

  1. Non-alias type declarations
  2. Alias type declarations
  3. Everything else (constants, variables, functions)

This ordering avoids issues where aliases need their underlying types to be determined first.

Now here’s a tricky problem: what if you have declarations that depend on each other? For example:

type A B
type B A

If the type checker tries to determine the type of A, it needs to look at B. But to determine B, it needs to look at A. We’re stuck in a loop! This is called a dependency cycle, and the type checker needs to detect these to avoid infinite loops.

The solution? For each object, the type checker calls objDecl (src/cmd/compile/internal/types2/decl.go:50), which implements a clever technique called the three-color algorithm:

switch obj.color() {
case white:
    obj.setColor(grey + color(check.push(obj)))
    defer func() {
        check.pop().setColor(black)
    }()
case black:
    return  // Already processed
case grey:
    // Cycle detected!
    if !check.validCycle(obj) {
        obj.typ = Typ[Invalid]
    }
}

Here’s how it works: objects start white (unprocessed). When the type checker begins processing an object, it marks it grey (in progress). When processing completes, it becomes black (complete). If the type checker encounters a grey object while processing—meaning we’ve circled back to something we’re already in the middle of checking—that’s a cycle.

Let’s trace through our type A B / type B A example:

  1. Type checker starts with A (white) → marks it grey, begins checking
  2. To check A, it needs to check B
  3. Type checker starts with B (white) → marks it grey, begins checking
  4. To check B, it needs to check A
  5. Type checker encounters A again—but it’s already grey!
  6. Cycle detected! The type checker reports an error.

If the types were valid, like type List struct { next *List }, the recursion is fine because there’s a pointer indirection. The type checker allows these valid cycles and only rejects the invalid ones.

The three-color algorithm runs for every declaration the type checker processes. Back in our hello world program, when the type checker gets to the main function declaration, it goes through the same color-marking process—though fortunately, our simple program has no cycles to worry about.

Here’s what happens when the type checker processes main:

func (check *Checker) funcDecl(obj *Func, decl *declInfo) {
    // Type-check the function signature
    sig := check.funcType(fdecl.Type, fdecl.TParamList)
    obj.typ = sig

    // Defer body checking
    check.later(func() {
        check.funcBody(decl, name, sig, fdecl.Body, iota)
    })
}

The type checker validates main’s signature: no parameters, no return values. It stores the function’s type—a Signature—but defers checking the body until later. This is the check.later() call adding a delayed action.

Why delay? Because function bodies can reference any package-level declaration, even those declared later in the file. By delaying, the type checker ensures all declarations are known before checking function bodies. This brings us to the next phase.

Phase 4: Checking Function Bodies

Remember all those delayed actions we queued up? Now’s when they run. processDelayed (src/cmd/compile/internal/types2/check.go:540) processes all those delayed actions—primarily function body checking.

The type checker sets up the function’s environment (scope, signature, etc.) and then checks all the statements in the body:

func (check *Checker) funcBody(decl *declInfo, name string, sig *Signature, body *syntax.BlockStmt, iota constant.Value) {
    // Set up function environment
    check.environment = environment{
        decl:  decl,
        scope: sig.scope,
        sig:   sig,
    }

    // Check all statements in the body
    check.stmtList(0, body.List)

    // Validate labels and returns
    // Check unused variables
}

For our main function, there’s just one statement: fmt.Println("Hello world"). Let’s trace through what happens with this line.

Resolving the selector

The first thing to figure out is what fmt.Println actually refers to. The type checker needs to resolve both parts of this selector expression.

It starts by looking up fmt in the current scope. It searches through the function’s local scope, then the file scope, and finds it—it’s the package we imported at the top of the file. Great!

Now it needs to look up Println within that package. The type checker searches the fmt package’s exported symbols and finds Println—it’s an exported function. The type checker records this resolution for later use.

Checking the call

Now that we know what we’re calling, the type checker needs to validate the call itself:

func (check *Checker) call(x *operand, call *syntax.CallExpr) exprKind {
    // x is the function being called (fmt.Println)
    // call.ArgList contains the arguments

    // Get function signature
    sig := x.typ.(*Signature)

    // Check each argument against parameters
    check.arguments(call, sig, call.ArgList)
}

The type checker grabs Println’s signature from the fmt package: func(a ...any) (n int, err error). This signature tells us that Println accepts a variadic parameter of type any and returns an int and an error.

Now comes the argument checking. Our call passes one argument: the string literal "Hello world". The type checker needs to verify that a string is compatible with the parameter type ...any. Since any accepts literally any type, a string is definitely valid. Check passes!

If we had passed something incompatible—say, tried to call a function expecting an int with a string—this is where the type checker would catch it and report an error.

Recording type information

Finally, the type checker records that this call expression returns (int, error):

check.recordTypeAndValue(call, value, sig.Results(), nil)

Even though we’re ignoring those return values in our code, the type information is tracked for later use.

The type checker has now fully validated our hello world program! All names are resolved, all types check out, and there are no errors. The remaining phases (cleanup, initOrder, unusedImports, etc.) handle final bookkeeping tasks, but the heavy lifting is done.

Our hello world example shows the basic mechanics, but the type checker is capable of handling much more interesting scenarios.

What About More Complex Cases?

The type checker deals with some pretty sophisticated situations. Let’s peek at a few examples to see how deep this system goes.

Untyped Constants

Consider:

const x = 42
var y int = x
var z float64 = x

The constant 42 is untyped—it has a kind (integer) but not a concrete type yet. The type checker tracks untyped expressions and propagates types based on context:

func (check *Checker) updateExprType(x syntax.Expr, typ Type, final bool) {
    // If x is untyped, update its type based on context
    // Recursively update nested untyped expressions
}

When assigning x to y, the type checker converts 42 to int. When assigning to z, it converts to float64. This is why Go’s untyped constants “just work.” The type checker’s ability to track and propagate untyped expressions makes constant usage feel natural.

Generic Functions

Generics add another layer of complexity. For generic code like:

func Min[T constraints.Ordered](a, b T) T {
    if a < b {
        return a
    }
    return b
}

result := Min(3, 5)  // Type inference

The type checker:

  1. Parses the type parameters (T constraints.Ordered)
  2. When it sees Min(3, 5), it infers T = int from the arguments
  3. Instantiates Min[int] by substituting int for T
  4. Checks that int satisfies the Ordered constraint
  5. Validates the function body with T replaced by int

This happens in src/cmd/compile/internal/types2/infer.go using a unification algorithm. It’s fascinating to watch the type checker deduce types automatically—it feels almost magical, but it’s just careful bookkeeping and constraint solving. I’d love to explore how generics work under the hood in more detail in a future post—there’s a lot more to uncover about type inference, constraint checking, and code generation.

Initialization Order

Another tricky problem the type checker solves: figuring out the order to initialize package-level variables:

var a = b + 1
var b = 42

The type checker computes the initialization order: b must be initialized before a. It does this by building a dependency graph and performing a topological sort (src/cmd/compile/internal/types2/initorder.go):

func (check *Checker) initOrder() {
    // Build dependency graph
    pq := dependencyGraph(check.objMap)

    // Topological sort
    // Detect cycles
    // Emit initialization order
}

If there’s a cycle (var a = b; var b = a), the type checker reports an error.

These complex scenarios show just how sophisticated the type checker is. It’s not just checking “does this int match that int”—it’s solving constraint systems, inferring types, and computing dependency graphs.

Try It Yourself

Want to experiment with type checking yourself? The Go standard library includes a go/types package (similar to types2 but using go/ast instead of syntax). Here’s how you can type-check your own Go code:

package main

import (
    "fmt"
    "go/importer"
    "go/parser"
    "go/token"
    "go/types"
)

func main() {
    src := `package main

import "fmt"

func main() {
    fmt.Println("Hello world")
}`

    fset := token.NewFileSet()
    f, err := parser.ParseFile(fset, "hello.go", src, 0)
    if err != nil {
        panic(err)
    }

    conf := types.Config{Importer: importer.Default()}
    info := &types.Info{
        Defs:  make(map[*ast.Ident]types.Object),
        Uses:  make(map[*ast.Ident]types.Object),
        Types: make(map[ast.Expr]types.TypeAndValue),
    }

    pkg, err := conf.Check("main", fset, []*ast.File{f}, info)
    if err != nil {
        panic(err)
    }

    fmt.Printf("Package: %s\n", pkg.Path())
    fmt.Printf("Definitions: %d\n", len(info.Defs))
    fmt.Printf("Uses: %d\n", len(info.Uses))
}

This shows you exactly what the type checker discovers: which identifiers are definitions, which are uses, and what types are assigned to expressions. Try modifying the source code to introduce errors and see what the type checker catches!

Summary

The type checker is a crucial step between parsing and IR generation in the Go compiler. It takes the structured AST from the parser and validates that your code follows Go’s rules—resolving names, checking types, and ensuring everything makes sense.

We traced through our hello world program and saw how the type checker works in multiple phases: it first collects all declarations, then type-checks them (using clever cycle detection to handle recursive types), and finally checks function bodies once all declarations are known. We also explored how it handles more complex scenarios like untyped constants, generics, and initialization order.

The type checker is thorough and strict, but that’s what makes Go reliable. If your code passes type checking, you can be confident the types are correct—no surprises at runtime.

If you want to go deeper, I recommend exploring src/cmd/compile/internal/types2/. The code is well-structured, and now that you understand the phases and patterns, it should be much more approachable.

In upcoming posts, I’ll cover the Unified IR and then the full Intermediate Representation—the next steps in transforming your code toward executable form.