How to Use the go/types Package for Type Checking

The `go/types` package performs static type checking on Go source code by analyzing the Abstract Syntax Tree (AST) to verify type correctness without compiling the program. It is primarily used by tools like IDEs, linters, and refactoring utilities to detect type errors at development time.

The compiler's secret weapon

You write a Go program, run go build, and the compiler spits out cannot use string as int in argument. You fix it. Now imagine you are building a linter that catches that exact mistake before the user ever runs the build command. Or an IDE that highlights the error the moment you type the semicolon. You cannot just run go build in the background for every keystroke. You need the compiler's type-checking engine, stripped of the code generation step, running directly in your Go program. That engine lives in the standard library under go/types.

What static type checking actually means

Static type checking is the process of verifying that every value in your program matches the expected shape before the program ever runs. Go does this automatically. The go/types package exposes that same verification step as a library. Think of it like a structural inspector for a building blueprint. The inspector does not pour concrete or wire the electricity. They just walk through the drawings, measure the beams, check the load-bearing walls, and flag anything that violates the building code. go/types walks through your parsed source code, measures the types, checks the assignments, and flags mismatches.

The package works alongside go/parser and go/ast. The parser turns raw text into an Abstract Syntax Tree. The AST is just a map of the code structure: where functions start, where variables are declared, where expressions sit. The AST knows about syntax. It does not know about types. go/types takes that syntax tree and fills in the missing information. It resolves package imports, matches function signatures, verifies interface satisfaction, and attaches a types.Type object to every node in the tree.

A minimal type checker

Here is the smallest program that runs the type checker on a string of Go source code.

package main

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

// checkSource runs the Go type checker against a raw source string.
func checkSource(src string) error {
	// FileSet tracks line numbers and column offsets for accurate error reporting
	fset := token.NewFileSet()

	// Parse the raw string into an AST. The empty filename tells the parser to read from the string.
	astFile, err := parser.ParseFile(fset, "", src, 0)
	if err != nil {
		return err
	}

	// Config holds the rules for type checking. Importer resolves external packages.
	config := &types.Config{Importer: importer.Default()}

	// Check runs the type checker. It returns the package info and any type errors.
	_, err = config.Check("main", fset, []*ast.File{astFile}, nil)
	return err
}

func main() {
	// Deliberately wrong code to trigger a type error
	badCode := `package main
func main() {
	var x int = "hello"
}`

	if err := checkSource(badCode); err != nil {
		fmt.Println("Type error:", err)
	}
}

The program starts by creating a token.FileSet. This object is the coordinate system for your source code. Every position in the AST maps back to a line and column in the FileSet. Without it, error messages would just say line 0, column 0.

Next, parser.ParseFile converts the string into an *ast.File. The parser only cares about syntax. It will happily parse var x int = "hello" because the grammar is valid. It does not know that strings cannot become integers.

The types.Config struct holds the environment for the checker. The Importer field is crucial. Go code rarely lives in a vacuum. If your source imports fmt or net/http, the type checker needs to know what those packages export. importer.Default() handles standard library imports by reading the precompiled archive files or using the module cache.

Finally, config.Check does the heavy lifting. You pass it a package name, the file set, a slice of AST files, and an *types.Info struct. We passed nil here, which means we do not care about the detailed results. The checker walks the tree, resolves names, matches types, and returns an error if anything breaks the rules. Run that example and you get a clean error message pointing to the exact line where the string assignment fails.

Walking the tree with type data

Running the checker and catching an error is useful. Building a tool that extracts type information is where go/types shines. Imagine you are writing a code generator that needs to find every function that returns a specific interface. You need to populate the types.Info struct so you can query the results.

package main

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

