Skip to content

Generics: what go vet and gofmt catch

Level: 301 · anyone who expects a linter to review their constraints

One line: No go vet analyzer is about type parameters, but four of them see through one — copylocks, printf, composites and stdversion — and gofmt -s trims repeated instantiations from literals; the mistakes that are specific to generics — a union without ~, comparable on an interface key, a Max that meets NaN — compile, vet clean, and are wrong.

Each entry runs a bad program through go vet in a throwaway module, then the corrected one. Vet exits 1 on a finding and 0 on silence; its first two lines, # example and # [example], name the package being checked.

copylocks: a generic struct holding a Mutex, passed by value

Verified output of generics_vet_copylocks_sh.sh — regenerated by tools/run_examples.py, never hand-typed.

$ cd bad && go vet .
# example
# [example]
./main.go:13:9: Add passes lock by value: example.Counter[K] contains sync.Mutex
./main.go:19:27: Size passes lock by value: example.Counter[K] contains sync.Mutex
./main.go:24:19: call of Size copies lock value: example.Counter[string] contains sync.Mutex
exit status 1
$ cd good && go vet .
exit status 0

The warning means a Counter[K] contains a sync.Mutex, and a value receiver, a value parameter and the call that passes one each copy it — a copied mutex guards nothing. The analyzer reports the generic declaration once, Counter[K], and the call once more with the instantiation, Counter[string].

The good version is in the second half of the transcript: a pointer receiver and a pointer parameter, *Counter[K], and vet is silent. That is the rule from A mutex guards a counter, unchanged by the type parameter.

When the tool is right, and when it is noise. Right every time a struct with a lock is copied. It is noise only for a struct that is copied before its lock is ever used, which is a design that reads as a bug anyway.

printf: a verb the type set cannot take

Verified output of generics_vet_printf_sh.sh — regenerated by tools/run_examples.py, never hand-typed.

$ cd bad && go vet .
# example
# [example]
./main.go:5:40: fmt.Printf format %d has arg v of wrong type T
./main.go:7:55: fmt.Printf format %d has arg v of wrong type T (contains ~float64)
./main.go:9:46: fmt.Printf format %d has arg v of wrong type T (contains ~string)
exit status 1
$ cd good && go vet .
exit status 0

The warning means %d is checked against every type in the parameter's set, not against the calls: under any nothing guarantees an integer; under ~int | ~float64 the message names the term that does not fit, (contains ~float64); under ~string likewise. ShowCount[T ~int] with %d is not reported.

The good version: %v, which formats every type, or a constraint whose every term takes the verb — ShowCount is left as it was.

When the tool is right, and when it is noise. Right: the %d on a float prints %!d(float64=2.5) at run time, and vet is the only thing that says so before then. Noise: a verb that is wrong for a term no caller will ever pass — but then the constraint is wider than the function, and narrowing it fixes both.

composites: an instantiated struct literal with unkeyed fields

Verified output of generics_vet_composites_sh.sh — regenerated by tools/run_examples.py, never hand-typed.

$ go mod init example
$ go vet ./...
# example
# [example]
./main.go:10:14: example/ledger.Entry[int] struct literal uses unkeyed fields
exit status 1
$ go vet ./...   # with keyed fields
exit status 0

The warning means ledger.Entry[int]{"cash", 100} depends on the order of fields in another package's struct, which that package may change; the type argument does not change the rule. Struct types in the same package are exempt.

The good version names the fields, ledger.Entry[int]{Account: "cash", Amount: 100}, and vet is silent.

When the tool is right, and when it is noise. Right for any struct another package owns. Noise for small pair-like types whose field order is the point — which is why composites exempts types from the same package, and why the standard library's image.Point{1, 2} is allowed by a whitelist inside the analyzer.

stdversion: cmp, slices and maps are newer than the module's go line

Verified output of generics_vet_stdversion_sh.sh — regenerated by tools/run_examples.py, never hand-typed.

$ go vet .
# example
# [example]
./main.go:9:54: cmp.Or requires go1.22 or later (module is go1.21)
./main.go:12:21: slices.Concat requires go1.22 or later (module is go1.21)
exit status 1
$ go build .
exit status 0
$ go mod edit -go=1.22 && go vet .
exit status 0

The warning means the module declares go 1.21, and cmp.Or and slices.Concat arrived in Go 1.22: a user whose toolchain honours the line — Go 1.21 itself — will fail to build the package. The compiler here is 1.25, so go build succeeds and says nothing; vet is what checks the promise the go line makes. The generic helper packages — cmp (1.21), slices and maps (1.21; iterator functions 1.23), iter (1.23) — are where this bites most, because they grew a few functions per release.

The good version: go mod edit -go=1.22, and vet is silent, as the last two lines show — or avoid the newer function while the line stays.

When the tool is right, and when it is noise. Right whenever the module is a library others build with older toolchains. Noise for a main package whose go line is just old: raise the line.

gofmt -s: the element type repeated inside a composite literal

Verified output of generics_vet_gofmt_sh.sh — regenerated by tools/run_examples.py, never hand-typed.

