cmplint
is a Go linter (static analysis tool) that detects comparisons against the address of newly created values,
such as ptr == &MyStruct{}
or ptr == new(MyStruct)
. These comparisons are almost always incorrect, as each
expression creates a unique allocation at runtime, usually yielding false or undefined results.
Install the linter:
brew install fillmore-labs/tap/cmplint
go install fillmore-labs.com/cmplint@latest
Install eget
, then
eget fillmore-labs/cmplint
Run the linter on your project:
cmplint ./...
Comparing pointers to newly allocated values is a source of subtle bugs in Go. Consider this code:
import (
"time"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
d := &metav1.Duration{30 * time.Second}
if (d == &metav1.Duration{30 * time.Second}) { // This will always be false!
// This code never executes
}
According to the Go language specification, taking the address of a composite
literal (&metav1.Duration{}
) or calling new()
creates a new allocation:
“Calling the built-in function
new
or taking the address of a composite literal allocates storage for a variable at run time.”
Each allocation gets a unique address:
“Taking the address of a composite literal generates a pointer to a unique variable initialized with the literal's value.”
This means ptr == &MyStruct{}
will almost always evaluate to false
, regardless of what ptr
points to. The only
exception is zero-sized types, where the behavior is undefined:
“Pointers to distinct zero-size variables may or may not be equal.”
When developers write comparisons like ptr == &MyStruct{}
, they often intend to:
- Compare the values
- Check for a specific sentinel instance
- Use type checking
Here are examples that cmplint
will flag:
import (
"github.com/operator-framework/api/pkg/operators/v1alpha1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
// Checking if a operator update strategy matches expected values
func validateUpdateStrategy(spec *v1alpha1.CatalogSourceSpec) {
expectedTime := 30 * time.Second
// ❌ This comparison will always be false - &metav1.Duration{} creates a unique address.
if (spec.UpdateStrategy.Interval != &metav1.Duration{Duration: expectedTime}) {
// ...
}
// ✅ Correct approach: Dereference the pointer and compare values (after a nil check).
if spec.UpdateStrategy.Interval == nil || spec.UpdateStrategy.Interval.Duration != expectedTime {
// ...
}
}
func connectToDatabase() {
db, err := dbConnect()
// ❌ This will always be false - &url.Error{} creates a unique address.
if errors.Is(err, &url.Error{}) {
log.Fatal("Cannot connect to DB")
}
// ✅ Correct approach:
var urlErr *url.Error
if errors.As(err, &urlErr) {
log.Fatal("Error connecting to DB:", urlErr)
}
// ...
}
func unmarshalEvent(msg []byte) {
var es []cloudevents.Event
err := json.Unmarshal(msg, &es)
// ❌ This comparison will always be false:
if errors.Is(err, &json.UnmarshalTypeError{}) {
//...
}
// ✅ Correct approach:
var typeErr *json.UnmarshalTypeError
if errors.As(err, &typeErr) {
//...
}
}
cmplint
includes special handling for errors.Is
and similar functions to reduce
false positives. The linter suppresses diagnostics when:
- The error type has an
Unwrap() error
method, aserrors.Is
traverses the error tree.
Unwrap() error
tree example.
type wrappedError struct{ Cause error }
func (e *wrappedError) Error() string { return "wrapped: " + e.Cause.Error() }
func (e *wrappedError) Unwrap() error { return e.Cause } // This suppresses the diagnostic.
// No warning for this code:
if errors.Is(&wrappedError{os.ErrNoDeadline}, os.ErrNoDeadline) { // Valid due to "Unwrap" method.
// ...
}
- The error type has an
Is(error) bool
method, as custom comparison logic is executed.
Custom Is(error) bool
method example.
When the static type of an error is just the error
interface, the analyzer cannot know its dynamic type, so the
diagnostic is also suppressed when the target has an Is(error) bool
method:
type customError struct{ Code int }
func (i *customError) Error() string { return fmt.Sprintf("custom error %d", i.Code) }
func (i *customError) Is(err error) bool { // This suppresses the diagnostic.
_, ok := err.(*customError)
return ok
}
err = func() error {
return &customError{100}
}()
// No warning for this code:
if errors.Is(err, &customError{200}) { // Valid due to custom "Is" method.
// ...
}
The applied heuristic can lead to false positives in rare cases. For example, if one error type's Is
method is
designed to compare against a different error type, cmplint
may flag valid code. This pattern is uncommon and
potentially confusing.
This workaround improves clarity and suppresses the linting error.
type errorA struct{ Code int }
func (e *errorA) Error() string { return fmt.Sprintf("error a %d", e.Code) }
type errorB struct{ Code int }
func (e *errorB) Error() string { return fmt.Sprintf("error b %d", e.Code) }
func (e *errorB) Is(err error) bool {
if err, ok := err.(*errorA); ok { // errorB knows how to check against errorA.
return e.Code == err.Code
}
return false
}
err := func() error {
return &errorB{100}
}()
// ❌ This valid code gets flagged:
if errors.Is(err, &errorA{100}) { // Flagged, but technically correct.
// ...
}
// ✅ Document to clarify intent and assign to an identifier to suppress the warning:
target := &errorA{100} // errorB's "Is" method should match.
if errors.Is(err, target) {
// ...
}
-
“Result of comparison with address of new variable of type "..." is always false”
This indicates a comparison like
ptr == &MyStruct{}
that will never be true. Consider these fixes:-
Compare values instead:
*ptr == MyStruct{}
-
Use
errors.As
for errors:var target *MyError if errors.As(err, &target) { // ... }
-
Check dynamic type (for interface types):
if v, ok := v.(*MyStruct); ok { if v.SomeField == expected { /* ... */ } }
-
Pre-declare the target:
var sentinel = &MyStruct{} // ... if ptr == sentinel { /* ... */ }
-
-
“Result of comparison with address of new variable of type "..." is false or undefined”
This diagnostic appears for zero-sized types where the comparison behavior is undefined:
type Skip struct{} func (e *Skip) Error() string { return "host hook execution skipped." } func (r renderRunner) RunHostHook(ctx context.Context, hook *hostHook) { if err := hook.run(ctx /*, ... */); errors.Is(err, &Skip{}) { // ❌ Undefined behavior. // ... } }
or
defer func() { err := recover() if err, ok := err.(error); ok && errors.Is(err, &runtime.PanicNilError{}) { // ❌ Undefined behavior. log.Print("panic called with nil argument") } }() panic(nil)
While this might work due to Go runtime optimizations, it's logic is unsound. Use
errors.As
instead:var panicErr *runtime.PanicNilError if errors.As(err, &panicErr) { log.Println("panic error") }
For more details, see the blog post "Equality of Pointers to Zero-Sized Types".
Add cmplint
to your CI pipeline:
# GitHub Actions example
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: 1.24
- name: Run cmplint
run: go run fillmore-labs.com/cmplint@latest ./...
This project is licensed under the Apache License 2.0. See the LICENSE file for details.