DEV Community

Cover image for Catch Bad Validation Tags at Compile Time with checkerlint
Onur Cinar
Onur Cinar

Posted on

Catch Bad Validation Tags at Compile Time with checkerlint

Struct tags are just strings — a typo'd checker name, a wrong-typed field, or a renamed cross-field target all compile fine and fail silently at runtime. checkerlint catches all three before you ship.

Struct tags are string literals. The Go compiler checks that your struct compiles — it has no idea what checkers:"eq-field:Passwrd" means, so a typo in a field name, a checker applied to a field of the wrong type, or a renamed field that a cross-field rule still points at all compile fine. They fail later, at runtime, sometimes silently, sometimes as a panic in the middle of handling a request.

type Registration struct {
    Password        string `checkers:"trim required"`
    ConfirmPassword string `checkers:"required eq-field:Passwrd"` // typo: no such field
    Age             int    `checkers:"email"`                     // email is string-only
}
Enter fullscreen mode Exit fullscreen mode

Nothing here trips go build, go vet, or a normal linter — they all treat checkers:"..." as an opaque string. The first bug only surfaces the moment someone submits a registration form and eq-field can't find a field called Passwrd. The second is worse: email assumes a string under the hood, so calling it on an int field panics at validation time instead of returning a normal error.

checkerlint is a go/analysis-based static analyzer, shipped as its own module in the Checker repo, that reads these tags at build/lint time and catches exactly this class of bug before it ships:

./registration.go:3:2: checkerlint: eq-field references field "Passwrd", which doesn't exist on this struct
./registration.go:4:2: checkerlint: email requires a string, but the field's type is int
Enter fullscreen mode Exit fullscreen mode

What it actually checks

Three things, all specific to how checkers/validate tags can go wrong:

  1. Unknown checker names. Every token in the tag has to be a registered checker, normalizer, field-relative checker, omitempty, or a name your own code registered via RegisterMaker/RegisterFieldMaker with a string literal. Typo requird instead of required and checkerlint flags it — nothing else in your toolchain will.
  2. Type compatibility. A well-scoped set of built-ins panic at runtime on the wrong kind — string-only checkers (email, trim, url, and friends) and numeric-only ones (gt, gte, lt, lte). checkerlint checks the tagged field's actual type against what the checker expects, including through one level of pointer indirection and through a slice/array/map's element type for item-level tokens.
  3. Cross-field targets. eq-field, after-field, before-field, required-if, and required-unless all name a sibling field by string. checkerlint confirms that field actually exists on the struct — including, on a best-effort basis, embedded/anonymous fields — so a rename doesn't silently leave a dangling reference.

It knows the built-in vocabulary from whichever version of github.com/cinar/checker/v2 your code is built against, so it stays in sync automatically as new checkers ship. It can't see a checker registered only at runtime in a separate package, or one whose name is built from a non-literal expression — both are edge cases outside what static analysis can reach.

Running it

Install the standalone binary:

go install github.com/cinar/checker/v2/checkerlint/cmd/checkerlint@latest
Enter fullscreen mode Exit fullscreen mode

Run it like go vet — same flags, same package patterns:

checkerlint ./...
Enter fullscreen mode Exit fullscreen mode

Or plug it into go vet directly as a vet tool:

go vet -vettool=$(which checkerlint) ./...
Enter fullscreen mode Exit fullscreen mode

Wiring it into golangci-lint

If your CI already runs golangci-lint, add checkerlint as a module plugin (golangci-lint v2's plugin system) instead of a separate step:

# .golangci.yml
version: "2"
linters:
  settings:
    custom:
      checkerlint:
        type: module
        path: github.com/cinar/checker/v2/checkerlint
        settings: {}
Enter fullscreen mode Exit fullscreen mode

Module-plugin build steps have shifted across golangci-lint releases, so check its module-plugin docs for the exact setup your version expects — the .golangci.yml shape above is the stable part.

Why this matters more than it sounds like

Every struct-tag-driven validation library has this exact blind spot — go-playground/validator, ozzo-validation, all of them. The tags are strings; the compiler can't see inside them. Most teams find out about a typo'd checker name or a stale cross-field reference the way you'd expect: a bug report, or a support ticket about a validation rule that "just doesn't work," usually for a code path that isn't covered by a test with that exact malformed input.

checkerlint moves that failure from "someone hits it in production" to "the linter fails your PR." That's a category of bug — silently wrong or panicking struct tags — that the rest of the Go toolchain has no way to see, closed at the point where it's cheapest to fix.

Try it

go install github.com/cinar/checker/v2/checkerlint/cmd/checkerlint@latest
checkerlint ./...
Enter fullscreen mode Exit fullscreen mode

Full details, including exactly which checkers are type-checked, are in the checkerlint README. It's a separate, independently versioned module — adding it to your CI doesn't add a single dependency to the core checker library your code actually imports.

Top comments (0)