Chapter 44

Code Generation and Static Analysis

Code Generation and Static Analysis

Every mature programming language eventually confronts the same problem: some code should be written by machines, not people. Java has annotation processors, C++ has macros, Rust has procedural macros. Go's answer is go generate combined with an open AST toolchain — a design that is deliberately simple yet profoundly powerful.

This chapter is not about "how to run a command with go generate." We are going deep into the foundations of Go source code analysis: what an AST looks like, where type information comes from, how to build a code generator that is genuinely useful, and how to write a custom linter that benefits your entire team. By the end, you will understand how tools like gopls, staticcheck, and golangci-lint work internally — and how to contribute new rules to them.

Level 1 · Why Code Generation

Repetitive Code Is a Form of Information Entropy

Consider a common scenario: you have a Status type aliased from int with a dozen named constants. Every time you debug or log, you want to see "StatusActive" rather than "2". So you hand-write a String() method, hardcoding each constant:

func (s Status) String() string {
    switch s {
    case StatusDraft:
        return "StatusDraft"
    case StatusActive:
        return "StatusActive"
    // ... a dozen more cases
    }
    return "unknown"
}

This code has three problems. First, it is pure redundancy — the name StatusActive already appears in the constant declaration; repeating it in the string means the two must always agree, but the compiler will never verify this for you. Second, it is error-prone — when you add a new constant StatusArchived but forget to update String(), the code still compiles, but at runtime you silently get "unknown". This is one of the hardest bug categories to track down: silent, correct-looking code that produces wrong output. Third, it is tedious — mechanical repetition consumes engineering attention that should be directed at design decisions that actually matter.

The core value of code generation is eliminating redundant representations of information. When your type definition is the single source of truth and all derivative code is automatically inferred from it by a machine, you eliminate an entire category of bugs and reduce maintenance overhead.

Go's Tooling Philosophy: Tools Are Go Programs

Go's tooling philosophy is distinctive. In the Go world, code generation tools are themselves Go programs, using the standard library's go/ast, go/parser, and go/types packages to parse and analyze Go source code. This has important consequences:

The barrier to entry is low. You do not need to learn a dedicated template language or macro system. You already know Go, so you can write code generation tools.

Tools are type-safe. Because a tool receives a fully type-checked AST at runtime, the generated code is nearly guaranteed to be type-correct — as long as your generation logic is correct.

Tools are composable. The go/analysis framework allows multiple analyzers to share parsed results, avoiding redundant work. This is why golangci-lint can run dozens of linters in a single parse pass.

go generate itself does nothing magical. It scans .go source files for special comments of the form //go:generate command args and executes the corresponding commands. The real work is in the command, which is typically a Go program. This design is extremely simple, yet gives engineers complete freedom.

Static Analysis: Catching Bugs Before Compilation

Static analysis is the practice of reasoning about code properties without executing it. Its value lies in minimizing the cost of feedback: the later a bug is discovered, the more expensive it is to fix. Catching it at design time costs a few minutes of thought; catching it in code review costs a PR round-trip; catching it in production can cost hours of on-call effort and user data integrity.

The go vet command bundled with Go's toolchain is the most basic static analysis tool. It detects many problems using the go/analysis framework: printf format string/argument mismatches, value copies of sync.Mutex, misuse of nil channels, and more. But go vet only includes rules that the Go team believes are universally applicable. Every team has its own coding conventions and business-specific constraints — which is why custom linters are valuable.

Level 2 · The Go AST Toolchain

go/token: Position Information

In the Go toolchain, go/token provides the most fundamental concepts: the file set (token.FileSet) and positions (token.Pos).

A token.FileSet is an index of a group of files. When parsing multiple .go files, all files share a single FileSet, and each byte position is encoded as a globally unique token.Pos integer. This design allows AST nodes to represent their source location with a single integer rather than carrying redundant file name and line number fields — when needed, decode via FileSet.Position(pos):

fset := token.NewFileSet()
// Pass fset during parsing; query any node's position afterwards
pos := someNode.Pos()
position := fset.Position(pos)
fmt.Printf("%s:%d:%d\n", position.Filename, position.Line, position.Column)

