Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

43 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

valgen-validations

GitHub release (latest SemVer) Lint & Test Go Report Card GoDoc License

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.

Getting started

1. Tag your structs

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
}

2. Generate

Run the default generator from your module root:

go run github.com/go-playground/valgen-validations/cmd/valgen-validations@latest

That 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 tidy

Commit 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.

Configure without a custom generator

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-all

Only 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).

3. Customize the generator (optional)

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.

4. Call Validate and inspect the result

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.

5. Where to go next

Tag catalog

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

Coming from validator v10's conditional tags

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).

Behavior notes

  • 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||B and ne=A&&B emit the same zero-allocation switch as oneof/noneof. run

  • Field references always carry the sigil (@field). @Sibling is 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 constant switch cases. 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 structsgte=@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 *csfield family 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 closedeq=@Ptr.Zip with Ptr nil is a violation, because an absent value cannot satisfy the comparison. In a condition it makes the condition false: required_if does not require, excluded_if does not exclude, and skip_if does not skip (so validation still runs — never the silent direction). Consequence worth knowing: required_if=@Addr.Country eq US with Addr nil 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"`
  • email is exactly Go's parser, restricted to an address. Grammar is delegated entirely to the standard library's net/mail.ParseAddress (RFC 5322, extended by RFC 6532 for Unicode), so the tag accepts what the Go runtime accepts — no more, no less. Because net/mail parses message-header addresses, email additionally requires a bare addr-spec: no display name, and the parse must round-trip. That rejects Barry <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 both micro@µ.example.com and micro@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 a dive, a reference still resolves against the enclosing struct (so dive,gte=@Min works); 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 as required/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's Syntax.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 the database/sql Null* wrappers (including generic Null[T]) — so gt, between, oneof, etc. work on a sql.NullInt64 or *int unchanged. Add your own (guregu/null, samber/mo, …) with Set.Nullable in your generator main — it then works across every validator, cross-field, and required/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 _ci suffix (eq_ci, ne_ci, oneof_ci, noneof_ci); these require a string.

  • Dynamic (any/interface) fields. A interface{} 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 declares Validate(context.Context) error is auto-traversed (nil-guarded).

  • Fail-closed on absence. A value constraint on an absent wrapper (nil / None / !Valid) fails — an absent value cannot satisfy lt=10. Use omitempty/omitnil to 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 own Validate is 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 generator main to report every failure per field.

  • Gen-time param checks. Numeric params are range/sign-checked against the field's exact type (gt=300 on an int8 is 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=@Min where @Min is not the field's type) or a registered validation's Kind — 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 (int8int64, uint16uint64), float32float64, and a small integer that fits a float exactly (int16float64). NOT lossless (→ build error): floatint, wider→narrower, signed↔unsigned, and any number↔string/bool; int/uint are treated platform-conservatively (they may be 32-bit). To allow a lossy or foreign conversion deliberately, register a converter with Set.Convert — a failed conversion then counts as a failed validation.

String & format validators

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

Building validators in your own repo

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 call template

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.Contextoptional; 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.

Stateful & dependency-backed validators (%c / ctx)

%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 %c and read the dependency out of ctx:

    // 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 exported Setup()); the func reaches it directly and takes no ctx. Nothing special in the Func template — 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.

Converters — validate a foreign type

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 %cFunc:"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. int32int64); otherwise it is a positioned build error that names the type and points you at Set.Convert.

What you can register

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).

Writing a custom or combined validator

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 = passwordGen

A 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.

Custom error types

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.ViolationNamespace, reflect Field/Struct/InnerType, Tag, Param, Value, Cause yes yes (Unwrap) richest metadata; reflection-based introspection
gen.SimpleErrorBuilder() *validations.SimpleViolationField, 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()

Your own error type

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.

Changelog

Notable changes are recorded in CHANGELOG.md.

Supported Go versions

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.

AI Policy

Use AI tools or not — either way, you own what you submit. See AI_POLICY.md.

License

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.

About

Code generation validations library for the valgen engine

Resources

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages