Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,32 @@ jobs:
- name: Run task
run: go run github.com/go-task/task/v3/cmd/task@v3.38.0

checkergen:
runs-on: ubuntu-latest
defaults:
run:
working-directory: checkergen
steps:
- uses: actions/checkout@v4

- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: ./checkergen/go.mod

- name: Setup cache
uses: actions/cache@v4
with:
path: |
~/.cache/go-build
~/go/pkg/mod
key: ${{ runner.os }}-go-checkergen-${{ hashFiles('checkergen/go.sum') }}
restore-keys: |
${{ runner.os }}-go-checkergen-

- name: Run task
run: go run github.com/go-task/task/v3/cmd/task@v3.38.0

cli:
runs-on: ubuntu-latest
defaults:
Expand Down
35 changes: 26 additions & 9 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
Checker is a zero-dependency Go library (`github.com/cinar/checker/v2`) for validating and normalizing user
input, driven by struct tags (`checkers:"..."`) or plain function calls. It ships 23 translated locales, JSON
Schema generation from checker tags, separately-versioned `gin`/`echo`/`nethttp`/`fiber` framework adapter
modules, an opt-in `nfkc` checker module, a `checkerlint` static analyzer, and a standalone `cli`
command-line interface.
modules, an opt-in `nfkc` checker module, a `checkerlint` static analyzer, a `checkergen` reflection-free
code generator, and a standalone `cli` command-line interface.

## Repository layout

Expand Down Expand Up @@ -36,23 +36,40 @@ This is a multi-module repo:
it. Its own module, not part of the core, because NFKC needs `golang.org/x/text/unicode/norm`; keeping it
isolated keeps the core module's zero-dependency promise intact for callers who don't opt in. Same
`replace ../` pattern as the other adapters.
- `checkergen/` — `github.com/cinar/checker/v2/checkergen`, generates a `Check<Type>(v *Type)
(checker.CheckErrors, bool)` function per eligible struct, calling the same checker/normalizer plain
functions (`checker.IsEmail`, `checker.MinLen[string](8)`, ...) directly via `checker.Check` instead of
walking the struct with `reflect` at runtime — coexists with `CheckStruct`, not a replacement for it. Same
`replace ../` pattern as `checkerlint`, and needs the same newer Go toolchain for `golang.org/x/tools`
(`go/packages`, for type info). Its `testdata/fixture/` and `testdata/ineligible/` hold committed,
regenerated-by-test `checkergen_generated.go` fixtures (see `generate_test.go`'s
`TestGenerateMatchesCommittedFixture` for the drift check, and `differential_test.go` for the
behavioral-parity-with-`CheckStruct` check) — regenerate them with
`go run ./cmd/checkergen ./testdata/<dir>` after a `callSpecs` change, same as any other generated-and-committed
code. A test that calls `Generate` with anything other than a plain unfiltered rerun (e.g. a `-type` filter)
must target a scratch copy (`copyToScratch` in `generate_test.go`), never a real `testdata` package directly,
since `Generate` overwrites that package's committed, shared output file as a side effect.
- `locales/` — the `locales` package, one file per locale (e.g. `en_us.go`) plus `locales.go` and `locales_test.go`.

Each module has its own `taskfile.yml` and is built/linted/tested independently (see `.github/workflows/ci.yml`,
which runs parallel jobs per module: root, `gin/`, `echo/`, `nethttp/`, `fiber/`, `nfkc/`, `checkerlint/`, `cli/`).
which runs parallel jobs per module: root, `gin/`, `echo/`, `nethttp/`, `fiber/`, `nfkc/`, `checkerlint/`,
`checkergen/`, `cli/`).

## Commands

All commands use [Task](https://taskfile.dev) (`go run github.com/go-task/task/v3/cmd/task@v3.38.0`), run from
the module's own directory (root, `gin/`, `echo/`, `nethttp/`, `fiber/`, `nfkc/`, `checkerlint/`, or `cli/`):
the module's own directory (root, `gin/`, `echo/`, `nethttp/`, `fiber/`, `nfkc/`, `checkerlint/`, `checkergen/`,
or `cli/`):