token.Token enumerates all of Go's lexical units: IDENT (identifiers), INT (integer literals), FUNC, IF, RETURN, etc. Lexing converts source text into a token stream; parsing converts the token stream into an AST.

go/ast: The Syntax Tree

The go/ast package defines Go's complete Abstract Syntax Tree. The root node is *ast.File, representing one .go source file.

Key interfaces and types in the Go AST:

The canonical way to traverse an AST is ast.Inspect, which visits nodes in depth-first order:

ast.Inspect(file, func(n ast.Node) bool {
    if n == nil {
        return false // called on return from subtree; n is nil
    }
    switch node := n.(type) {
    case *ast.FuncDecl:
        fmt.Printf("function: %s\n", node.Name.Name)
    case *ast.CallExpr:
        if sel, ok := node.Fun.(*ast.SelectorExpr); ok {
            fmt.Printf("method call: %s\n", sel.Sel.Name)
        }
    }
    return true // returning true continues traversal into children
})

For more complex traversal logic, implement the ast.Visitor interface:

type MyVisitor struct{}

func (v MyVisitor) Visit(node ast.Node) ast.Visitor {
    if node == nil {
        return nil
    }
    // process node...
    return v // return nil to skip children
}

ast.Walk(MyVisitor{}, file)

go/types: Type Information

Analysis based on the AST alone is limited, because the AST represents syntactic structure only, without type information. When you see an x.Foo() call, the AST tells you it is a SelectorExpr, but it does not tell you what type x is or whether Foo is a real method.

The go/types package performs type checking, annotating the AST with complete type information:

conf := types.Config{Importer: importer.Default()}
info := &types.Info{
    Types: make(map[ast.Expr]types.TypeAndValue),
    Defs:  make(map[*ast.Ident]types.Object),
    Uses:  make(map[*ast.Ident]types.Object),
}
pkg, err := conf.Check("mypkg", fset, []*ast.File{file}, info)

types.Info is the key data structure:

With type information, you can precisely answer: "What are all the concrete implementors of this io.Writer interface?", "What is the return type of this function?", "Does this variable implement a given interface?"

go/packages: Loading Whole Packages

In real projects, you need to analyze not a single file but entire packages, including cross-package dependencies. go/packages provides this capability. It internally calls go list to obtain package metadata, then parses and type-checks all relevant files:

cfg := &packages.Config{
    Mode: packages.NeedName | packages.NeedFiles |
          packages.NeedSyntax | packages.NeedTypes |
          packages.NeedTypesInfo,
    Context: ctx,
}
pkgs, err := packages.Load(cfg, "github.com/yourorg/yourpkg/...")

With packages.NeedTypesInfo, every loaded package carries complete type information and is ready for analysis.

go/format: Writing Formatted Output

The final step of code generation is outputting code. Code assembled by string concatenation may not conform to gofmt standards, which creates friction in code review. go/format provides two formatting approaches:

// Approach 1: format source bytes
formatted, err := format.Source([]byte(generatedCode))

// Approach 2: format an AST node
var buf bytes.Buffer
err := format.Node(&buf, fset, astFile)

Production-grade code generators should always use format.Source to guarantee consistent output formatting.

Level 3 · Code in Practice

Practice 1: A Struct-to-Mock Generator

Suppose your team's convention requires all database operations to go through interfaces, enabling mocks in tests. Let us build a tool that reads an interface definition and outputs a mock implementation.

Given:

type UserRepository interface {
    FindByID(ctx context.Context, id int64) (*User, error)
    Create(ctx context.Context, u *User) error
    Delete(ctx context.Context, id int64) error
}

Generate:

type MockUserRepository struct {
    FindByIDFunc func(ctx context.Context, id int64) (*User, error)
    CreateFunc   func(ctx context.Context, u *User) error
    DeleteFunc   func(ctx context.Context, id int64) error
}

func (m *MockUserRepository) FindByID(ctx context.Context, id int64) (*User, error) {
    return m.FindByIDFunc(ctx, id)
}
// ...

Core generation logic:

package main

import (
    "bytes"
    "go/ast"
    "go/format"
    "go/parser"
    "go/token"
    "text/template"
)

