The standard validators for valgen — a
compile-time reimagining of validator. You tag your
structs with validate:"..." and a generator emits a static
func (v *T) Validate(ctx context.Context) error per struct: no reflection on the hot path, and
misspelled or type-mismatched tags become build errors instead of first-request panics.
This module bundles the baked-in validators (the tags below) and the default runtime
Violation error type (fully
swappable — see Custom error types). It ships a default generator you run without
installing anything, plus a library for building your own custom generator. The generator engine and plugin
contract live in github.com/go-playground/valgen.
package user
type User struct {
Name string `validate:"required,gt=2"` // present and > 2 runes
Email string `validate:"required,email"` // present and a valid address
Age int `validate:"gte=0,lte=130"` // 0..130 inclusive
Tags []string `validate:"dive,gt=1"` // each element > 1 rune
}Run the default generator from your module root:
go run github.com/go-playground/valgen-validations/cmd/valgen-validations@latestThat runs the standard validators with default config over the whole module in one pass, fetched and run
in isolation by version, so nothing is added to your go.mod (pin @vX.Y.Z for reproducibility). It
writes a valgen_gen.go next to your code with a func (v *User) Validate(ctx context.Context) error for
every tagged struct — and every struct it reaches, across packages. (Whole-module in one pass is why:
generating package-by-package is order-dependent and silently under-validates cross-package fields.)
The generated code imports the runtime module, so tidy it in once:
go mod tidyCommit the valgen_gen.go files, and regenerate whenever tags change. A misspelled or type-mismatched tag
is a build error at generation time — not a runtime surprise.
The default generator takes flags for the common non-code settings, so many projects never need a custom
main:
| Flag | Default | Purpose |
|---|---|---|
-dir |
. |
directory to load and generate in |
-patterns |
./... |
comma-separated package patterns (whole module; use . for a single package) |
-name-tag |
"" |
struct-tag key for error display names, e.g. json (empty = Go field names) |
-validate-tag |
validate |
struct-tag key holding the rules |
-out |
valgen_gen.go |
generated file name (one per package) |
-mode |
fail-fast |
per-field failure mode: fail-fast or collect-all |
-namespace |
false |
emit dotted error paths (User.Address.Street) |
-errors |
verbose |
baked-in error builder: verbose, simple, or minimal |
go run github.com/go-playground/valgen-validations/cmd/valgen-validations@latest -namespace -mode collect-allOnly code-level choices — your own validators, a custom error builder, a nullable/converter registration — need a custom generator (next section); flags never change which validators run.
Validate is read-only — it reads the struct and returns errors, never mutating the receiver (the
pointer receiver is for zero-copy efficiency, not mutation). Call it on an addressable value — a local
variable, a slice element, or &T (that costs nothing; Go passes the address, no copy, no heap alloc). A
non-addressable value, such as a map entry or a function's return value, must be assigned to a variable
first (a compile requirement of pointer receivers, not a runtime cost).
The generator is ordinary Go — a small main that feeds the validator set into the engine. Write one when you
want code-level control the default binary's flags can't give you: your own validators, a custom error
type, or a nullable/converter registration. (The common non-code settings — namespace, mode, tags, output,
and picking a baked-in error builder — are already flags on the default generator, above.) A custom main
generates the whole module by default — no .Patterns needed:
package main
import (
"log"
validations "github.com/go-playground/valgen-validations/gen"
"github.com/go-playground/valgen/gen"
)
func main() {
err := gen.New(validations.New().Generators()...).
Namespace(true). // e.g. opt into dotted error paths
Run() // generates the whole module by default
if err != nil {
log.Fatal(err)
}
}Run it from your module root with go run ./tools/valgen. validations.New() builds the standard set (chain
.Nullable(...), .ErrorBuilder(...), .Register(...)); .Generators() hands it to the engine's
gen.New(...), which you configure with .NameTag(), .Mode(), .Namespace(), .Add(), .Patterns()
(defaults to the whole module ./...; narrow only to scope), … and finish with .Run() (see the engine's
Configuration table). The set is EXPLICIT — no global
registry, no flags — so nothing a dependency's init() can inject a generator into your output.
A recommendation for teams. For a single repo, a tools/valgen main is fine. But for a large codebase
spread across multiple repos, or any shared customization, don't copy a main into every repo — build
one self-contained generator module (its own repo/go.mod) that bakes in your validators, error builder,
and config, and run it everywhere with go run your-org/valgen-gen@vX.Y.Z. One source of truth — and because
it's a separate module run by version, its build-time deps (golang.org/x/tools, the gen packages) stay out
of every app's go.mod; your apps only ever need the runtime valgen + valgen-validations the generated
code imports. (A generator kept as a cmd/… inside your own module works too, but its build-time deps then
land in that module's go.mod — fine, just not isolated.) See
example/tools/valgen/main.go,
and SECURITY.md for the trust model.
u := User{Name: "Al", Age: 200}
if err := u.Validate(context.Background()); err != nil {
// err is either a single *validations.Violation (one failure) or an
// errors.Join of them (several) — nil when valid.
// Match the first violation (Name is 2 runes, so gt=2 fails first):
var v *validations.Violation
if errors.As(err, &v) {
fmt.Println(v.Field.Name, v.Tag, v.Param) // "Name" "gt" "2"
}
// Or range over all of them — GUARD the multi-error assertion, since a lone
// failure is returned unwrapped:
if multi, ok := err.(interface{ Unwrap() []error }); ok {
for _, e := range multi.Unwrap() {
if errors.As(e, &v) {
fmt.Printf("%s failed %q\n", v.Field.Name, v.Tag)
}
}
} else {
fmt.Printf("%s failed %q\n", v.Field.Name, v.Tag)
}
}*validations.Violation is the default error type — the sane default the built-in error builder
produces; it is not the only option. It carries the failed Tag and its Param, the offending Value
(nil when the field was absent), reflect metadata (Field, Struct, InnerType), and a dotted
Namespace (populated only when namespacing is on — see below). It is one of three baked-in error types —
swap it for the reflect-free gen.SimpleErrorBuilder(), the plain-fmt.Errorf gen.MinimalErrorBuilder(),
or your own type via .ErrorBuilder(...) in your generator main — see
Custom error types. valgen never forces Violation on you.
Error-path namespacing is OFF by default. A dotted path like User.Address.Street costs a
context.WithValue allocation per nested Validate on the hot path, so it is opt-in: with a bare
gen.New(...), Violation.Namespace is "" and you identify the field via the
reflect Field/Struct metadata (and Field.Tag for a display name). Turn on full dotted paths by calling
.Namespace(true) in your generator main (step 3) — exactly what
example/tools/valgen
does. Runtime deps threaded through ctx propagate to nested Validate calls either way — namespacing only
adds the path string.
- The tag catalog below — every tag, with a runnable example per tag.
- Behavior notes — presence wrappers (
*T,Option[T],sql.Null*), cross-field@references, value-list combinators, numeric casting, fail-closed semantics. - Writing a custom or combined validator and Custom error types when you outgrow the standard set.
Rules are a comma-separated AND-sequence under the validate key. Each Example links to a
runnable godoc example.
| Tag | Description | Param | Field types | Example |
|---|---|---|---|---|
eq |
value must equal the param | eq=free |
any comparable | run |
ne |
value must not equal the param | ne=banned |
any comparable | run |
gt |
greater than | gt=17 |
numbers (value), strings (rune count), slice/array/map (len) | run |
gte |
greater than or equal | gte=0 |
same as gt |
run |
lt |
less than | lt=10 |
same as gt |
run |
lte |
less than or equal | lte=2 |
same as gt |
run |
min |
at least (v10 spelling of gte) |
min=18 |
same as gt |
run |
max |
at most | max=3 |
same as gt |
run |
len |
exact length/value | len=4 |
same as gt |
run |
eq_ci |
case-insensitive equality (_ci = case-insensitive) |
eq_ci=us |
string | run |
ne_ci |
case-insensitive inequality | ne_ci=admin |
string | run |
between |
within a range; start always inclusive, .. exclusive end / ..= inclusive. A bound may be a field: between=@Min..=@Max (value comparisons only) |
between=1..=10 |
same as gt |
run |
oneof |
value is one of a space-separated set (single-quote values with spaces). A member may be a field: oneof=@Allowed free |
oneof='in progress' done |
comparable basic (string/number/bool) | run |
noneof |
value must NOT be in the set (members may be @field references) |
noneof=red green |
comparable basic | run |
oneof_ci |
case-insensitive oneof |
oneof_ci=small large |
string | run |
noneof_ci |
case-insensitive noneof |
noneof_ci=foo bar |
string | run |
hexcolor |
#RGB/#RGBA/#RRGGBB/#RRGGBBAA hex color |
— | string | run |
rgb |
rgb(r,g,b) (0-255 or 0-255%) |
— | string | run |
rgba |
rgba(r,g,b,a) |
— | string | run |
hsl |
hsl(h,s%,l%) |
— | string | run |
hsla |
hsla(h,s%,l%,a) |
— | string | run |
cmyk |
cmyk(c%,m%,y%,k%) |
— | string | run |
color |
matches ANY color format (hexcolor/rgb/rgba/hsl/hsla/cmyk) | — | string | run |
required |
field must be present / non-zero | — | any (use a pointer/wrapper for structs) | run |
omitempty |
skip the rest of the chain when the field is empty | — | any | run |
omitzero |
alias of omitempty |
— | any | run |
omitnil |
skip the rest only when nil/None (present-zero still runs) | — | nullable (pointer/Option/sql.Null/slice/map) | run |
skip_if |
skip the rest of the chain while sibling conditions hold (conditional omitempty; asserts nothing) |
Kind eq legacy (flat &&/||) |
any (siblings same struct) | run |
dive |
apply the remaining rules to each element; the index/key is added to the path | — | slice/array/map | run |
keys |
inside a map dive, open the key-rule block (map only) |
— | map | run |
endkeys |
close the key block; rules after it validate the value | — | map | run |
unique |
no duplicate elements (map: values); unique=@<field> dedupes a struct slice by a field |
optional field | slice/array/map (comparable) | run |
required_if |
required while sibling conditions hold; otherwise treated like omitempty (skipped if absent, still validated if present) |
Country eq US (flat &&/||) |
any (siblings same struct) | run |
excluded_if |
must be empty while sibling conditions hold | Kind eq paid (flat &&/||) |
any (siblings same struct) | run |
isdefault |
field must equal its zero value | — | any | run |
valgen has no _with/_without/_unless tag family — the same semantics are expressed with the three
_if coordinators plus a presence test, so any / all is just the || / && you already use:
| v10 | valgen |
|---|---|
required_with=A |
required_if=@A required |
required_with=A B (any) |
required_if=@A required || @B required |
required_with_all=A B |
required_if=@A required && @B required |
required_without=A |
required_if=@A isdefault |
required_without_all=A B |
required_if=@A isdefault && @B isdefault |
excluded_with / excluded_without (+ _all) |
same shapes with excluded_if |
required_unless=A x |
required_if=@A ne x (negate the operator) |
skip_unless=A x |
skip_if=@A ne x (negate the operator) |
Runnable example of the presence conditions (and a quoted condition value).
-
Value-list combinators. A comparison/affix param may be a flat list joined by
||(OR) or&&(AND) —eq=free||pro,ne=root&&admin,contains=foo||bar(mixing the two is a build error).eq=A||Bandne=A&&Bemit the same zero-allocationswitchasoneof/noneof. run -
Field references always carry the sigil (
@field).@Siblingis a reference to another field; a bare token is always a literal. This one rule holds in every position — value tags (eq=@Password,gte=@Min,eq=@Password||guest), set members and range bounds (oneof=@Allowed free,between=@Min..=@Max), and both condition operands (required_if=@Country eq US,required_if=@Ceiling gt @Floor). A set containing a reference emits a comparison chain rather than the all-literal jump table, since Go requires constantswitchcases. A presence-wrapped reference is nil-guarded (in a value tag an absent one fails closed; in a condition it makes the condition false). run -
References may walk DOWNWARD into nested structs —
gte=@Limits.Min,eq=@Addr.Geo.Lat. Every hop is presence-unwrapped, so pointers,Option,sql.Null*and registered wrappers are all traversable, and each nullable layer is guarded in order before the next is dereferenced. Downward-only is deliberate: a parent knows it owns a child, but a child cannot know which parents contain it, so an upward reference has no stable meaning (this is why validator v10's*csfieldfamily has no equivalent here). A path may cross nullable layers but only ever lands on a struct — it cannot traverse a slice/array/map, since a container element has no single value to reference. run -
An absent reference fails a value check, but does NOT trigger a condition. The two contexts differ deliberately. In a value tag an absent (nil /
None/!Valid) reference fails closed —eq=@Ptr.ZipwithPtrnil is a violation, because an absent value cannot satisfy the comparison. In a condition it makes the condition false:required_ifdoes not require,excluded_ifdoes not exclude, andskip_ifdoes not skip (so validation still runs — never the silent direction). Consequence worth knowing:required_if=@Addr.Country eq USwithAddrnil leaves the field optional. To require it in that case too, say so explicitly with a presence test:// required when Addr is missing, OR when it is present and US Field string `validate:"required_if=@Addr isdefault || @Addr.Country eq US"`
-
emailis exactly Go's parser, restricted to an address. Grammar is delegated entirely to the standard library'snet/mail.ParseAddress(RFC 5322, extended by RFC 6532 for Unicode), so the tag accepts what the Go runtime accepts — no more, no less. Becausenet/mailparses message-header addresses,emailadditionally requires a bare addr-spec: no display name, and the parse must round-trip. That rejectsBarry <b@example.com>,<b@example.com>,b@example.com (Barry), RFC 2047 encoded-words and quoted local parts, while the strict grammar rejects leading/trailing/doubled dots in both the local part and the domain. It deliberately accepts what the RFC allows:a@b(no dot required),jdoe@[192.168.0.1](domain-literal), and bothmicro@µ.example.comandmicro@xn--bcher-kva.example— note those last two are different strings for the same mailbox, and the tag does not fold between them (IDNA normalization would need a dependency outside the standard library). It checks syntax only: no MX lookup, no mailbox existence, no deliverability. If you need different rules — a required dotted domain, ASCII-only, no domain-literals, a provider allow-list — write your own validator and add it to your generator set; see Writing a custom or combined validator. run -
Traversal is not a reference. Two different mechanisms, easy to confuse: traversal runs a nested struct's OWN rules automatically (no tag needed — see "Automatic struct traversal"), while a reference (
@Addr.Zip) reads a nested field to compare the current field against. Inside adive, a reference still resolves against the enclosing struct (sodive,gte=@Minworks); a dive cannot reference the element's own fields — those are covered by traversal. -
Condition grammar (
required_if/excluded_if/skip_if). Each condition is@Field op value(a comparison —@Country eq US) or@Field op(a presence test —@Coupon required,@Coupon isdefault). The target must be a field reference, so it always carries the sigil; the value may be a literal or another field (@Ceiling gt @Floor). Presence tests go through the same machinery asrequired/omitempty, so they work for pointers,Option,sql.Null*, registered wrappers, slices/maps, strings, bools and numbers. A condition holds only when every field it references is present. Conditions join with a flat&&/||(mixing the two is a build error), and unary and binary conditions may be freely interleaved:required_if=@Ref required && @Country eq US. -
Quoting condition/set values. Quote a value to include spaces or to mean empty:
required_if=@Note eq 'New York',required_if=@Note eq '',oneof='in progress' done. The delimiter is the run'sSyntax.Quote(default'). -
Escaping.
\makes the next character literal (\,\=\@\|\&\\), so a value may contain a separator; a bare@(or\@) is a literal@. In a Go struct tag, write\\for one\. Quoting covers spaces and empty values; escaping is still required for engine-level separators (,and=) even inside quotes, because the engine splits rules before a validator sees the value. -
Presence wrappers. Value checks auto-unwrap through
*T,optionext.Option[T](go-playground/pkg), and thedatabase/sqlNull*wrappers (including genericNull[T]) — sogt,between,oneof, etc. work on asql.NullInt64or*intunchanged. Add your own (guregu/null,samber/mo, …) withSet.Nullablein your generatormain— it then works across every validator, cross-field, andrequired/omitempty:set := validations.New().Nullable("github.com/guregu/null", "String", validations.Nullable{Present: "%s.Valid", Absent: "!%s.Valid", Read: "%s.String"}) gen.New(set.Generators()...).Run()
It is additive — the built-ins are always active. The inner type is derived from
Read(a%s.<field>field type or%s.<method>()return type). -
_ci= case-insensitive. Any validator that folds case uses a_cisuffix (eq_ci,ne_ci,oneof_ci,noneof_ci); these require a string. -
Dynamic (
any/interface) fields. Ainterface{}field has no concrete type at generation time, so a value tag on it is a build error — valgen validates concrete types and never reflects at runtime; model the data with a concrete type (or handle it in a custom generator). Presence tags (required,omitempty,omitnil,isdefault) still work as a nil check. A field of a named interface that declaresValidate(context.Context) erroris auto-traversed (nil-guarded). -
Fail-closed on absence. A value constraint on an absent wrapper (nil /
None/!Valid) fails — an absent value cannot satisfylt=10. Useomitempty/omitnilto opt out. -
Automatic struct traversal. Nested structs are validated automatically with no tag; iterating a slice/array/map requires an explicit
dive. A struct that already has its ownValidateis called, not regenerated. -
Fail-fast by default. A field's chain stops at the first failing rule (v10 parity); use
.Mode(gen.CollectAll)in your generatormainto report every failure per field. -
Gen-time param checks. Numeric params are range/sign-checked against the field's exact type (
gt=300on anint8is a build error), and unknown or misapplied tags fail generation with a positioned message — never at runtime. -
Numeric casting across a comparison is lossless-only. When a comparison mixes numeric types — a cross-field operand of a different type (
gte=@Minwhere@Minis not the field's type) or a registered validation'sKind— valgen inserts a Go conversion only when it is provably lossless; otherwise it is a positioned build error naming both types. Lossless: widening within the same signedness (int8→int64,uint16→uint64),float32→float64, and a small integer that fits a float exactly (int16→float64). NOT lossless (→ build error):float→int, wider→narrower, signed↔unsigned, and any number↔string/bool;int/uintare treated platform-conservatively (they may be 32-bit). To allow a lossy or foreign conversion deliberately, register a converter withSet.Convert— a failed conversion then counts as a failed validation.
All string-only; all implemented with hand-written scans or stdlib builtins (no regexp). Each has a
runnable godoc example (one per
family) and exhaustive valid/invalid coverage in the runtime tests.
Substring / affix (param = the substring/prefix/suffix; containsrune/excludesrune take one rune):
| Tag | Checks |
|---|---|
contains / excludes |
value does / does not contain the substring |
containsany / excludesall |
value does / does not contain any of the runes |
containsrune / excludesrune |
value does / does not contain the rune |
startswith / startsnotwith |
has / doesn't have the prefix |
endswith / endsnotwith |
has / doesn't have the suffix |
Character class / number (each requires non-empty, except ascii/printascii):
| Tag | Checks | Tag | Checks |
|---|---|---|---|
alpha |
ASCII letters | digits |
ASCII digits [0-9]+ |
alphanum |
letters + digits | numeric |
decimal number, incl. sign/fraction/exponent (-3.5, 1.5e10) |
alphaspace |
letters + spaces | boolean |
strconv.ParseBool value |
alphanumspace |
letters + digits + spaces | hexadecimal |
hex, optional 0x |
alphaunicode |
Unicode letters | lowercase |
no upper/title-case runes |
alphanumunicode |
Unicode letters + numbers | uppercase |
no lower/title-case runes |
ascii |
all ASCII | multibyte |
contains a non-ASCII byte |
printascii |
printable ASCII |
Hashes (exactly N lowercase hex chars) & checksum: md4 md5 ripemd128 tiger128 (32) · ripemd160
tiger160 (40) · tiger192 (48) · sha256 (64) · sha384 (96) · sha512 (128) · luhn_checksum (Luhn mod-10).
Encoding: base64 base64url base64rawurl base32 (stdlib decode) · json (json.Valid).
Network (via net/netip / net; pure parses, no name resolution): ip ipv4 ipv6 cidr cidrv4
cidrv6 mac port.
Date / geo: datetime=<Go layout> (e.g. datetime=2006-01-02) · latitude (−90..90) · longitude
(−180..180) · timezone (IANA name).
Identifiers (RFC-precise; UUID version = RFC 4122 version+variant, ISBN/ISSN verify the check digit):
| Tag | Checks |
|---|---|
uuid / uuid3 / uuid4 / uuid5 / uuid6 / uuid7 / uuid8 |
any / version-3/4/5 (RFC 4122) / version-6/7/8 (RFC 9562) UUID, case-insensitive |
ulid |
26-char Crockford base32 ULID |
semver |
Semantic Versioning 2.0.0 |
e164 |
E.164 phone number |
cve |
CVE identifier (CVE-YYYY-NNNN…) |
bic |
ISO 9362 BIC (8 or 11) |
isbn / isbn10 / isbn13 |
ISBN with check digit (isbn = 10 or 13) |
issn |
ISSN with check digit |
ssn |
US SSN AAA-GG-SSSS (also AAA GG SSSS / 9 digits), excluding the ranges the SSA never assigns: area 000/666/900-999, group 00, serial 0000 |
email |
a bare email address — exactly what Go's net/mail accepts (RFC 5322 + RFC 6532), restricted to an addr-spec. See the note below for what that includes |
The simplest way to add — or override — a leaf value validation is Set.Register: you supply a Go
function that returns error and a small Validation describing how to call it. valgen's own generators,
presence unwrapping, @Field resolution, error building and imports stay internal; your function runs through
the same generation path the built-ins do.
// your runtime package (github.com/acme/valgen-acme):
func Even(n int64) error {
if n%2 != 0 {
return errors.New("must be even")
}
return nil
}import (
"reflect"
validations "github.com/go-playground/valgen-validations/gen"
"github.com/go-playground/valgen/gen"
)
func main() {
set := validations.New().
Register(validations.Validation{
Tag: "even",
Kind: reflect.Int64,
Func: "acme.Even(%s)",
Imports: []string{"github.com/acme/valgen-acme"},
})
if err := gen.New(set.Generators()...).Run(); err != nil {
log.Fatal(err)
}
}A field tagged validate:"even" now emits if err := acme.Even(int64(v.N)); err != nil { … } — the field is
losslessly widened to the func's int64, and the returned error is forwarded to the error builder as the
failure cause.
Func is a package-qualified call whose arguments are a template. Placeholders (all coerced to Kind
except %c/%p; % is literal so %s % 2 is fine):
| Placeholder | Meaning | Example (tag → emitted) |
|---|---|---|
%s |
the field value (LHS) — the ordering anchor | acme.Even(%s) → acme.Even(int64(v.N)) |
%c |
the method's ctx context.Context — optional; add it only for a stateful/dependency-backed validator (see below) |
acme.CheckUnique(%c, %s) → acme.CheckUnique(ctx, v.X) |
%1 … %N |
positional operands, left-to-right (literal or @Field) |
btw=@Min @Max + acme.Between(%s, %1, %2) → acme.Between(v.X, v.Min, v.Max) |
%v |
all operands spread as variadic args (arbitrary count) | in=a||b||c + acme.OneOf(%s, %v) → acme.OneOf(v.X, "a", "b", "c") |
%p |
the raw tag =param, quoted (funcs that parse it themselves) |
datetime=2006-01-02 + acme.DateTime(%s, %p) |
| literals | Go constants in the template | acme.InRange(%s, 0, 100) |
The three call-shapes follow from the template: positional (%1..%N, one call), variadic spread
(%v, one call), and per-operand combine (a single %1 with a ||/&& value-list — the func is called
per operand and the results joined with the operator). %s is always the field (LHS); %1..%N follow the
tag's left-to-right order.
Your Func must return error (nil = pass). It is validated at generator startup — a bad template, a
non-canonical Kind, or an invalid import path panics immediately.
%c is optional: most validators (and any existing/stdlib function you point Func at) omit it. Runtime
state reaches a validator through exactly one of two doors:
-
Per-request dependencies →
ctx(%c). For a DB connection, the request's tenant/user, an i18n translator, or feature flags, add%cand read the dependency out ofctx:// func CheckUnique(ctx context.Context, s string) error { // db := DBFrom(ctx) // your key // if db.Exists(ctx, s) { return errors.New("already taken") } // return nil // } set.Register(validations.Validation{Tag: "unique_email", Kind: reflect.String, Func: "acme.CheckUnique(%c, %s)", Imports: []string{"github.com/acme/valgen-acme"}})
The caller injects the dependency once, at the call site, and it auto-threads into nested struct validations:
ctx := context.WithValue(r.Context(), acme.DBKey, conn) err := user.Validate(ctx)
A ctx-backed validator that does I/O runs on the hot path and is not zero-alloc — a deliberate choice.
-
Static / load-once state → a package global. For a dataset, a compiled pattern, or one long-lived shared client, keep it in a global in your package (
init()/sync.Once/ an exportedSetup()); the func reaches it directly and takes noctx. Nothing special in theFunctemplate — codegen never sees the state.var countries = mustLoadCountrySet() // package global, built once func InCountrySet(s string) error { /* consult countries */ }
WHY ctx rather than something the generator injects: the generator runs at build time and can neither capture
a runtime object nor widen the fixed Validate(ctx) signature, so ctx is the only channel for per-request
state. %c is optional so simple validators — and existing/stdlib functions — register directly, with no
wrapper and no unused parameter.
A field may be a type you can't tag but can read. Register a converter that coerces it to a canonical kind, and the leaf runs on the converted value:
// foreign type you don't own: mail.Email (github.com/vendor/mail), with e.Address() string
// your wrapper: func EmailToString(e mail.Email) (string, error) { return e.Address(), nil }
set := validations.New().
Register(validations.Validation{Tag: "email", Kind: reflect.String,
Func: "acme.CorpEmail(%s)", Imports: []string{"github.com/acme/valgen-acme"}}).
Convert(validations.Converter{
FromPkg: "github.com/vendor/mail", FromType: "Email", // the gen-time LOOKUP KEY: which field type triggers this
Kind: reflect.String,
Func: "acme.EmailToString(%s)", // no %c needed — a plain func (or stdlib) works directly
Imports: []string{"github.com/acme/valgen-acme"},
})FromPkg/FromType is the type-trigger: when a field's (presence-unwrapped) type is mail.Email, the
converter runs first. A field Contact mail.Email tagged validate:"email" emits:
_cv, _cerr := acme.EmailToString(v.Contact)
if _cerr != nil {
// conversion failure IS a validation failure (Cause: _cerr, Converted: true)
} else if err := acme.CorpEmail(_cv); err != nil {
// Cause: err
}(A converter that needs a per-request dep adds %c — Func:"acme.Lookup(%c, %s)" — exactly like a
validation; see the ctx section above. Omitting it lets you point at an existing/stdlib function directly.)
A converter Func returns (value, error); a failed conversion is treated as a failed validation. This is
opt-in and at your own risk. Without a converter, valgen automatically coerces only when the cast is
provably lossless (e.g. int32→int64); otherwise it is a positioned build error that names the type
and points you at Set.Convert.
Register adds leaf value validations only, and may override a built-in leaf (email above). It rejects
the grammar/flow/operator tags — dive, keys, required, omitempty, skip_if, gt/lt/eq/…,
between, oneof, contains, required_if, … — which are the language, not leaf checks. For those, or for
anything needing full control, drop to a plugin.TagGenerator (next section).
valgen has no alias mini-language: a combined or named validation is just a TagGenerator — the same
interface every built-in uses. It emits inline Go under its own tag, so it keeps a distinct identity for error
messages and translation. You add it to the explicit generator set your main passes to the engine (no
global registry).
For a combined check — say a password tag a value passes when its length is 8–64 — write a generator that
emits the check and appends your error under the "password" tag:
package myvalidators
import (
"fmt"
"github.com/go-playground/valgen/plugin"
)
type passwordGen struct{}
func (passwordGen) Tag() string { return "password" }
func (passwordGen) Generate(ctx *plugin.Context) error {
x := ctx.Field.ExprPath // the value expression, e.g. "v.Password"
u := ctx.Import("unicode/utf8")
ep := ctx.Import("github.com/acme/apperr")
ctx.Linef("if n := %s.RuneCountInString(%s); n < 8 || n > 64 {", u, x)
ctx.Fail(fmt.Sprintf("&%s.Invalid{Path: %s, Rule: %q}", ep, ctx.Path(), "password"))
ctx.Line("}")
return ctx.EmitRest()
}
// PasswordGen is exported so your generator main can add it to the explicit set.
type PasswordGen = passwordGenA union of formats (v10's iscolor-style "any of these") has the same shape — the runtime scanners are
ValidateXxx(s) error (nil = pass), so a union fails only when every member fails:
vp := ctx.Import("github.com/go-playground/valgen-validations")
ctx.Linef("if %[1]s.ValidateHexColor(%[2]s) != nil && %[1]s.ValidateRGB(%[2]s) != nil && %[1]s.ValidateRGBA(%[2]s) != nil {", vp, x)
ctx.Fail(fmt.Sprintf("&%s.Invalid{Path: %s, Rule: %q}", ep, ctx.Path(), "color"))
ctx.Line("}")(The built-in color is simpler still — the runtime package already exposes the composite
validations.ValidateColor.)
Add it to the explicit set your generator main feeds the engine, then use it as validate:"password":
func main() {
gens := append(validations.New().Generators(), myvalidators.PasswordGen{})
if err := gen.New(gens...).Run(); err != nil {
log.Fatal(err)
}
}See the plugin.TagGenerator
contract for the full Context surface (ExprPath, Import, Path, Fail, EmitRest, cross-field
Struct.Field, …).
Error type. Emit your own error value (imported via ctx.Import, apperr.Invalid above) — do not
hand-build a validations.Violation, whose Error() dereferences the reflect Struct/InnerType fields
that only the standard set populates. To make the whole standard set emit your type instead, use
Set.ErrorBuilder.
Every validator routes error construction through one error builder — the whole standard set emits
whatever it returns. Three builders ship baked in; select one with Set.ErrorBuilder(...):
| Builder | Emits | Reflect? | Cause via errors.As |
Best for |
|---|---|---|---|---|
gen.VerboseErrorBuilder() (default) |
*validations.Violation — Namespace, reflect Field/Struct/InnerType, Tag, Param, Value, Cause |
yes | yes (Unwrap) |
richest metadata; reflection-based introspection |
gen.SimpleErrorBuilder() |
*validations.SimpleViolation — Field, Tag, Cause |
no | yes (Unwrap) |
lightweight, JSON-friendlier structured errors |
gen.MinimalErrorBuilder() |
a plain fmt.Errorf string error |
no | no (bare message) | smallest footprint; log-style messages |
No builder puts the offending value — or the leaf cause text — in its Error() string. That keeps PII
out of logs by default: the value stays on Violation.Value (Verbose) for programmatic use, and the leaf
cause stays reachable with errors.As/Unwrap for Verbose and Simple (Minimal is a bare message and carries
no cause). So errors.As(err, &v) for a *validations.Violation/*validations.SimpleViolation, or
errors.As(err, &validations.UUIDError{}) to reach a leaf cause, works with Verbose and Simple.
The default is Verbose, so a bare validations.New() behaves as before. Pick another
in your generator main:
gen.New(validations.New().ErrorBuilder(gen.SimpleErrorBuilder()).Generators()...).Run()For full control, pass a custom builder — a func(gen.ErrorInfo) string returning the Go expression that
constructs your value, with full gen-time context (field, struct, tag, param, namespace); see
gen.DefaultErrorBuilder
to wrap the default:
set := validations.New().ErrorBuilder(func(ei gen.ErrorInfo) string {
p := ei.Ctx.Import("github.com/acme/apperr")
return fmt.Sprintf("&%s.FieldError{Path: %s, Field: %q, Rule: %q}", p, ei.PathExpr, ei.Field, ei.Tag)
})
gen.New(set.Generators()...).Run()Because the builder lives on the builder chain (not a global mutator), a compromised dependency cannot swap it to inject a malicious error expression into your generated code.
Notable changes are recorded in CHANGELOG.md.
Aligned with the Go release policy, support is guaranteed for the two
most recent major versions of Go (the go directive in go.mod is the current MSGV — Minimum Supported Go
Version).
This does not mean the package won't work with older versions of Go, only that we reserve the right to raise the MSGV when needed to address security patches, OS support, or newly introduced functionality that materially benefits the package. Any MSGV increase ships in at least a minor release.
Use AI tools or not — either way, you own what you submit. See AI_POLICY.md.
Licensed under either of Apache License, Version 2.0 or MIT license at your option.Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in this package by you, as defined in the Apache-2.0 license, shall be dual licensed as above, without any additional terms or conditions.