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 anintor afloat64is happy, and the first caller with atype Celsius float64— in another package, later — getsCelsius 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. comparableon an interface key.Set[any]andIndex[T comparable]over a[]anycompile since Go 1.20 and panic at run time the moment a[]intis compared with another[]intor 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.Orderedfunction that meets NaN.Max(2.5, NaN)isNaNandMax(NaN, 2.5)is2.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 doesswitch any(v).(type)gained nothing fromTand 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¶
go vet↗ and the analyzers it runs:copylocks↗,printf↗,composites↗,stdversion↗.gofmt↗ — the-sflag and the simplifications it applies.- Go Modules Reference, the
godirective ↗ — what the line promises. - Ian Lance Taylor, When To Use Generics ↗, the Go blog, 12 April 2022.