const mockTemplate = `
// Code generated by mockgen. DO NOT EDIT.
package {{.Package}}

type Mock{{.Name}} struct {
{{- range .Methods}}
    {{.Name}}Func {{.FuncType}}
{{- end}}
}
{{range .Methods}}
func (m *Mock{{$.Name}}) {{.Name}}({{.Params}}) {{.Results}} {
    {{if .HasResults}}return {{end}}m.{{.Name}}Func({{.ArgNames}})
}
{{end}}
`

type MethodInfo struct {
    Name       string
    FuncType   string
    Params     string
    Results    string
    ArgNames   string
    HasResults bool
}

type MockData struct {
    Package string
    Name    string
    Methods []MethodInfo
}

func generateMock(srcFile, ifaceName string) ([]byte, error) {
    fset := token.NewFileSet()
    f, err := parser.ParseFile(fset, srcFile, nil, 0)
    if err != nil {
        return nil, err
    }

    var methods []MethodInfo
    ast.Inspect(f, func(n ast.Node) bool {
        typeSpec, ok := n.(*ast.TypeSpec)
        if !ok || typeSpec.Name.Name != ifaceName {
            return true
        }
        iface, ok := typeSpec.Type.(*ast.InterfaceType)
        if !ok {
            return false
        }
        for _, method := range iface.Methods.List {
            funcType, ok := method.Type.(*ast.FuncType)
            if !ok {
                continue
            }
            mi := MethodInfo{
                Name:     method.Names[0].Name,
                FuncType: formatFuncType(fset, funcType),
                Params:   formatFieldList(fset, funcType.Params),
                ArgNames: extractArgNames(funcType.Params),
            }
            if funcType.Results != nil && len(funcType.Results.List) > 0 {
                mi.Results = "(" + formatFieldList(fset, funcType.Results) + ")"
                mi.HasResults = true
            }
            methods = append(methods, mi)
        }
        return false
    })

    data := MockData{
        Package: f.Name.Name,
        Name:    ifaceName,
        Methods: methods,
    }

    var buf bytes.Buffer
    tmpl := template.Must(template.New("mock").Parse(mockTemplate))
    if err := tmpl.Execute(&buf, data); err != nil {
        return nil, err
    }
    return format.Source(buf.Bytes())
}

The key helper functions formatFuncType and extractArgNames reconstruct function type strings from AST nodes and extract parameter name lists for use in forwarding calls. Both must handle the distinction between named and anonymous parameters in *ast.FieldList.

Practice 2: A Custom Linter with the analysis Framework

The go/analysis framework, introduced in Go 1.12, is the infrastructure that powers go vet. Analyzers written with it can be loaded and run by go vet or golangci-lint.

Suppose your team's rule: never call log.Fatal inside an http.Handler — it kills the entire process and is an abuse of the logging API in a handler context.

package nofatal

import (
    "go/ast"
    "golang.org/x/tools/go/analysis"
    "golang.org/x/tools/go/analysis/passes/inspect"
    "golang.org/x/tools/go/ast/inspector"
)

var Analyzer = &analysis.Analyzer{
    Name:     "nofatal",
    Doc:      "detects calls to log.Fatal inside http.Handler implementations",
    Run:      run,
    Requires: []*analysis.Analyzer{inspect.Analyzer},
}

func run(pass *analysis.Pass) (interface{}, error) {
    insp := pass.ResultOf[inspect.Analyzer].(*inspector.Inspector)

    nodeFilter := []ast.Node{
        (*ast.CallExpr)(nil),
    }

    insp.Preorder(nodeFilter, func(n ast.Node) {
        call := n.(*ast.CallExpr)
        sel, ok := call.Fun.(*ast.SelectorExpr)
        if !ok {
            return
        }
        pkg, ok := sel.X.(*ast.Ident)
        if !ok || pkg.Name != "log" {
            return
        }
        name := sel.Sel.Name
        if name != "Fatal" && name != "Fatalf" && name != "Fatalln" {
            return
        }
        if isInsideHTTPHandler(pass, call) {
            pass.Reportf(call.Pos(),
                "avoid calling %s.%s inside an http.Handler: it terminates the process; use http.Error or return an error instead",
                pkg.Name, name)
        }
    })
    return nil, nil
}

