vx
Validation built from small, composable checks with zero dependencies and a reconstructable error path
About vx
vx
Validation for Go values of any shape — scalars, whole structs, and the nested structs, slices, and maps inside them — built from small, composable checks. No dependencies beyond the standard library.
import "github.com/sevlyar/vx"
Why
- Schemas are values you build once, not graphs rebuilt per call.
vx.Structure(&zeroValue, vx.Field(&zeroValue.Age, vx.Ge(0)))only needszeroValueto resolve field addresses by pointer; the result is avx.Schemayou can store in a package-level var and bind once, then check any number of actual values. Libraries that take a pointer to the live instance being validated (e.g.ozzo/invopop'svalidation.Field(&a.Age, ...)) can't do this — their rule graph is reconstructed on every single call. - No dependencies beyond the standard library — not even for format or regexp checks. Compare
go-playground/validator's 7 direct dependencies (including a full locale/translation stack) orinvopop/validation's 1. - Checks compose, and a failure's path shows the whole chain, not one flat rule.
Len(Gt(3)),AllOf/AnyOf/OneOfnest arbitrarily;SchemaPathreconstructs the full chain that fired, e.g.Field(Name).Len.Gt(3), andDataPaththe exact place in the data, e.g.Items[2].Name— no error-string parsing needed for either. - Field selectors and arguments are regular Go code, checked by the compiler.
vx.Field(&user.Age, vx.Gt(0))— a typo or type mismatch is a build error, not a validation tag that silently never matches at runtime. - Measured faster, with fewer allocations, against both libraries named below, on every scenario benchmarked so far — see Performance.
See below for how these hold up against go-playground/validator and invopop/validation specifically, and what vx doesn't have (yet).
Quick start
type Person struct {
Name string
Age int
}
var p Person
schema := vx.Structure(&p,
vx.Field(&p.Name, vx.Len(vx.Gt(0))),
vx.Field(&p.Age, vx.Ge(0)),
)
err := schema.BindAny().Check(Person{Name: "", Age: -1})
fmt.Println(err)
// Field(Name).Len.Gt(0): invalid Name: value should be greater than 0
Structure requires a Field option for every field; use vx.AllowUncheckedFields(&p.SomeField) to opt specific fields out deliberately, rather than leaving them unchecked by accident.
Binding: early vs late
A Schema is a function from a reflect.Type to a BoundSchema. How you bind it decides when a type mismatch is caught:
schema.BindTypeOf(v)binds early, against the concrete type ofv. A schema that doesn't support that type panics immediately, at startup — not buried in a request handler. This is whatStructure/Fielduse internally for each field, since a field's type is always known.schema.BindAny()binds late, against any type. A type mismatch is returned as a regular error fromCheck, since the actual value is only known at validation time (e.g. a field typedany, or data decoded from JSON).
Inspecting a failure
Every failure is either a *vx.CompoundCheckError (a check that delegates to a nested schema: Structure, Item, Len, AllOf/AnyOf/OneOf) or a *vx.CheckError (a leaf check with nothing to delegate to: Gt, In, Format, ...). Both are reachable through the standard errors.Is/errors.As.
var ce *vx.CompoundCheckError
if errors.As(err, &ce) {
ce.SchemaPath() // []string{"Field(Items)", "Item", "Field(Name)", "Len", "Gt(3)"}
ce.DataPath() // []vx.PathElement{...} -> vx.RenderPath(...) == "Items[1].Name"
}
Checks
Navigate the data
| Check | Checks |
|---|---|
Structure(ptr, Field(...), ...) | a struct's fields, addressed by pointer |
Item(schema) | every element of an array or slice |
A derived aspect of the value
| Check | Checks |
|---|---|
Len(schema) | the length of a string (rune count), array, slice or map |
Leaf predicates
| Check | Checks |
|---|---|
Gt, Ge, Lt, Le | a number against a threshold |
In(values...) | membership in a fixed set |
Empty, NonEmpty | the value is/isn't the zero value for its type |
Format(name, isValid), RegexpFormat(pattern) | a string against a predicate or regexp |
PrintableLine, PrintableText | a string has only printable runes (the latter also allows line breaks) |
Combine schemas
| Check | Checks |
|---|---|
AllOf(schemas...) | the value against every schema |
AnyOf(schemas...) | the value against each schema until one matches |
OneOf(schemas...) | the value matches exactly one schema |
See the package documentation for runnable examples of each.
Writing your own checks
A check is just a vx.Schema — func(reflect.Type) vx.BoundSchema. Two exported building blocks cover the two shapes every check in this package follows:
-
vx.BindTypeCheck(check, validate)for a leaf check, one with no nested schema (likeGtorFormat).checkrejects types the check doesn't support;validatedoes the actual work and returns a*vx.CheckErroron failure.func MultipleOf(n int) vx.Schema { mustBeInt := func(t reflect.Type) error { if t.Kind() != reflect.Int { return errors.New("type must be int") } return nil } err := &vx.CheckError{ CheckName: "MultipleOf", Param: fmt.Sprint(n), // -> "MultipleOf(5)" in SchemaPath Msg: fmt.Sprintf("value must be a multiple of %d", n), } return vx.BindTypeCheck(mustBeInt, func(v reflect.Value) error { if v.Int()%int64(n) != 0 { return err } return nil }) } -
vx.BindCompound(check, early, late)for a check that delegates to a nested schema reached through some transformation of the value (likeItemreaching into a slice's elements, orLeninto its length). Wrap the nested failure in a*vx.CompoundCheckError, picking avx.PathElementforDataItem:FieldElement/IndexElementif one fits, or the general-purposeKeyElementotherwise (e.g. for a map key, which is neither).
Both keep the early/late binding contract: a type mismatch panics when the type is known at bind time, and is returned as an error when it's only known at Check time (see "Binding" above) — exactly what hand-rolling the two branches yourself would get you, without having to get it right by hand.
See extend_test.go for both worked end to end, including a Values check that validates every value of a map the way Item validates a slice.
Compared to other validators
Checked against go-playground/validator (the dominant struct-tag library) and invopop/validation (the maintained ozzo-validation fork — closest in spirit: Go code and pointer-addressed fields, no tags), against their own source, not secondhand claims:
✅ clear advantage · ⚠️ partial / limited · ❌ clear gap — unmarked rows are a paradigm choice, not a win or a loss.
go-playground/validator | invopop/validation | vx | |
|---|---|---|---|
| Rule is | a string tag, validate:"gt=0" | Go code | Go code |
| Field addressed by | tag on the field | pointer, per live instance | pointer, resolved once via a throwaway zero value |
| Schema reusable across calls? | ✅ yes (caches parsed tags per type) | ❌ no — rebuilt on every call | ✅ yes, explicitly, as a value |
| Failure path | ⚠️ Namespace(), e.g. "User.Addresses[0].Street", but one flat Tag()+Param() | ❌ none — a (possibly nested) map[string]error | ✅ SchemaPath/DataPath, full nested chain |
| Rule composition | ⚠️ flat, comma-separated (AND), limited | (OR) per tag | ❌ independent rules, no nesting | ✅ arbitrary nesting: Len(Gt(3)), AllOf/AnyOf/OneOf |
| Direct dependencies | ❌ 7 (locales, universal-translator, go-urn, mimetype, x/crypto, x/text, assert) | ⚠️ 1 (govalidator) | ✅ 0 |
| Rule checked by the compiler? | ❌ no — a malformed tag fails silently or at runtime | ✅ yes | ✅ yes |
Performance
Benchmarked in an isolated module — not a dependency of vx itself, see "no dependencies" above — against go-playground/validator, invopop/validation, and nobl9/govy (a newer, generics-based, reflection-free validator — not yet in the table above, but close enough in spirit to be worth a speed comparison), validating a flat struct and a struct with a nested 5-item slice. The one format check in each scenario uses the exact same compiled regexp in every library: an earlier pass instead used each library's own built-in email validator and showed a bigger gap, which wasn't a fair reading — go-playground's and invopop's built-in email checks are more thorough (and so more expensive) than a plain regexp. This version isolates framework overhead from validation thoroughness.
vx is called as schema.Check(&data), not schema.Check(data): passing a pointer avoids boxing the struct into the any parameter, which otherwise costs an allocation for any struct over one machine word (see BoundSchema.Check's doc comment). The other three libraries are each called the way their own benchmarks/docs call them — for go-playground/validator that's actually by value, not by pointer, since pointer input measured slower and less allocation-free for it specifically. Each library is shown in its own best light, not forced into one calling convention.
go1.24.4 darwin/arm64, Apple M3, go test -bench=. -benchmem -count=3, numbers stable across runs:
| Scenario | vx | go-playground/validator | invopop/validation | govy |
|---|---|---|---|---|
| Flat struct, valid | 83 ns/op, 0 allocs | 154 ns/op, 0 allocs (1.9×) | 566 ns/op, 18 allocs (6.8×) | 144 ns/op, 0 allocs (1.7×) |
| Flat struct, invalid | 50 ns/op, 2 allocs | 351 ns/op, 10 allocs (7.0×) | 692 ns/op, 22 allocs (13.8×) | 1358 ns/op, 43 allocs (27.2×) |
| Nested struct + 5-item slice, valid | 369 ns/op, 0 allocs | 1063 ns/op, 22 allocs (2.9×) | 4195 ns/op, 142 allocs (11.4×) | 714 ns/op, 0 allocs (1.9×) |
Three caveats, honestly:
- The "invalid" row isn't purely framework overhead.
vx'sStructurestops at the first failing field by design ("first failure wins"); the other three collect every field's errors by default. Part of that gap is a difference in what's being done, not just how fast. Note also that passing a pointer only zeroes out allocations on the valid path — the 2 allocations on the invalid row are the returned*CompoundCheckErrorchain itself, unavoidable since describing a failure means allocating something to describe it. govyis genuinely fast and allocation-free on the valid path — partly its reflection-free, generics-based design (a genericValidate[T](value T)never boxes intoanythe wayvx'sCheck(val any)does without a pointer), partly its own merits. It's the slowest of the four on the invalid path, by a wide margin, which plausibly comes from its own stated priority: building detailed, templated, per-property error messages costs allocations that a terser error doesn't. Not a flaw so much as a different trade-off thanvxmakes.- One machine, one run of three scenarios — not a broad performance suite.
invopop/validation's much higher allocation count does match the architectural gap noted in the table above: it rebuilds its rule graph on every call, the other three don't.
Runnable, with the other three libraries as real dependencies, in benchmarks/ — a separate module so they never touch vx's own go.mod:
cd benchmarks && go test -bench=. -benchmem ./...
What vx doesn't have, honestly:
- ❌ No struct-tag / data-driven mode. Rules are Go code; if you need to change validation without recompiling (e.g. rules loaded from JSON/config), this isn't it.
- ❌ No built-in i18n/translation of error messages, unlike
go-playground/validator's locale stack. - ❌ Smaller built-in rule set and a much smaller community — this is a new library, not a battle-tested one with years of edge cases shaken out.
Install
go get github.com/sevlyar/vx
License
MIT, see LICENSE.
Frequently Asked Questions
What is vx?
vx is a Validation library for the Go programming language. Validation built from small, composable checks with zero dependencies and a reconstructable error path
How do I install vx?
Install vx with the Go module system using `go get sevlyar/vx`. Check the repository for the current installation instructions.
What category does vx belong to?
vx is listed under Validation, specifically Validation.