kanna-validate generates a Validate method from the validate tags of a struct — plain if statements, no
reflection — and reports whatever breaks under Rails' error codes, keyed by the fields' JSON names.
Part of the kanna family of code generators. Requires Go 1.27, for encoding/json/v2.
Install
go get -tool github.com/go-kanna/validate/cmd/kanna-validateThe generated code calls the small runtime at the root of this module, github.com/go-kanna/validate, which the line
above already requires.
Use
Mark a struct //kanna:validate and write its rules as you would for go-playground/validator:
package user
//go:generate go tool kanna-validate
//kanna:validate
type CreateUserRequest struct {
Name string `json:"name" validate:"required,max=50"`
Age int `json:"age" validate:"gte=18,lte=130"`
Role string `json:"role" validate:"required,oneof=admin member guest"`
Tags []string `json:"tags" validate:"max=5,dive,max=20"`
Address Address `json:"address" validate:"required"`
Password string `json:"password" validate:"required,min=12"`
Confirm string `json:"confirm" validate:"eqfield=Password"`
}
//kanna:validate
type Address struct {
Zip string `json:"zip" validate:"required,pattern=^\\d{3}-\\d{4}$"`
}go generate ./... writes validate_gen.go next to it:
// Code generated by kanna-validate. DO NOT EDIT.
func (x CreateUserRequest) Validate() error {
var errs validate.Errors
if x.Name == "" {
errs.Add("name", validate.Blank())
} else {
if utf8.RuneCountInString(x.Name) > 50 {
errs.Add("name", validate.TooLong(50))
}
}
if x.Age < 18 {
errs.Add("age", validate.GreaterThanOrEqualTo(18))
}
// ...
for i := range x.Tags {
if utf8.RuneCountInString(x.Tags[i]) > 20 {
errs.Add(validate.Index("tags", i), validate.TooLong(20))
}
}
if x.Address == (Address{}) {
errs.Add("address", validate.Blank())
} else {
errs.Nest("address", x.Address.Validate())
}
// ...
return errs.Err()
}Every rule is an if you can read and stop at in a debugger, and changing a tag changes the generated code, so a change
of rules shows up in review.
A rule that does not exist or does not fit its field stops generation, rather than the first request that meets it:
$ go generate ./...
./request.go:5:2: error: unknown rule "requried" (field Name)
did you mean "required"?
./request.go:6:2: error: rule "email" cannot apply to int (field Age)
./request.go:7:2: error: rule "max": 300 does not fit uint8 (field Level)
./request.go:8:2: error: invalid pattern: error parsing regexp: missing closing ]: `[0-9` (field Zip)
./request.go:10:2: error: eqfield=Pasword: no such field (field Confirm)
did you mean "Password"?A misspelled directive, which would otherwise leave the struct without a Validate and say nothing, draws a warning:
./request.go:3:1: warning: "//kanna:validte" is not recognized as a directive
did you mean "//kanna:validate"?Rules
The tags are go-playground/validator's; the codes and params are those of Rails' ActiveModel::Validations.
| Tag | Applies to | Code | Params |
|---|---|---|---|
required |
anything with a zero value | blank |
|
min, max, len
|
strings (in characters), slices, maps |
too_short, too_long, wrong_length
|
count |
min, max
|
numbers |
greater_than_or_equal_to, less_than_or_equal_to
|
count |
gt, gte, lt, lte, eq, ne
|
numbers |
greater_than, greater_than_or_equal_to, less_than, ..., other_than
|
count |
oneof=a b c |
strings, integers | inclusion |
|
eqfield=Field |
a field of the same type | confirmation |
attribute: the other's JSON name |
pattern=regexp |
strings | invalid |
|
email, url, uuid
|
strings |
email, url, uuid, from go-kanna/value
|
|
rule=Type, rule=pkg.Type
|
what Type holds or validates |
the rule's own | the rule's own |
omitempty |
anything with a zero value | lets the zero value past the rules after it | |
dive |
slices, arrays, maps | applies the rules after it to each element, or each value of a map |
Where it differs from go-playground:
- Every rule a field breaks is reported, not just the first. A value that fails
requiredis the exception: it is reportedblankalone, since anything else it breaks only says again that it is missing. -
requiredmeans "not the zero value":"",0,false,nil, orIsZero()for a type that has the method. An empty slice that is not nil is there; bound its length withmin=1. A 0 sent is the 0 never sent, so where a 0, or a value type whose rule accepts its zero, is a value of its own, take a pointer:requiredthen reports only nil. - A nil pointer skips every rule but
required: a*stringtaggedmax=20accepts nil, one taggedrequired,max=20does not. - A floating-point NaN breaks every comparison it is held to:
min=0andmax=100both report it. The generated code negates the requirement,!(x >= 0), asx < 0would let NaN through. -
pattern=takes the rest of the tag, commas included, so it comes last. The tag value is a Go string literal, so a backslash is written\\; a tag that does not unquote stops generation instead of being skipped in silence. -
omitemptycomes first among the rules of its level.
rule=Type applies the rule of Type to the field's value, or to each element after dive. Type is a value type of
kanna-value, known by the shape kanna-value gives it, whose constructor NewType(T) (Type, error)
holds a value to its rule; an enum of kanna-value, whose constructor NewType(string) (Type, error) accepts only its
constants; or a type with a method Validate(T) error, called on its zero value. A bare Type is one of the struct's package. pkg.Type names
the package by its name, among the packages of your module and those the struct's package imports, directly or not;
a/pkg.Type, with as much of the import path as it takes, tells two packages of one name apart. The struct's file need
not import it, since the generated file does. The generated code records at the field what the constructor or
Validate returns, with the code and params the rule gave it.
Nesting and hooks
A field whose type has Validate() error — another //kanna:validate struct, a type validating itself by hand, an
interface — is validated with it, and its errors are nested under the field: address.zip, items[2].sku, or
labels.primary for a map. Slices, arrays, and maps of such types are validated element by element without a dive.
An embedded struct without a JSON name keeps its errors at the level of the struct embedding it, as encoding/json
flattens its fields.
A rule no tag can express, such as one spanning two fields, goes in a validate method, which the generated Validate
calls after every tag has been checked:
func (x CreateUserRequest) validate(errs *validate.Errors) {
if x.Role == "admin" && x.Age < 21 {
errs.Add("age", validate.Fail("too_young_for_admin"))
}
}An error concerning the struct as a whole goes under validate.Base, "base", as in Rails.
Errors
Validate returns validate.Errors, or nil. It encodes as Rails encodes errors.details, with the fields in the order
they first broke:
{
"name": [{"error": "blank"}],
"tags[1]": [{"error": "too_long", "count": 20}],
"confirm": [{"error": "confirmation", "attribute": "password"}]
}A field is keyed by the name its json tag gives it, or by its Go name without one, whatever the transport. Behind a gRPC
handler, a use case input without json tags reports TodoID; tag the field json:"id" to report the name the proto
declares. A message that protoc-gen-go generates carries json tags with the proto's field names already.
The rejected value is never part of an error: errors end up in logs, and the value may be personal data.
Decoding requests
validate.DecodeJSON decodes a request body, validates it, and reports everything wrong with it in one go:
var req user.CreateUserRequest
if err := validate.DecodeJSON(r.Body, &req); err != nil {
if errs, ok := errors.AsType[validate.Errors](err); ok {
writeJSON(w, http.StatusUnprocessableEntity, map[string]any{"errors": errs})
return
}
writeError(w, http.StatusBadRequest, err) // JSON that cannot be read at all
return
}A field whose type rejects its value while decoding — a value type of kanna-value, say — does not stop the decoding:
the rejection is reported at the field's path, alongside whatever the tags find in the rest. json.Unmarshal cannot do
this; it stops at the first rejection, and does not say which field it came from. DecodeJSON decodes through
encoding/json/v2, which matches names case-sensitively and rejects duplicate names.
With kanna-value
kanna-value generates value types that hold a value to its rule wherever it goes:
when it is built, decoded, or scanned from a database. The email, url, and uuid tags call the same rules, from its
package rule, and report the same codes, so a format checked by a tag and one held in a type read alike in an API
response.
//kanna:value
type Email struct{ s string }
func (Email) validate(s string) error { return rule.Email(s) }
//kanna:validate
type SignUp struct {
Email Email `json:"email" validate:"required"` // the type holds the format; the tag, that it is there
Website string `json:"website" validate:"omitempty,url"` // the tag checks the format of a plain string
}An enum of kanna-value, a string type whose generated UnmarshalText accepts only its constants, needs no oneof
listing them again, and reports the same code, inclusion:
//kanna:validate
type ListOrders struct {
Status *model.OrderStatus `json:"status"` // nil when omitted; "shiped" is reported as inclusion
}rule= applies the rule of a value type to a plain field, through its constructor. A request built from something
other than JSON, such as a gRPC message, can take a *time.Time and check it as model.CompletedAt would:
//kanna:validate
type UpdateTask struct {
CompletedAt *time.Time `json:"completed_at" validate:"rule=model.CompletedAt"` // nil skips it
} if x.CompletedAt != nil {
if _, err := model.NewCompletedAt(*x.CompletedAt); err != nil {
errs.Add("completed_at", err)
}
}It checks a plain string against the constants of an enum the same way, as rule=model.OrderStatus.
go-kanna/value is a module of its own: go get github.com/go-kanna/value when you use these tags, or
go get -tool github.com/go-kanna/value/cmd/kanna-value for its types. A generated file that needs it while your go.mod
does not require it gets a warning rather than a build that fails later:
./validate_gen.go: warning: imports github.com/go-kanna/value/rule, which go.mod does not require; run go mod tidyLinting
wrapcheck reports return errs.Err() in a hand-written Validate as an unwrapped error from another package. Err
hands back what was collected, with nothing underneath to wrap, so add it to the signatures wrapcheck ignores:
linters:
settings:
wrapcheck:
extra-ignore-sigs:
- validate.Errors).Err(Flags
kanna-validate [flags] [packages]| Flag | Meaning |
|---|---|
-tags <list> |
comma-separated build tags |
-check |
verify the output is up to date instead of writing it, including that no output is left over |
-v |
list the files written, removed, or left unchanged |
-version |
print the version |
The packages default to the one in the current directory, so //go:generate go tool kanna-validate needs no argument.
A run that succeeds prints nothing but its warnings. One of them is about the run itself: when no struct in its packages
is marked //kanna:validate, it succeeds, since a //go:generate line left behind does no harm, but says so, since the
line was most likely meant to go as well:
$ go generate ./...
warning: no struct is marked //kanna:validate in example.com/app/userCode may call Validate before the first run has written it. kanna-validate type-checks the packages it loads once more
with what it is about to write in place, and writes nothing while their code does not compile with it: a mistake the
missing method hid is reported then, such as passing what Validate returns where it does not fit.
A package whose structs have all lost //kanna:validate has its validate_gen.go removed, unless code in the packages
the run loads still calls their Validate; a file of that name without the generated-code header is never touched. The
go generate that removes it ends with open ...: no such file or directory, from go generate itself, which lists a
package's files before it runs the generators; the file is gone as it should be
(golang/go#36068).
Example
example decodes sign-up requests the way a handler would — a valid one, one with everything wrong at once,
an empty one, one the hook rejects, and malformed JSON — and prints the response each gets. CI regenerates it and fails
if the output would change.
Performance
bench validates a request like the one in Use with the generated Validate and with
go-playground/validator, both reading the same tags; len=8 stands in for pattern=, which go-playground does not
have. go-playground is set up to report what kanna-validate does — a required struct must not be its zero value, and a
field goes by its JSON name — and a test fails unless the two find the same fields broken.
| Request | Validator | ns/op | B/op | allocs/op |
|---|---|---|---|---|
| valid | kanna-validate | 18 | 0 | 0 |
| valid | go-playground/validator v10.30.5 | 539 | 432 | 18 |
| seven fields broken | kanna-validate | 231 | 1032 | 16 |
| seven fields broken | go-playground/validator v10.30.5 | 989 | 2210 | 51 |
The median of 10 runs with Go 1.27.3 on an Apple M5 Pro. The generated Validate checks a valid request about 30 times
as fast as go-playground, and a broken one about 4 times as fast with 53% fewer bytes and 69% fewer allocations. A
valid request costs the ifs and allocates nothing; a broken one costs the errors it reports, each built with its code
and params.
make bench # run it once
make bench BENCHFLAGS=-count=10 # often enough for benchstatDevelopment
make test # unit tests
make lint # golangci-lint
make example # regenerate the example, then build and run it
make bench # regenerate the benchmark, then run it