// FindStringReturns scans a file and prints every function that returns a string.
func FindStringReturns(src string) {
	fset := token.NewFileSet()
	astFile, err := parser.ParseFile(fset, "", src, 0)
	if err != nil {
		fmt.Println("Parse failed:", err)
		return
	}

	// Info collects detailed type data for every node in the AST
	info := &types.Info{
		Types:      make(map[ast.Expr]types.TypeAndValue),
		Defs:       make(map[*ast.Ident]types.Object),
		Uses:       make(map[*ast.Ident]types.Object),
		Selections: make(map[*ast.SelectorExpr]*types.Selection),
	}

	config := &types.Config{
		Importer: importer.Default(),
		Error: func(err types.Error) {
			// Silently ignore type errors for this demo
		},
	}

	_, err = config.Check("main", fset, []*ast.File{astFile}, info)
	if err != nil {
		fmt.Println("Type check failed:", err)
		return
	}

	// Walk the AST to find function declarations
	ast.Inspect(astFile, func(n ast.Node) bool {
		funcDecl, ok := n.(*ast.FuncDecl)
		if !ok {
			return true
		}

		// Check if the function has a return type
		if funcDecl.Type.Results == nil {
			return true
		}

		// Iterate over the return types
		for _, field := range funcDecl.Type.Results.List {
			// Look up the type in the info map
			if tv, exists := info.Types[field.Type]; exists {
				// Compare the underlying type to string
				if types.Universe.Lookup("string").Type().String() == tv.Type.String() {
					fmt.Printf("Function %s returns a string\n", funcDecl.Name.Name)
				}
			}
		}
		return true
	})
}

The types.Info struct is the bridge between syntax and semantics. When you pass it to Check, the type checker fills it with maps. Types maps every expression node to its resolved type. Defs maps identifier nodes to their declarations. Uses maps identifier nodes to the objects they refer to. This structure lets you ask questions like what type is this variable or which package does this function come from without writing your own resolver.

The ast.Inspect function walks the tree depth-first. We filter for *ast.FuncDecl nodes to find function definitions. Once we have a function, we look at its Results field. Each result has a type expression. We cross-reference that expression with the info.Types map to get the actual resolved type. The comparison uses types.Universe.Lookup("string") to get the canonical string type object. This pattern is the backbone of tools like stringer, mockgen, and custom linters.

A quick note on convention: Go tooling follows a strict rule. Accept interfaces, return structs. When building type-checking utilities, your public API should accept types.Object or types.Type interfaces rather than concrete structs. The go/types package itself follows this rule. It exposes types.Type as an interface with methods like Underlying() and String(). This design lets the standard library swap out internal implementations without breaking your code. Stick to the interface when querying types, and let the package handle the concrete details.

Where things go wrong

Working with go/types feels straightforward until you hit the edge cases. The package is strict. It expects valid Go code. If you pass malformed syntax, the parser fails first. If the syntax is valid but the types clash, Check returns an error. The error format is plain text, usually starting with the file position followed by the violation. You might see something like cannot use "hello" (untyped string constant) as int value in assignment.

A common trap is forgetting to configure the importer correctly. If your code imports a third-party package and you leave Importer as nil, the checker cannot resolve the package path. It will reject the code with an import "example.com/pkg": cannot find package error. Always wire up importer.Default() or a custom types.Importer if you are working with local modules.

Another pitfall is assuming types.Info is populated for every node. The map only contains entries for nodes that actually have type information. Literals, identifiers, and binary expressions get entries. Control flow statements like if or for do not. Trying to look up a non-expression node in info.Types returns a zero value, which can cause subtle bugs if you do not check the boolean return value of the map lookup.

The type checker also runs in a single pass. It does not iterate until convergence. If you have circular dependencies or forward references that violate Go's declaration rules, the checker will flag them immediately. Go does not allow forward references to functions in the same package without proper declaration ordering, and go/types enforces that rule exactly like go build does.

Packages under the go/ prefix are considered compiler-internal tooling. They are stable, but they are not meant for application logic. Use them for build tools, linters, and code generators. Do not ship go/types as a dependency in a web server or CLI application. Keep your tooling separate from your runtime code.

Type checking is a solved problem in Go. The standard library hands you the solution. Wire up the parser, feed the AST to the checker, and read the results.

When to reach for go/types

Use go/types when you need to verify type correctness without invoking the full compiler. Use go/ast alone when you only care about syntax patterns like finding comments or matching function names. Use go/printer when you need to format or rewrite source code back to text. Use gopls or external tooling when you are building a full IDE and do not want to reinvent the type-checking infrastructure. Use go/parser with parser.ParseComments when your tool needs to preserve documentation alongside the code structure. Use plain string matching when you are writing a quick script and do not need semantic accuracy.

Trust the standard library. The type checker does the heavy lifting.

Where to go next