cmplint is a Go linter that detects comparisons like ptr == &MyStruct{} — code that is almost always incorrect. Each
composite literal or new() call allocates a unique address, so these comparisons typically evaluate to false or have
undefined behavior when zero-sized types are involved.
go install fillmore-labs.com/cmplint@latestbrew install fillmore-labs/tap/cmplintEget downloads prebuilt binaries. After installing it:
eget fillmore-labs/cmplintcmplint ./...Comparing pointers to newly allocated values is a source of subtle bugs in Go. Consider:
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
}The reason: every &T{} or new(T) expression creates a fresh allocation with a
unique address. So ptr == &MyStruct{} is almost always false,
regardless of what ptr points to.
For zero-sized types, the behavior is undefined:
"Pointers to distinct zero-size variables may or may not be equal."
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 suppresses diagnostics when error types implement Unwrap or Is methods, since these enable legitimate use
with errors.Is. Specifically, diagnostics are suppressed when:
- The error type has an
Unwrap() errormethod, aserrors.Istraverses 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) boolmethod, 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. Therefore,
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.Asfor 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, its logic is unsound. Use
errors.Asinstead:var panicErr *runtime.PanicNilError if errors.As(err, &panicErr) { log.Println("panic error") }
go vet -vettool=$(which cmplint) ./...errortype is a comprehensive error handling linter that includes
cmplint's checks. If you're already using errortype, you don't need cmplint separately — though running cmplint
alone might be faster on large codebases.
Add a .custom-gcl.yaml file to your project root:
---
version: v2.8.0
plugins:
- module: fillmore-labs.com/cmplint
import: fillmore-labs.com/cmplint/gclplugin
version: v0.0.6See also the golangci-lint
module plugin system documentation.
Add cmplint to your CI pipeline:
# GitHub Actions example
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-go@v6
with:
go-version: 1.25
- 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.