Errors: what go vet and gofmt catch¶
Level: 201 · anyone who wants the tooling to find the error-handling slips before a reviewer does
One line: Four go vet analyzers cover error values — printf for %w in the wrong function or with the wrong operand, errorsas for a target of the wrong shape, unusedresult for an Errorf whose result was dropped, and stdmethods for an Unwrap, Is or As with a near-miss signature — and none of them sees a shadowed err, a typed nil, a == on a wrapped sentinel, or a cause lost to %v. gofmt has nothing to say about errors at all.
Each entry runs a bad program through go vet with a driver that prints the report and the exit status (vet exits 1 on a finding), and, because every one of these programs compiles, then runs it to show what the mistake does. Nothing outside the Go distribution runs here; the tools that would catch more are named at the end.
printf: %w outside Errorf, and Errorf's %w with a non-error¶
%w handed to Printf and Sprintf, an int handed to %w, an error handed to %d, a %w with no operand, and an operand with no verb.
Verified output of errors_vet_printf_sh.sh — regenerated by tools/run_examples.py, never hand-typed.
$ go mod init example
$ go vet .
# example
# [example]
./wrap_verbs.go:11:23: fmt.Printf does not support error-wrapping directive %w
./wrap_verbs.go:12:36: fmt.Sprintf does not support error-wrapping directive %w
./wrap_verbs.go:13:33: fmt.Errorf format %w has arg 503 of wrong type int
./wrap_verbs.go:15:38: fmt.Errorf format %w reads arg #1, but call has 0 args
./wrap_verbs.go:16:14: fmt.Errorf call needs 1 arg but has 2 args
exit status 1
$ go run .
printf : %!w(*errors.errorString=&{order not found})
sprintf: %!w(*errors.errorString=&{order not found})
status %!w(int=503)
count &{%!d(string=order not found)}
load order: %!w(MISSING)
load order: order not found%!(EXTRA int=7)
exit status 0
The warning means the format string and the arguments do not agree. Only fmt.Errorf understands %w — the other fmt functions print %!w(…) — and Errorf insists on an error operand for it (fmt.Errorf ↗). The run shows the damage: a %!w(…) in the output, a %!w(MISSING), an %!(EXTRA int=7) — the fmt package's error markers ↗.
The good version. %v outside Errorf, an error operand for %w, and one operand per verb:
fmt.Printf("printf : %v\n", ErrNotFound)
msg := fmt.Sprintf("sprintf: %v", ErrNotFound)
err := fmt.Errorf("status %d: %w", 503, ErrGateway)
When the tool is right, and when it is noise. Right every time it fires: these calls never do what they look like. What it does not fire on is the fourth line — fmt.Errorf("count %d", ErrNotFound) — because error is an interface and the analyzer cannot know the dynamic type is not a number; the run shows the &{%!d(string=order not found)} it produces. The analyzer also follows wrappers: a function of your own that ends in Printf and takes format string, args ...any is checked too.
errorsas: a target that is not a pointer to an error¶
errors.As with a nil *NotFoundError passed as is, a struct value, a pointer to a struct whose methods are on the pointer, and a pointer to an error.
Verified output of errors_vet_errorsas_sh.sh — regenerated by tools/run_examples.py, never hand-typed.
$ go mod init example
$ go vet .
# example
# [example]
./as_targets.go:15:14: second argument to errors.As must be a non-nil pointer to either a type that implements error, or to any interface type
./as_targets.go:16:14: second argument to errors.As must be a non-nil pointer to either a type that implements error, or to any interface type
./as_targets.go:18:14: second argument to errors.As must be a non-nil pointer to either a type that implements error, or to any interface type
./as_targets.go:20:14: second argument to errors.As should not be *error
exit status 1
$ go build -o as_targets . && ./as_targets
panic: errors: target must be a non-nil pointer
exit status 2
The warning means the second argument's static type is not a pointer to something that implements error or to an interface (errorsas ↗). The run shows what the first one does at run time: the panic from errors.As finds a type in the chain. The fourth report is different in kind: &target where target is an error is a legal target that always matches — every error is assignable to error — so As would return true for any non-nil error and tell you nothing.
The good version. A pointer to the type that was stored, and only that:
When the tool is right, and when it is noise. Right whenever it fires. The analyzer reads the static type, so a target passed as an any — as the lesson's program does, to reach the run-time panic — is not checked, and the analyzer says so in its own source: a target of any is always allowed, since it often indicates a value forwarded from another source.
unusedresult: an Errorf whose result is dropped¶
A fmt.Errorf that was meant to be returned, an errors.New on its own line, and an Error() call whose string goes nowhere.
Verified output of errors_vet_unusedresult_sh.sh — regenerated by tools/run_examples.py, never hand-typed.
$ go mod init example
$ go vet .
# example
# [example]
./dropped_results.go:12:3: result of fmt.Errorf call not used
./dropped_results.go:18:2: result of errors.New call not used
./dropped_results.go:19:2: result of (error).Error call not used
exit status 1
$ go run .
load(101) returned: <nil>
exit status 0
The warning means a call with no side effects produced a value nobody used (unusedresult ↗). The run shows the usual damage: load(101) was supposed to fail and returned <nil>, because the Errorf on line 12 built an error and threw it away.
The good version.
When the tool is right, and when it is noise. Right every time for fmt.Errorf, errors.New, fmt.Sprintf and the Error and String methods, which is the list it checks by default (go tool vet help unusedresult prints it, and -unusedresult.funcs extends it). It does not check that an error returned to you is used — os.Remove(name) on its own line is silent — that is errcheck's job, named below.
stdmethods: Unwrap, Is and As with the wrong signature¶
An Unwrap that returns the concrete pointer type, an Is that takes any, and an As that takes error — each one letter away from the method the errors package looks for.
Verified output of errors_vet_stdmethods_sh.sh — regenerated by tools/run_examples.py, never hand-typed.
$ go mod init example
$ go vet .
# example
# [example]
./wrong_signatures.go:16:22: method Unwrap() should have signature Unwrap() error or Unwrap() []error
./wrong_signatures.go:17:22: method Is(target any) bool should have signature Is(error) bool
./wrong_signatures.go:18:22: method As(target error) bool should have signature As(any) bool
exit status 1
$ go run .
catalogue has lamp: true
errors.Unwrap(err): <nil>
errors.Is(err, ErrTimeout): false
exit status 0
The warning means a method has the name of a well-known interface method and a signature that interface does not have (stdmethods ↗), so the errors package will never call it. The run shows exactly that: errors.Unwrap(err) is <nil> and errors.Is(err, ErrTimeout) is false, though the type "has" an Unwrap and an Is.
The good version. The signatures the report names, which are the ones in An error type wraps through Unwrap:
func (e *QueryError) Unwrap() error { return e.Err }
func (e *QueryError) Is(target error) bool { return target == ErrTimeout }
When the tool is right, and when it is noise. Right when the receiver is an error type. The Catalogue type in the same program has an Is(any) bool and an Unwrap() []string that mean something else, and the report does not mention them: for these three names the analyzer looks only at types that implement error. A type that is not an error may use the names freely.
What no tool catches¶
- A shadowed
err. The driver below compiles the program, runsgo veton it (silent, exit 0), runs it —saveprints that the check failed and then returns<nil>— and asks forgo vet -shadow, which does not exist: theshadowanalyzer lives ingolang.org/x/tools↗ and is not part ofgo vet. The fix iserr = check(customer)with=, not:=, inside the block.
Verified output of errors_vet_shadowed_err_sh.sh — regenerated by tools/run_examples.py, never hand-typed.
$ go mod init example
$ go vet .
exit status 0
$ go run .
save: check failed: customer is empty
save returned: <nil>
exit status 0
$ go vet -shadow .
flag provided but not defined: -shadow
usage: go vet [build flags] [-vettool prog] [vet flags] [packages]
Run 'go help vet' for details.
Run 'go tool vet help' for a full list of flags and analyzers.
Run 'go tool vet -help' for an overview.
exit status 2
- A typed nil returned as an
error.var nf *NotFoundError; return nfgives the caller a non-nilerrorholding a nil pointer, anderr != nilis true — A nil pointer in an interface is not nil. No analyzer ingo vetreports it;nilness, which reasons about nil in other ways, is also anx/toolsanalyzer thatgo vetdoes not ship (nilness↗). ==on a wrapped sentinel.err == ErrNotFoundcompiles, vets clean, and is false the day someone adds%w—%wwraps anderrors.Issees through it.- A cause lost to
%v.fmt.Errorf("load: %v", err)is a legitimate call, and whether it was meant to cut the chain is a design question no tool can answer — An error type wraps throughUnwrap.
Beyond the distribution: staticcheck ↗ reports error strings with a capital or a trailing stop (ST1005), go-errorlint ↗ reports == and type assertions on errors and %v where %w was likely meant, and errcheck, nilerr, shadow and errorlint all run under golangci-lint ↗. None of them runs in this library.
The five drivers
errors_vet_printf_sh.sh in full — pasted here by tools/run_examples.py from the file CI runs.
#!/usr/bin/env bash
# The printf analyzer on %w: Printf and Sprintf do not wrap, Errorf's %w wants
# an error operand, and a verb with no operand or an operand with no verb is a
# mismatch. Every one of these compiles, so the script also runs the program
# and shows what the bad calls print.
set -u
export GOTOOLCHAIN=local
dir=$(mktemp -d)
trap 'rm -rf "$dir"' EXIT
cd "$dir" || exit 1
cat >wrap_verbs.go <<'GO'
package main
import (
"errors"
"fmt"
)
var ErrNotFound = errors.New("order not found")
func main() {
fmt.Printf("printf : %w\n", ErrNotFound)
fmt.Println(fmt.Sprintf("sprintf: %w", ErrNotFound))
fmt.Println(fmt.Errorf("status %w", 503))
fmt.Println(fmt.Errorf("count %d", ErrNotFound))
fmt.Println(fmt.Errorf("load order: %w"))
fmt.Println(fmt.Errorf("load order: %s", ErrNotFound, 7))
}
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 $?"
say 'go run .'
go run . 2>&1
echo "exit status $?"
errors_vet_errorsas_sh.sh in full — pasted here by tools/run_examples.py from the file CI runs.
#!/usr/bin/env bash
# The errorsas analyzer reads the static type of errors.As's second argument:
# a nil pointer variable, a struct value, a pointer to a type whose methods
# are on the pointer, and a pointer to an error interface are each reported.
# The first of them also panics at run time, which the script shows.
set -u
export GOTOOLCHAIN=local
dir=$(mktemp -d)
trap 'rm -rf "$dir"' EXIT
cd "$dir" || exit 1
cat >as_targets.go <<'GO'
package main
import (
"errors"
"fmt"
)
type NotFoundError struct{ ID int }
func (e *NotFoundError) Error() string { return fmt.Sprintf("order %d not found", e.ID) }
func main() {
err := fmt.Errorf("load: %w", &NotFoundError{7})
var nf *NotFoundError
fmt.Println(errors.As(err, nf))
fmt.Println(errors.As(err, NotFoundError{}))
var value NotFoundError
fmt.Println(errors.As(err, &value))
var target error
fmt.Println(errors.As(err, &target))
}
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 $?"
say 'go build -o as_targets . && ./as_targets'
go build -o as_targets . || exit 1
./as_targets 2>stderr.txt
status=$?
grep '^panic:' stderr.txt
echo "exit status $status"
errors_vet_unusedresult_sh.sh in full — pasted here by tools/run_examples.py from the file CI runs.
#!/usr/bin/env bash
# The unusedresult analyzer: fmt.Errorf, errors.New and Error() have no side
# effects, so a call whose result is dropped did nothing. The usual story is
# an Errorf that was meant to be returned.
set -u
export GOTOOLCHAIN=local
dir=$(mktemp -d)
trap 'rm -rf "$dir"' EXIT
cd "$dir" || exit 1
cat >dropped_results.go <<'GO'
package main
import (
"errors"
"fmt"
)
var ErrNotFound = errors.New("order not found")
func load(id int) error {
if id > 100 {
fmt.Errorf("load order %d: %w", id, ErrNotFound)
}
return nil
}
func main() {
errors.New("never returned")
ErrNotFound.Error()
fmt.Println("load(101) returned:", load(101))
}
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 $?"
say 'go run .'
go run . 2>&1
echo "exit status $?"
errors_vet_stdmethods_sh.sh in full — pasted here by tools/run_examples.py from the file CI runs.
#!/usr/bin/env bash
# The stdmethods analyzer knows the signatures the errors package looks for.
# An Unwrap, Is or As with the wrong signature is a method the errors package
# never calls: the program shows errors.Unwrap ignoring it. The same names on
# a type that is not an error are left alone.
set -u
export GOTOOLCHAIN=local
dir=$(mktemp -d)
trap 'rm -rf "$dir"' EXIT
cd "$dir" || exit 1
cat >wrong_signatures.go <<'GO'
package main
import (
"errors"
"fmt"
)
var ErrTimeout = errors.New("timeout")
type QueryError struct {
Query string
Err error
}
func (e *QueryError) Error() string { return e.Query + ": " + e.Err.Error() }
func (e *QueryError) Unwrap() *QueryError { return nil }
func (e *QueryError) Is(target any) bool { return target == ErrTimeout }
func (e *QueryError) As(target error) bool { return false }
// Catalogue is not an error: its Is and Unwrap mean something else, and the
// analyzer leaves them alone.
type Catalogue map[string]bool
func (c Catalogue) Is(sku any) bool { return c[sku.(string)] }
func (c Catalogue) Unwrap() []string { return nil }
func main() {
fmt.Println("catalogue has lamp:", Catalogue{"lamp": true}.Is("lamp"))
err := &QueryError{"select 1", ErrTimeout}
fmt.Println("errors.Unwrap(err):", errors.Unwrap(err))
fmt.Println("errors.Is(err, ErrTimeout):", errors.Is(err, ErrTimeout))
}
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 $?"
say 'go run .'
go run . 2>&1
echo "exit status $?"
errors_vet_shadowed_err_sh.sh in full — pasted here by tools/run_examples.py from the file CI runs.
#!/usr/bin/env bash
# A shadowed err: the := inside the if declares a new variable, the outer err
# stays nil, and the function returns nil after a failure. It compiles, go vet
# is silent, and go vet has no -shadow flag (the shadow analyzer is a separate
# tool in golang.org/x/tools, not part of the distribution).
set -u
export GOTOOLCHAIN=local
dir=$(mktemp -d)
trap 'rm -rf "$dir"' EXIT
cd "$dir" || exit 1
cat >shadowed_err.go <<'GO'
package main
import (
"errors"
"fmt"
)
var ErrNoCustomer = errors.New("customer is empty")
func check(customer string) error {
if customer == "" {
return ErrNoCustomer
}
return nil
}
// save means to return check's error, but the err inside the if is a new one.
func save(customer string, checked bool) error {
var err error
if !checked {
err := check(customer)
if err != nil {
fmt.Println("save: check failed:", err)
}
}
return err
}
func main() {
fmt.Println("save returned:", save("", false))
}
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 $?"
say 'go run .'
go run . 2>&1
echo "exit status $?"
say 'go vet -shadow .'
go vet -shadow . 2>&1
echo "exit status $?"
Sources¶
- The analyzers:
printf↗,errorsas↗,unusedresult↗,stdmethods↗; and the onesgo vetdoes not ship,shadow↗ andnilness↗.go tool vet help <name>prints each one's own description. fmt↗ — the%!verb(…)markers a bad call prints.- staticcheck ↗, go-errorlint ↗ and golangci-lint ↗.