$ gofmt -l literals.go
exit status 0
$ gofmt -s -l literals.go
literals.go
exit status 0
$ gofmt -s -d literals.go
diff literals.go.orig literals.go
--- literals.go.orig
+++ literals.go
@@ -8,7 +8,7 @@
 }

 func main() {
-   stock := []Pair[string, int]{Pair[string, int]{"pear", 4}, Pair[string, int]{"apple", 7}}
-   byName := map[string]Pair[string, int]{"pear": Pair[string, int]{"pear", 4}}
+   stock := []Pair[string, int]{{"pear", 4}, {"apple", 7}}
+   byName := map[string]Pair[string, int]{"pear": {"pear", 4}}
    fmt.Println(stock, byName)
 }
exit status 0

The warning means — there is none from gofmt alone: the file is formatted. With -s, gofmt -s -l lists it and -d shows why: inside []Pair[string, int]{…} the element type is known, so each Pair[string, int]{…} can be {…}. An instantiated type name is the longest kind there is, and the one most worth not repeating.

The good version is the + side of the diff.

When the tool is right, and when it is noise. Right for slices and maps of a generic struct. It is never wrong, only optional: CI here runs gofmt -l, not -s, so a repeated instantiation passes.

The five drivers

generics_vet_copylocks_sh.sh in full — pasted here by tools/run_examples.py from the file CI runs.

#!/usr/bin/env bash
# copylocks sees through a type parameter: a Counter[K] that holds a
# sync.Mutex is reported wherever it is passed by value -- a value receiver,
# a parameter, and the call site -- and the pointer version is clean.
set -u
export GOTOOLCHAIN=local

dir=$(mktemp -d)
trap 'rm -rf "$dir"' EXIT
mkdir -p "$dir/bad" "$dir/good"

cat >"$dir/bad/main.go" <<'GO'
package main

import (
    "fmt"
    "sync"
)

type Counter[K comparable] struct {
    mu     sync.Mutex
    counts map[K]int
}

func (c Counter[K]) Add(key K) {
    c.mu.Lock()
    defer c.mu.Unlock()
    c.counts[key]++
}

func Size[K comparable](c Counter[K]) int { return len(c.counts) }

func main() {
    c := Counter[string]{counts: map[string]int{}}
    c.Add("orders")
    fmt.Println(Size(c))
}
GO

cat >"$dir/good/main.go" <<'GO'
package main

import (
    "fmt"
    "sync"
)

type Counter[K comparable] struct {
    mu     sync.Mutex
    counts map[K]int
}

func (c *Counter[K]) Add(key K) {
    c.mu.Lock()
    defer c.mu.Unlock()
    c.counts[key]++
}

func Size[K comparable](c *Counter[K]) int { return len(c.counts) }

func main() {
    c := &Counter[string]{counts: map[string]int{}}
    c.Add("orders")
    fmt.Println(Size(c))
}
GO

say() { printf '$ %s\n' "$*"; }

for variant in bad good; do
    cd "$dir/$variant" || exit 1
    go mod init example >/dev/null 2>&1 || exit 1
    say "cd $variant && go vet ."
    go vet . 2>&1
    echo "exit status $?"
done

generics_vet_printf_sh.sh in full — pasted here by tools/run_examples.py from the file CI runs.

#!/usr/bin/env bash
# printf checks a verb against the type parameter's whole type set: %d on a T
# constrained by any, or by a union with a float or string term, is reported,
# and the message names the term that does not fit. %v fits everything.
set -u
export GOTOOLCHAIN=local

dir=$(mktemp -d)
trap 'rm -rf "$dir"' EXIT
mkdir -p "$dir/bad" "$dir/good"

cat >"$dir/bad/main.go" <<'GO'
package main

import "fmt"

func ShowAny[T any](v T) { fmt.Printf("%d\n", v) }

func ShowNumber[T ~int | ~float64](v T) { fmt.Printf("%d\n", v) }

func ShowLabel[T ~string](v T) { fmt.Printf("%d\n", v) }

func ShowCount[T ~int](v T) { fmt.Printf("%d\n", v) }

func main() {
    ShowAny("pear")
    ShowNumber(2.5)
    ShowLabel("paid")
    ShowCount(7)
}
GO

cat >"$dir/good/main.go" <<'GO'
package main

import "fmt"

func ShowAny[T any](v T) { fmt.Printf("%v\n", v) }

func ShowNumber[T ~int | ~float64](v T) { fmt.Printf("%v\n", v) }

func ShowCount[T ~int](v T) { fmt.Printf("%d\n", v) }

func main() {
    ShowAny("pear")
    ShowNumber(2.5)
    ShowCount(7)
}
GO

say() { printf '$ %s\n' "$*"; }

for variant in bad good; do
    cd "$dir/$variant" || exit 1
    go mod init example >/dev/null 2>&1 || exit 1
    say "cd $variant && go vet ."
    go vet . 2>&1
    echo "exit status $?"
done

generics_vet_composites_sh.sh in full — pasted here by tools/run_examples.py from the file CI runs.

