Interfaces: what go vet and gofmt catch¶
Level: 201 · anyone deciding how much to trust a clean go vet around interfaces
One line: Four go vet analyzers have something to say about interfaces and methods — ifaceassert, stdmethods, composites and copylocks — and go build -gcflags=-m is the lens on what boxing costs; the mistakes that matter most in this chapter, the typed-nil error and the value receiver that changes a copy, pass every one of them.
gofmt has nothing particular to say here: it formats a method declaration and an interface like any other declaration, and gofmt -s has no simplification that involves either. Each driver below makes a throwaway module, runs go vet, and prints the finding and the exit status — vet exits 1 on any finding.
ifaceassert: an assertion between interfaces that no type can satisfy¶
Closer wants Close() error; an older Shutdowner wants Close(). A function asserts from one to the other.
Verified output of interfaces_vet_ifaceassert_sh.sh — regenerated by tools/run_examples.py, never hand-typed.
$ go mod init example
$ go build ./...
exit status 0
$ go vet ./...
# example
# [example]
./main.go:12:17: impossible type assertion: no type can implement both example.Closer and example.Shutdowner (conflicting types for Close method)
exit status 1
The warning means — no type can have two methods named Close with different signatures, so c.(Shutdowner) is false for every value that will ever exist. The compiler allows it, because an interface-to-interface assertion is legal in general; ifaceassert ↗ checks the two method sets for a conflicting name.
The good version — decide which Close the code wants and assert to an interface that has it; when the old interface is what the value implements, the assertion is to Shutdowner from an any, not from a Closer:
When the tool is right, and when it is noise — always right: the assertion is dead code. It reads only the assertions and type switches written in the source; a reflect.Type.Implements call asking the same question is answered at run time, by false.
stdmethods: a well-known method with the wrong signature¶
Invoice declares String() int, Error() int, WriteTo(w io.Writer) error and MarshalJSON() []byte.
Verified output of interfaces_vet_stdmethods_sh.sh — regenerated by tools/run_examples.py, never hand-typed.
$ go mod init example
$ go vet main.go
# command-line-arguments
# [command-line-arguments]
./main.go:12:18: method WriteTo(w io.Writer) error should have signature WriteTo(io.Writer) (int64, error)
./main.go:13:18: method MarshalJSON() []byte should have signature MarshalJSON() ([]byte, error)
exit status 1
$ go build main.go use.go
# command-line-arguments
./use.go:5:22: cannot use Invoice{} (value of struct type Invoice) as fmt.Stringer value in variable declaration: Invoice does not implement fmt.Stringer (wrong type for method String)
have String() int
want String() string
./use.go:6:15: cannot use Invoice{} (value of struct type Invoice) as error value in variable declaration: Invoice does not implement error (wrong type for method Error)
have Error() int
want Error() string
exit status 1
The warning means — stdmethods ↗ keeps a list of method names that the standard library gives a meaning, with the signature each must have: Format, GobDecode, GobEncode, MarshalJSON, MarshalXML, ReadByte, ReadFrom, ReadRune, Scan, Seek, UnmarshalJSON, UnmarshalXML, UnreadByte, UnreadRune, WriteByte and WriteTo, plus Is, As and Unwrap on types that are errors. A method with one of those names and another signature will be silently ignored by encoding/json, io.Copy and the rest, so vet reports it.
String and Error are not on the list. vet said nothing about String() int or Error() int: the second command shows that it is the compiler that reports them, and only at the point where an Invoice is used as a fmt.Stringer or an error. An Invoice that is only ever passed to fmt.Println will print as a struct, with no message anywhere — Println checks for a Stringer at run time and finds none.
The good version — the standard signatures:
func (i Invoice) String() string { return fmt.Sprintf("invoice %d", i.ID) }
func (i Invoice) Error() string { return "invoice error" }
func (i Invoice) WriteTo(w io.Writer) (int64, error) { n, err := fmt.Fprintf(w, "%d", i.ID); return int64(n), err }
func (i Invoice) MarshalJSON() ([]byte, error) { return json.Marshal(i.ID) }
When the tool is right, and when it is noise — right whenever the type is meant to be used through that interface. It is noise for a type that happens to have a method called Scan or Seek for its own reasons; rename it and the finding goes away.
composites: an unkeyed literal of an imported struct¶
Employee embeds net/mail.Address, and the literal gives the Address's fields by position.
Verified output of interfaces_vet_composites_sh.sh — regenerated by tools/run_examples.py, never hand-typed.
$ go mod init example
$ go vet main.go
# command-line-arguments
# [command-line-arguments]
./main.go:14:16: net/mail.Address struct literal uses unkeyed fields
exit status 1
$ go vet keyed.go
exit status 0
The warning means — a positional literal of a struct from another package breaks when that package adds or reorders a field, so composites ↗ reports it. The local Employee literal around it was unkeyed too and drew no finding: a type in the same package is yours to reorder.
The good version — keyed.go in the transcript names every field, and vet exits 0. Embedding is why this analyzer is on this page: an embedded imported type's fields are promoted, so the outer literal tends to be written from memory of the promoted names, and the inner literal positionally.
When the tool is right, and when it is noise — right for any struct from another module. The analyzer has an allowlist of standard types whose positional form is idiomatic (image.Point, the colours in image/color, unicode.Range16); for anything else, key the fields.
copylocks: a value receiver on a type that holds a lock¶
Ledger has a sync.Mutex field. Add has a value receiver, report takes a Ledger by value, and main copies one into snapshot.
Verified output of interfaces_vet_copylocks_sh.sh — regenerated by tools/run_examples.py, never hand-typed.
$ go mod init example
$ go vet main.go
# command-line-arguments
# [command-line-arguments]
./main.go:13:9: Add passes lock by value: command-line-arguments.Ledger contains sync.Mutex
./main.go:19:15: report passes lock by value: command-line-arguments.Ledger contains sync.Mutex
./main.go:24:9: call of report copies lock value: command-line-arguments.Ledger contains sync.Mutex
./main.go:25:14: assignment copies lock value to snapshot: command-line-arguments.Ledger contains sync.Mutex
exit status 1
$ go vet pointers.go
exit status 0
The warning means — the receiver l in Add is a copy of the caller's Ledger, mutex and all: l.mu.Lock() locks the copy, l.entries++ increments the copy, and the caller's ledger is neither guarded nor changed. copylocks ↗ reports every place a value containing a sync.Locker is copied — the receiver, the parameter, the call, the assignment — and contains sync.Mutex names the field. The message for a type that embeds the mutex ends at the type name instead.
The good version — pointers.go: pointer receivers, a pointer parameter, and vet exits 0. A mutex guards a counter is the lock doing its job once it is not copied. This entry is the chapter's clearest case of a value receiver being wrong: with a lock inside, the copy is not merely wasted, it is a lock on nothing.
When the tool is right, and when it is noise — right every time. The analyzer's source exempts a right-hand side that is a composite literal or the result of a call, since those are fresh values that have never been locked — which is why return Ledger{} in a constructor draws no finding, and var l Ledger; return l does. (Not machine-checked here.)
go build -gcflags=-m: a value boxed and kept escapes to the heap¶
keepValue stores an int parameter in a package-level any; keepPointer stores a pointer.
Verified output of interfaces_vet_escape_sh.sh — regenerated by tools/run_examples.py, never hand-typed.
$ go mod init example
$ go build -gcflags=-m . 2>&1 | grep -E "escapes|leaking|moved"
./main.go:6:39: quantity escapes to heap
./main.go:9:18: leaking param: order
./main.go:13:14: &[2]int{...} escapes to heap
exit status 0
The output means — quantity escapes to heap is the boxing: the compiler must copy the int somewhere the interface's data word can point at after the function returns. leaking param: order is the pointer case — nothing is copied, the pointer itself is what leaks — and &[2]int{...} escapes to heap is the caller's array being moved to the heap because a pointer to it will outlive the call. These are the two rows of the table in An interface is two words, seen from the compiler's side.
The good version — there is no bad version here; -m is a lens. When a boxed value is kept, one allocation per boxing is the price, and it is either paid by copying (quantity) or already paid by whoever made the pointer (order).
When the tool is right, and when it is noise — the decisions are exact for the program as compiled; they change with inlining, with -gcflags=-N, and between Go releases. Escape analysis decides stack or heap reads the full output.
The five drivers
interfaces_vet_ifaceassert_sh.sh in full — pasted here by tools/run_examples.py from the file CI runs.
#!/usr/bin/env bash
# ifaceassert: an assertion from one interface to another that no type can
# ever satisfy, because the two interfaces want the same method name with
# different signatures. It compiles; go vet refuses it.
set -u
export GOTOOLCHAIN=local
dir=$(mktemp -d)
trap 'rm -rf "$dir"' EXIT
cd "$dir" || exit 1
cat >main.go <<'GO'
package main
import "fmt"
// Closer is what the connection pool returns.
type Closer interface{ Close() error }
// Shutdowner is an older interface with a Close that returns nothing.
type Shutdowner interface{ Close() }
func shutdown(c Closer) {
if s, ok := c.(Shutdowner); ok { // no type has both Close() error and Close()
s.Close()
return
}
fmt.Println("not a Shutdowner")
}
func main() { shutdown(nil) }
GO
say() { printf '$ %s\n' "$*"; }
say 'go mod init example'
go mod init example >/dev/null 2>&1 || exit 1
say 'go build ./...'
go build ./... 2>&1
echo "exit status $?"
say 'go vet ./...'
go vet ./... 2>&1
echo "exit status $?"
interfaces_vet_stdmethods_sh.sh in full — pasted here by tools/run_examples.py from the file CI runs.
#!/usr/bin/env bash
# stdmethods: methods whose names the standard library gives a meaning, with
# the wrong signature. Invoice gets four of them wrong. vet reports the two
# on its list (WriteTo, MarshalJSON) and says nothing about String() int and
# Error() int -- those the compiler reports, but only where the value is
# used as a Stringer or an error.
set -u
export GOTOOLCHAIN=local
dir=$(mktemp -d)
trap 'rm -rf "$dir"' EXIT
cd "$dir" || exit 1
cat >main.go <<'GO'
package main
import (
"fmt"
"io"
)
type Invoice struct{ ID int }
func (i Invoice) String() int { return i.ID }
func (i Invoice) Error() int { return i.ID }
func (i Invoice) WriteTo(w io.Writer) error { return nil }
func (i Invoice) MarshalJSON() []byte { return nil }
func main() { fmt.Println(Invoice{7}.ID) }
GO
cat >use.go <<'GO'
package main
import "fmt"
var _ fmt.Stringer = Invoice{}
var _ error = Invoice{}
GO
say() { printf '$ %s\n' "$*"; }
say 'go mod init example'
go mod init example >/dev/null 2>&1 || exit 1
say 'go vet main.go'
go vet main.go 2>&1
echo "exit status $?"
say 'go build main.go use.go'
go build main.go use.go 2>&1
echo "exit status $?"
interfaces_vet_composites_sh.sh in full — pasted here by tools/run_examples.py from the file CI runs.
#!/usr/bin/env bash
# composites: a struct literal of a type from another package with its
# fields given by position. Employee embeds net/mail.Address; vet flags the
# unkeyed mail.Address literal and says nothing about the local Employee
# literal around it, keyed or not. The good version names the fields.
set -u
export GOTOOLCHAIN=local
dir=$(mktemp -d)
trap 'rm -rf "$dir"' EXIT
cd "$dir" || exit 1
cat >main.go <<'GO'
package main
import (
"fmt"
"net/mail"
)
type Employee struct {
mail.Address
Dept string
}
func main() {
e := Employee{mail.Address{"Ann Lee", "ann@example.com"}, "Sales"}
fmt.Println(e.Name, e.Address.String(), e.Dept)
}
GO
cat >keyed.go <<'GO'
package main
import (
"fmt"
"net/mail"
)
type Employee struct {
mail.Address
Dept string
}
func main() {
e := Employee{Address: mail.Address{Name: "Ann Lee", Address: "ann@example.com"}, Dept: "Sales"}
fmt.Println(e.Name, e.Address.String(), e.Dept)
}
GO
say() { printf '$ %s\n' "$*"; }
say 'go mod init example'
go mod init example >/dev/null 2>&1 || exit 1
say 'go vet main.go'
go vet main.go 2>&1
echo "exit status $?"
say 'go vet keyed.go'
go vet keyed.go 2>&1
echo "exit status $?"
interfaces_vet_copylocks_sh.sh in full — pasted here by tools/run_examples.py from the file CI runs.
#!/usr/bin/env bash
# copylocks: a value receiver on a type that holds a sync.Mutex copies the
# lock with every call, so the method locks its own copy and guards nothing.
# vet reports the receiver, a by-value parameter, the call that copies, and a
# plain assignment. With pointer receivers and a pointer parameter it is
# silent.
set -u
export GOTOOLCHAIN=local
dir=$(mktemp -d)
trap 'rm -rf "$dir"' EXIT
cd "$dir" || exit 1
cat >main.go <<'GO'
package main
import (
"fmt"
"sync"
)
type Ledger struct {
mu sync.Mutex
entries int
}
func (l Ledger) Add() { // value receiver: l is a copy, mutex and all
l.mu.Lock()
defer l.mu.Unlock()
l.entries++
}
func report(l Ledger) { fmt.Println(l.entries) }
func main() {
var l Ledger
l.Add()
report(l)
snapshot := l
fmt.Println(snapshot.entries)
}
GO
cat >pointers.go <<'GO'
package main
import (
"fmt"
"sync"
)
type Ledger struct {
mu sync.Mutex
entries int
}
func (l *Ledger) Add() {
l.mu.Lock()
defer l.mu.Unlock()
l.entries++
}
func report(l *Ledger) { fmt.Println(l.entries) }
func main() {
var l Ledger
l.Add()
report(&l)
}
GO
say() { printf '$ %s\n' "$*"; }
say 'go mod init example'
go mod init example >/dev/null 2>&1 || exit 1
say 'go vet main.go'
go vet main.go 2>&1
echo "exit status $?"
say 'go vet pointers.go'
go vet pointers.go 2>&1
echo "exit status $?"
interfaces_vet_escape_sh.sh in full — pasted here by tools/run_examples.py from the file CI runs.
#!/usr/bin/env bash
# go build -gcflags=-m is the lens for boxing: the compiler says which values
# it moves to the heap when they are converted to an interface and kept. Only
# the escape lines are kept here; the inlining decisions are noise for this
# question.
set -u
export GOTOOLCHAIN=local
dir=$(mktemp -d)
trap 'rm -rf "$dir"' EXIT
cd "$dir" || exit 1
cat >main.go <<'GO'
package main
var kept any
//go:noinline
func keepValue(quantity int) { kept = quantity }
//go:noinline
func keepPointer(order *[2]int) { kept = order }
func main() {
keepValue(300)
keepPointer(&[2]int{1, 2})
}
GO
say() { printf '$ %s\n' "$*"; }
say 'go mod init example'
go mod init example >/dev/null 2>&1 || exit 1
say 'go build -gcflags=-m . 2>&1 | grep -E "escapes|leaking|moved"'
go build -gcflags=-m . 2>&1 | grep -E "escapes|leaking|moved"
echo "exit status $?"
What no tool catches¶
- The typed nil error.
var verr *ValidationError; …; return verrcompiles, vets clean, and gives every caller a non-nilerrorthat prints as<nil>— A nil pointer in an interface is not nil. staticcheck ↗ reports a comparison that can never be true when it can see the typed return;go vethas no such check. - The value receiver that changes a copy.
func (c Counter) Reset() { c.orders = 0 }is legal and does nothing to any counter — A pointer receiver changes the method set.copylockscatches it only when the struct holds a lock; for a plain struct, nothing does — the method has no result to leave unused and no lock to copy, so it is outside every analyzer. - The embedded interface nobody filled in.
LoudNotifier{}satisfiesNotifierin full at compile time and panics on the first call of the promoted method — Embedding is not inheritance. A constructor that takes the inner value as a parameter is the fix, and no tool insists on one. - The interface that is too big. An interface with eight methods is satisfied by one type and mocked by nobody. The Go proverb ↗ is "The bigger the interface, the weaker the abstraction", and the wiki's review guidance on interfaces ↗ adds the other half: define the interface on the side that uses it, and return concrete types — "accept interfaces, return structs". Nothing measures an interface's size; a reader does.
Sources¶
cmd/vet↗ — the analyzersgo vetruns by default, andsrc/cmd/vet/main.goat go1.25.5 ↗, the list itself.- The analyzers:
ifaceassert↗,stdmethods↗,composite↗ (thecompositescheck) andcopylock↗ (thecopylockscheck). - The Go wiki, Go Code Review Comments ↗ — "Interfaces" and "Receiver Type".
- Rob Pike, Go Proverbs ↗, and the talk, Gopherfest 2015: Go Proverbs ↗.
- staticcheck ↗ — SA4023 for the typed nil; not run in this library.