func isInsideHTTPHandler(pass *analysis.Pass, call ast.Node) bool {
    for _, file := range pass.Files {
        for _, decl := range file.Decls {
            funcDecl, ok := decl.(*ast.FuncDecl)
            if !ok || funcDecl.Body == nil {
                continue
            }
            if funcDecl.Name.Name != "ServeHTTP" {
                continue
            }
            if funcDecl.Body.Pos() <= call.Pos() && call.End() <= funcDecl.Body.End() {
                return true
            }
        }
    }
    return false
}

Notice Requires: []*analysis.Analyzer{inspect.Analyzer}. The inspect.Analyzer is a shared preprocessing analyzer that builds an *inspector.Inspector object reused by all dependent analyzers — avoiding redundant AST traversal. This is the key mechanism by which the go/analysis framework efficiently runs dozens of linters.

Practice 3: The stringer Tool Walkthrough

golang.org/x/tools/cmd/stringer is the most canonical official code generation tool. It generates String() methods for named integer constant blocks.

Usage: add a directive comment to the file declaring your constants:

//go:generate stringer -type=Status -output=status_string.go

type Status int

const (
    StatusDraft   Status = iota
    StatusActive
    StatusArchived
    StatusDeleted
)

Running go generate ./... produces status_string.go:

// Code generated by stringer -type=Status; DO NOT EDIT.

package mypackage

import "strconv"

func _() {
    // An "invalid array index" compiler error signifies that the constant values
    // have changed. Run the stringer command again.
    var x [1]struct{}
    _ = x[StatusDraft-0]
    _ = x[StatusActive-1]
    _ = x[StatusArchived-2]
    _ = x[StatusDeleted-3]
}

const _Status_name = "StatusDraftStatusActiveStatusArchivedStatusDeleted"

var _Status_index = [...]uint8{0, 11, 23, 37, 50}

func (i Status) String() string {
    if i < 0 || i >= Status(len(_Status_index)-1) {
        return "Status(" + strconv.FormatInt(int64(i), 10) + ")"
    }
    return _Status_name[_Status_index[i]:_Status_index[i+1]]
}

The blank function _() is the elegant part: it uses array index expressions as compile-time assertions. If a constant's value changes, this file produces a compiler error, forcing you to re-run stringer. This is a technique for using the compiler to enforce consistency between source and generated code.

go generate Workflow Integration

Best practice: place //go:generate directives as close as possible to the code they operate on:

// At the top of status.go
//go:generate stringer -type=Status
//go:generate mockgen -source=user_repository.go -destination=mock/user_repository.go

package mypackage

In CI/CD, the recommended approach is:

  1. Commit generated code to version control (so builds work without installing generation tools)
  2. Run go generate ./... in CI and check for diffs; fail if any exist (indicating someone forgot to regenerate)
# CI script
go generate ./...
if ! git diff --exit-code; then
    echo "Generated code is out of sync with source. Run go generate ./..." >&2
    exit 1
fi

Code Generation with text/template

For complex code generation scenarios, directly constructing AST nodes is extremely tedious. A better approach is to define templates with text/template and fill them with data extracted from the AST.

const repoTemplate = `
// Code generated by repogen. DO NOT EDIT.
package {{.Package}}

import (
    "context"
    "database/sql"
)

type {{.Name}}Repo struct {
    db *sql.DB
}

func New{{.Name}}Repo(db *sql.DB) *{{.Name}}Repo {
    return &{{.Name}}Repo{db: db}
}

func (r *{{.Name}}Repo) FindByID(ctx context.Context, id int64) (*{{.Name}}, error) {
    var result {{.Name}}
    err := r.db.QueryRowContext(ctx,
        "SELECT {{.Columns}} FROM {{.Table}} WHERE id = $1", id,
    ).Scan({{.ScanArgs}})
    if err != nil {
        return nil, err
    }
    return &result, nil
}
`

Template variables (Package, Name, Columns, etc.) come from a data structure built by analyzing struct definitions and their field tags. Combined with format.Source, this pipeline is both flexible and guaranteed to produce well-formatted output.

Level 4 · Advanced Topics and Edge Cases

gopls and the Language Server Protocol

