checkergen turns Checker's struct tags into plain Go validation code — same rules, ~3x faster and 4-8x fewer allocations than the reflection-based path, with a one-line swap into your existing Gin, Echo, Fiber, or net/http handlers.
Checker validates a Go struct from its tags:
type SignupRequest struct {
Email string `json:"email" checkers:"trim lower required email"`
Password string `json:"password" checkers:"required min-len:8"`
}
errs, ok := checker.CheckStruct(&req)
CheckStruct does this with reflect: walk the struct's fields, look up each tag token, run the resolved checker through a reflect.Value. It caches the resolved execution plan per struct type after the first call, so it's not doing that lookup work on every request — but every field access, every checker invocation, still goes through reflect.Value. On a hot API path validating thousands of requests a second, that adds up: extra allocations for boxing values, indirect calls the compiler can't inline, and CPU time spent in reflect internals that a plain function call doesn't pay.
checkergen removes that cost entirely, without changing a single validation rule.
What it generates
Every checker already has a plain, non-reflection Go function sitting next to the reflect-based one CheckStruct uses — IsEmail(value string), MinLen[T](n int) CheckFunc[T], and so on. checkergen reads a struct's tags once, at build time, and emits a function that calls those directly:
//go:generate go run github.com/cinar/checker/v2/checkergen/cmd/checkergen
// Code generated by checkergen. DO NOT EDIT.
func CheckSignupRequest(v *SignupRequest) (checker.CheckErrors, bool) {
errs := make(checker.CheckErrors)
{
newValue, err := checker.Check(v.Email, checker.TrimSpace, checker.Lower, checker.Required, checker.IsEmail)
v.Email = newValue
if err != nil {
errs["email"] = err
}
}
{
newValue, err := checker.Check(v.Password, checker.Required, checker.MinLen[string](8))
v.Password = newValue
if err != nil {
errs["password"] = err
}
}
return errs, len(errs) == 0
}
CheckSignupRequest returns the exact same checker.CheckErrors type as CheckStruct — same error codes, same {{ .field }}-templated locale messages, same .JSON() method for an API response. Nothing about the shape of your rules or your error handling changes. Only how validation runs changes: real function calls the compiler can see and inline, not a struct walked with reflect at runtime.
The numbers
Benchmarked against the CheckStruct call it replaces, on the same struct and the same input:
| Struct | CheckStruct |
Generated | Speedup |
|---|---|---|---|
| 5-field signup form | 1830 ns/op, 1304 B/op, 24 allocs/op | 562 ns/op, 168 B/op, 7 allocs/op | ~3.3x faster, ~8x less memory |
| 25-field mixed checker coverage | 8200 ns/op, 4928 B/op, 59 allocs/op | 2730 ns/op, 760 B/op, 9 allocs/op | ~3.0x faster, ~6.5x less memory |
That's not just lower latency — it's roughly a quarter of the garbage collector pressure per validated request, which matters more the higher your request rate climbs.
Using it with your framework
Checker already ships thin adapter modules for Gin, Echo, Fiber, and plain net/http. Each one's Bind does two things: decode the request body with the framework's own binder, then run checker.CheckStruct and write a 400 JSON response on failure. Swapping in generated code means keeping the framework's binder and swapping only that second step.
Gin, before:
router.POST("/signup", func(c *gin.Context) {
var req SignupRequest
if !checkergin.Bind(c, &req) {
return // 400 already written
}
c.JSON(http.StatusOK, req)
})
Gin, with generated validation:
router.POST("/signup", func(c *gin.Context) {
var req SignupRequest
if err := c.ShouldBind(&req); err != nil {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if errs, ok := CheckSignupRequest(&req); !ok {
data, _ := errs.JSON()
c.Abort()
c.Data(http.StatusBadRequest, "application/json; charset=utf-8", data)
return
}
c.JSON(http.StatusOK, req)
})
Same status codes, same error body shape, same gin.Context. The only line that changed is checker.CheckStruct(&req) becoming CheckSignupRequest(&req).
The same substitution drops into the other three exactly as directly — decode with the framework's own binder, then call the generated function instead of checker.CheckStruct:
-
Echo:
c.Bind(&req)for decoding,CheckSignupRequest(&req)in place ofchecker.CheckStruct(&req), write the failure withc.JSONBlob(http.StatusBadRequest, data). -
Fiber:
c.Bind().Body(&req)for decoding, same swap, write the failure withc.Status(fiber.StatusBadRequest).Send(data)afterc.Set("Content-Type", ...). -
net/http:
json.NewDecoder(r.Body).Decode(&req)for decoding, same swap, write the failure withw.WriteHeader(http.StatusBadRequest)andw.Write(data).
You don't have to convert a whole API at once, either. checkergen and CheckStruct are meant to coexist in the same codebase — generate code for the handful of structs on your busiest endpoints, and leave everything else exactly as it is on CheckStruct. A struct outside checkergen's current scope (a nested struct, a slice/map field, or a named type like type Email string) is skipped at generate time with a clear reason, not silently mishandled, so mixing the two in one project is the expected way to use it, not a fallback.
Try it
go get github.com/cinar/checker/v2/checkergen
go run github.com/cinar/checker/v2/checkergen/cmd/checkergen
Full scope and setup details are in the checkergen README. It's a separate, independently versioned module — generating code for one struct adds nothing to the dependency footprint of the core checker library the rest of your code already imports.
Top comments (0)