- `task` (default) — runs `fmt`, `lint`, then `test`, in that order.
- `task fmt` — `go fix ./...`
- `task lint` — `go vet`, `gosec` (excludes `gin`/`echo`/`examples`/`checkerlint`/`cli`/`nethttp`/`fiber`/`nfkc`
dirs when run from root — `gosec` doesn't respect Go module boundaries or the `testdata` convention the way
`go build`/`go vet`/`go test` do, so any subdirectory with its own `go.mod`, or a `testdata` tree with fixtures
that don't type-check against the root module, needs an explicit `-exclude-dir`), `staticcheck`, and `revive`
(config in `revive.toml`).
- `task lint` — `go vet`, `gosec` (excludes
`gin`/`echo`/`examples`/`checkerlint`/`cli`/`nethttp`/`fiber`/`nfkc`/`checkergen` dirs when run from root —
`gosec` doesn't respect Go module boundaries or the `testdata` convention the way `go build`/`go vet`/`go test`
do, so any subdirectory with its own `go.mod`, or a `testdata` tree with fixtures that don't type-check
against the root module, needs an explicit `-exclude-dir` — **a new module here needs this list updated in
both the root `taskfile.yml` and this file, or the root `build` CI job breaks**, as happened after the
`nethttp`/`fiber` modules landed), `staticcheck`, and `revive` (config in `revive.toml`).
- `task test` — `go test -cover -coverprofile=coverage.out ./...` then prints per-func coverage.

Equivalent plain `go` commands work too, e.g.:
Expand Down
32 changes: 32 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
- **Trojan Source & Unicode normalization** — the built-in `strip-invisible` normalizer strips zero-width/bidi spoofing characters, and the opt-in `nfkc` module folds compatibility characters (fullwidth digits, ligatures, ...) into their canonical form.
- **Context-aware pipelines** — `Pipeline[T]` reuses the same checkers for domain rules that need a `context.Context` (a DB lookup, a tenant claim) struct tags can't carry.
- **Static analysis** — the separate `checkerlint` module catches unknown checker names, type mismatches, and dangling cross-field targets at build time, not runtime.
- **Code generation** — the separate `checkergen` module turns `checkers` tags into reflection-free Go validation code, ~3x faster with far fewer allocations, for structs on a hot path.
- **Command-line interface** — the separate `checker` binary runs any checker or normalizer from a shell script, CI pipeline, or Git hook, with no Go code and no runtime dependency of its own.

## Table of Contents
Expand All @@ -58,6 +59,7 @@
- [Framework Integration](#framework-integration)
- [Unicode Normalization (NFKC)](#unicode-normalization-nfkc)
- [Static Analysis](#static-analysis)
- [Code Generation](#code-generation)
- [Command-Line Interface](#command-line-interface)
- [Performance](#performance)
- [Changelog](#changelog)
Expand Down Expand Up @@ -855,6 +857,36 @@ checkerlint ./...

See [checkerlint/README.md](checkerlint/README.md) for `go vet -vettool` and `golangci-lint` module-plugin integration.

## Code Generation

`CheckStruct` walks a struct with `reflect` on every call (a cached execution plan keeps this fast — see [Performance](#performance) below — but it's still reflection). [`checkergen`](checkergen/), a separate module, instead generates a `Check<Type>` function that calls the same checkers directly, with no reflection at runtime at all:

```bash
go get github.com/cinar/checker/v2/checkergen
```

```golang
//go:generate go run github.com/cinar/checker/v2/checkergen/cmd/checkergen

type SignupRequest struct {
Email string `json:"email" checkers:"trim lower required email"`
Password string `json:"password" checkers:"required min-len:8"`
}
```

```bash
go generate ./...
```

Benchmarked against the equivalent `CheckStruct` call, on the same struct and 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** |

`checkergen` and `CheckStruct` are meant to coexist — generate code for the structs on your hot paths, leave everything else on `CheckStruct`. A struct with a field outside `checkergen`'s scope (a nested struct, a slice/map field, or a named type like `type Email string`, for now) is skipped with a clear reason instead of generated incorrectly. See [checkergen/README.md](checkergen/README.md) for the full scope, the exact benchmark setup, and how to reproduce the numbers above.

## Command-Line Interface

[`checker`](cli/), a separate module, is a standalone command-line interface to the library, for running any checker or normalizer from a shell script, CI pipeline, or Git hook without writing Go code. It ships as a single, dependency-free static binary.
Expand Down
129 changes: 129 additions & 0 deletions articles/reflection-free-go-validation-with-checkergen.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
---
title: 3x Faster Struct Validation in Go — Same Rules, No Reflection
published: true
description: 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.
tags: go, golang, performance, webdev
canonical_url: https://dev.to/onurcinar/3x-faster-struct-validation-in-go-same-rules-no-reflection-43hf
cover_image:
---

[Checker](https://github.com/cinar/checker) validates a Go struct from its tags:

```go
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`](https://github.com/cinar/checker/tree/main/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
//go:generate go run github.com/cinar/checker/v2/checkergen/cmd/checkergen
```

```go
// 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](https://github.com/cinar/checker/tree/main/gin), [Echo](https://github.com/cinar/checker/tree/main/echo), [Fiber](https://github.com/cinar/checker/tree/main/fiber), and plain [`net/http`](https://github.com/cinar/checker/tree/main/nethttp). 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:

```go
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:

```go
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 of `checker.CheckStruct(&req)`, write the failure with `c.JSONBlob(http.StatusBadRequest, data)`.
- **Fiber**: `c.Bind().Body(&req)` for decoding, same swap, write the failure with `c.Status(fiber.StatusBadRequest).Send(data)` after `c.Set("Content-Type", ...)`.
- **net/http**: `json.NewDecoder(r.Body).Decode(&req)` for decoding, same swap, write the failure with `w.WriteHeader(http.StatusBadRequest)` and `w.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

```bash
go get github.com/cinar/checker/v2/checkergen
```

```bash
go run github.com/cinar/checker/v2/checkergen/cmd/checkergen
```

Full scope and setup details are in the [checkergen README](https://github.com/cinar/checker/tree/main/checkergen). 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.
Loading
Loading