gopls is Go's official Language Server Protocol (LSP) implementation, providing code completion, jump-to-definition, refactoring, and more to editors like VS Code, Vim, and Emacs. Understanding gopls's internal architecture helps you contribute features or integrate custom analyzers.

Internally, gopls maintains an incremental build cache. When you modify a file, it does not re-parse the entire project — it only re-analyzes packages affected by the change. This depends on go/packages's caching mechanism and the Go package dependency graph.

Starting with Go 1.24, gopls can load external analyzers implemented with the go/analysis framework. Configure analyzer plugins in your gopls settings:

{
  "gopls": {
    "analyses": {
      "nofatal": true
    }
  }
}

Deep Dive into staticcheck Rules

staticcheck is the most popular Go static analysis tool in the community. Its rules are categorized by prefix:

One of staticcheck's most notable rules is SA1006, detecting incorrect use of sync.WaitGroup.Add:

// Wrong: Add called inside the goroutine — may execute after Wait returns
go func() {
    wg.Add(1) // SA1006: sync.WaitGroup.Add called from inside a goroutine
    defer wg.Done()
    doWork()
}()

// Correct: Add called before launching the goroutine
wg.Add(1)
go func() {
    defer wg.Done()
    doWork()
}()

staticcheck can detect this because it implements data flow analysis on top of go/analysis: it tracks the flow of sync.WaitGroup values and knows the timing of Add calls relative to goroutine launches.

Registering Custom Linters in golangci-lint

golangci-lint supports loading custom linters via a plugin mechanism. However, due to Go plugin limitations (requiring identical build parameters), a common production approach is to fork golangci-lint and add your linters directly.

A more modern approach uses golangci-lint's custom mode, configured in .golangci.yml:

linters-settings:
  custom:
    nofatal:
      path: ./tools/linters/nofatal.so
      description: Detects log.Fatal in http.Handler
      original-url: github.com/yourorg/linters/nofatal

Compile the linter as a plugin:

go build -buildmode=plugin -o ./tools/linters/nofatal.so ./tools/linters/nofatal/

AST Transformation for Automated Refactoring

AST transformation is the inverse of code generation: read code, modify the AST, output the modified code. This is the foundation of automated refactoring tools like gofmt -r and the eg (example-based refactoring) tool.

gofmt -r 'pattern -> replacement' supports simple pattern replacement:

# Replace all bytes.Compare(a, b) == 0 with bytes.Equal(a, b)
gofmt -r 'bytes.Compare(a, b) == 0 -> bytes.Equal(a, b)' -w ./...

For more complex refactoring, use golang.org/x/tools/go/ast/astutil's Apply function, which supports modifying the AST during traversal:

result := astutil.Apply(file, func(cursor *astutil.Cursor) bool {
    call, ok := cursor.Node().(*ast.CallExpr)
    if !ok {
        return true
    }
    if shouldReplace(call) {
        cursor.Replace(buildReplacement(call))
    }
    return true
}, nil)

The cursor.Replace call is what makes this different from ast.Inspectastutil.Apply maintains the parent context, so replacements propagate correctly into the final output without manual parent-node bookkeeping.

Build Tags and Conditional Code Generation

Sometimes code generation itself needs to execute conditionally based on the build environment. Go's build tags provide this capability:

//go:build ignore

// This file is excluded from normal builds.
// go generate uses it to produce generated files.
// Usage: go run generate.go

package main

func main() {
    // generation logic
}

//go:build ignore causes this file to be excluded from a regular go build. Only explicit go run generate.go execution can trigger it. This is a lighter-weight organizational strategy than placing generation tools in a separate repository — the tool lives alongside the code it generates, making it easier to maintain.

Another common pattern generates different code per platform:

//go:generate go run -tags generate gen_linux.go
//go:generate go run -tags generate gen_darwin.go

Generated files can be scoped to a platform using file name suffixes (_linux.go, _darwin.go) or build tag headers, letting the compiler automatically select the correct version.

Code generation and static analysis are the foundation of Go's tooling ecosystem. Mastering these tools does more than improve individual productivity — it elevates the quality of your entire team's codebase by delegating to machines the checks and validations that machines are better suited to perform.

Rate this chapter
4.8  / 5  (3 ratings)

💬 Comments