#!/usr/bin/env bash
# composites: an unkeyed struct literal of a type from another package is
# reported, and an instantiated generic struct is no exception. Keyed fields
# are silent.
set -u
export GOTOOLCHAIN=local

dir=$(mktemp -d)
trap 'rm -rf "$dir"' EXIT
mkdir -p "$dir/ledger"
cd "$dir" || exit 1

cat >ledger/ledger.go <<'GO'
package ledger

type Entry[A any] struct {
    Account string
    Amount  A
}
GO

cat >main.go <<'GO'
package main

import (
    "fmt"

    "example/ledger"
)

func main() {
    fmt.Println(ledger.Entry[int]{"cash", 100})
}
GO

say() { printf '$ %s\n' "$*"; }

say 'go mod init example'
go mod init example >/dev/null 2>&1 || exit 1

say 'go vet ./...'
go vet ./... 2>&1
echo "exit status $?"

cat >main.go <<'GO'
package main

import (
    "fmt"

    "example/ledger"
)

func main() {
    fmt.Println(ledger.Entry[int]{Account: "cash", Amount: 100})
}
GO

say 'go vet ./...   # with keyed fields'
go vet ./... 2>&1
echo "exit status $?"

generics_vet_stdversion_sh.sh in full — pasted here by tools/run_examples.py from the file CI runs.

#!/usr/bin/env bash
# stdversion: the generic helpers arrived in releases (cmp.Or and
# slices.Concat in 1.22) that a module's go line may be older than. The
# compiler builds the code anyway; vet is what says the line is a lie.
set -u
export GOTOOLCHAIN=local

dir=$(mktemp -d)
trap 'rm -rf "$dir"' EXIT
cd "$dir" || exit 1

cat >go.mod <<'MOD'
module example

go 1.21
MOD

cat >main.go <<'GO'
package main

import (
    "cmp"
    "fmt"
    "slices"
)

func First[T comparable](values ...T) T { return cmp.Or(values...) }

func main() {
    fmt.Println(slices.Concat([]int{7}, []int{12}))
    fmt.Println(First("", "fallback"))
}
GO

say() { printf '$ %s\n' "$*"; }

say 'go vet .'
go vet . 2>&1
echo "exit status $?"

say 'go build .'
go build . 2>&1
echo "exit status $?"

say 'go mod edit -go=1.22 && go vet .'
go mod edit -go=1.22 && go vet . 2>&1
echo "exit status $?"

generics_vet_gofmt_sh.sh in full — pasted here by tools/run_examples.py from the file CI runs.

#!/usr/bin/env bash
# gofmt -s: inside a composite literal whose element type is already known,
# each element's type may be left out -- and an instantiated Pair[string, int]
# is exactly the kind of type worth not repeating.
set -u
export GOTOOLCHAIN=local

dir=$(mktemp -d)
trap 'rm -rf "$dir"' EXIT
cd "$dir" || exit 1

cat >literals.go <<'GO'
package main

import "fmt"

type Pair[K comparable, V any] struct {
    Key K
    Val V
}

func main() {
    stock := []Pair[string, int]{Pair[string, int]{"pear", 4}, Pair[string, int]{"apple", 7}}
    byName := map[string]Pair[string, int]{"pear": Pair[string, int]{"pear", 4}}
    fmt.Println(stock, byName)
}
GO

say() { printf '$ %s\n' "$*"; }

say 'gofmt -l literals.go'
gofmt -l literals.go
echo "exit status $?"

say 'gofmt -s -l literals.go'
gofmt -s -l literals.go
echo "exit status $?"

say 'gofmt -s -d literals.go'
gofmt -s -d literals.go
echo "exit status $?"

What no tool catches

These compile, pass go vet, and are wrong. staticcheck ↗ and golangci-lint ↗ have no check for them either; they are decisions, not slips.

  • A union without ~. interface{ int | float64 } compiles, every caller with an int or a float64 is happy, and the first caller with a type Celsius float64 — in another package, later — gets Celsius does not satisfy Exact (possibly missing ~ for float64 in Exact). Nothing before that build says the set was meant to be wider — A type parameter needs a constraint.
  • comparable on an interface key. Set[any] and Index[T comparable] over a []any compile since Go 1.20 and panic at run time the moment a []int is compared with another []int or used as a key: comparing uncomparable type []int, hash of unhashable type []int. The type checker accepted the trade; no analyzer reopens it — Constraints are type sets.
  • A cmp.Ordered function that meets NaN. Max(2.5, NaN) is NaN and Max(NaN, 2.5) is 2.5, because > is false against NaN either way. The function is correct for every integer and string in its set and order-dependent for floats, and nothing flags the float term — A type parameter needs a constraint.
  • A type parameter where an interface was the right tool. func Kind[T any](v T) that immediately does switch any(v).(type) gained nothing from T and lost the ability to be called through an interface; vet has no opinion on whether a function needed to be generic. The guideline is Ian Lance Taylor's When To Use Generics ↗: if the body only ever calls methods, take an interface; if it only stores, passes or indexes the values, take a type parameter.

Sources