vx

package module
v1.0.1 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 2, 2026 License: MIT Imports: 9 Imported by: 0

README

vx

CI

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 needs zeroValue to resolve field addresses by pointer; the result is a vx.Schema you 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's validation.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) or invopop/validation's 1.
  • Checks compose, and a failure's path shows the whole chain, not one flat rule. Len(Gt(3)), AllOf/AnyOf/OneOf nest arbitrarily; SchemaPath reconstructs the full chain that fired, e.g. Field(Name).Len.Gt(3), and DataPath the 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 of v. A schema that doesn't support that type panics immediately, at startup — not buried in a request handler. This is what Structure/Field use 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 from Check, since the actual value is only known at validation time (e.g. a field typed any, 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 (like Gt or Format). check rejects types the check doesn't support; validate does the actual work and returns a *vx.CheckError on 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 (like Item reaching into a slice's elements, or Len into its length). Wrap the nested failure in a *vx.CompoundCheckError, picking a vx.PathElement for DataItem: FieldElement/IndexElement if one fits, or the general-purpose KeyElement otherwise (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 the same two libraries, 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 all three libraries: 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.

go1.24.4 darwin/arm64, Apple M3, go test -bench=. -benchmem -count=3, numbers stable across runs:

Scenario vx go-playground/validator invopop/validation
Flat struct, valid 84 ns/op, 0 allocs 156 ns/op, 0 allocs (1.9×) 560–590 ns/op, 18 allocs (7×)
Flat struct, invalid 47 ns/op, 2 allocs 347 ns/op, 10 allocs (7.4×) 680–720 ns/op, 22 allocs (15×)
Nested struct + 5-item slice, valid 390 ns/op, 1 alloc 1052–1058 ns/op, 22 allocs (2.7×) 4188–4194 ns/op, 142 allocs (10.7×)

Two caveats, honestly:

  • The "invalid" row isn't purely framework overhead. vx's Structure stops at the first failing field by design ("first failure wins"); the other two collect every field's errors by default. Part of that gap is a difference in what's being done, not just how fast.
  • 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, vx and go-playground/validator don't.

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.

Documentation

Overview

Package vx validates Go values against schemas built from small, composable checks (Gt, Len, Item, Structure, ...).

A Schema is a function that binds to a reflect.Type and returns a BoundSchema for that type:

schema := Gt(0)
validate := schema.BindTypeOf(0)
err := validate.Check(-1) // error: value should be greater than 0

Binding happens either early, against a concrete type known when the schema is built (BindTypeOf, or a struct field's own type inside Structure), or late, against any value's type at Check time (BindAny). A schema bound early fails fast: a mismatched type panics when the schema is built, not when data is checked. A schema bound late can only report a type mismatch as a regular error, since any value may arrive at Check time.

Checks compose: Structure and Field describe a struct's fields, Item and Len reach into a slice or its length, AllOf/AnyOf/OneOf combine several schemas. A failure is a *CompoundCheckError (or, for a check with no nested schema, a *CheckError); SchemaPath and DataPath reconstruct exactly which rule fired and where in the data, e.g. "Field(Name).Item. Len.Gt(3)" and "Name[2]".

Example

Example validates a struct: every field needs a Field option, checks run in the order they are declared, and the first failure wins.

package main

import (
	"fmt"

	"github.com/sevlyar/vx"
)

func main() {
	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)
}
Output:
Field(Name).Len.Gt(0): invalid Name: value should be greater than 0
Example (ErrorPaths)

Example_errorPaths shows SchemaPath and DataPath on a failure nested through a struct field, a slice item, and a second struct field.

package main

import (
	"fmt"

	"github.com/sevlyar/vx"
)

func main() {
	type Item struct{ Name string }
	type Container struct{ Items []Item }

	var it Item
	itemSchema := vx.Structure(&it, vx.Field(&it.Name, vx.Len(vx.Gt(3))))

	var c Container
	schema := vx.Structure(&c, vx.Field(&c.Items, vx.Item(itemSchema)))

	err := schema.BindAny().Check(Container{Items: []Item{{Name: "abcd"}, {Name: "ab"}}})
	ce := err.(*vx.CompoundCheckError)

	fmt.Println(ce.SchemaPath())
	fmt.Println(vx.RenderPath(ce.DataPath()))
}
Output:
[Field(Items) Item Field(Name) Len Gt(3)]
Items[1].Name

Index

Examples

Constants

This section is empty.

Variables

View Source
var (
	// ErrNonEmptyValue is returned by Empty when the value is not empty.
	ErrNonEmptyValue = newCheckError("Empty", "", "value is not empty")
	// ErrEmptyValue is returned by NonEmpty when the value is empty.
	ErrEmptyValue = newCheckError("NonEmpty", "", "value is empty")
)
View Source
var ErrMultipleSchemasMatch = errors.New("value matches multiple schemas")

ErrMultipleSchemasMatch is the cause of a OneOf error when more than one schema matches.

View Source
var ErrNilValue = newCheckError("NotNil", "", "value is nil")

ErrNilValue is returned by a late-bound check when the value to check, reached through an any-typed slot (a []any element, a map value, a struct field typed any, ...), turns out to be a nil interface. It is a *CheckError (CheckName "NotNil"), not a plain error, so it still contributes its own segment to SchemaPath like any other leaf failure — "NotNil", not the name of whatever check never got to run on a nil value.

View Source
var PrintableLine = Format("PrintableLine", isPrintableLine)

PrintableLine checks a string has no non-printable runes, including line breaks.

View Source
var PrintableText = Format("PrintableText", isPrintableText)

PrintableText is like PrintableLine but allows line breaks and other whitespace control runes.

Functions

func CanCheckEarly

func CanCheckEarly(t reflect.Type) bool

CanCheckEarly reports whether t is a concrete type a Schema can check immediately when it is built, rather than having to wait for a value at Check time. It is false only for the interface type BindAny binds to.

func RenderPath

func RenderPath(path []PathElement) string

RenderPath renders a DataPath as a single string, e.g. "Field1.NestedArray[2].Field2".

func Validate

func Validate(value any, validate BoundSchema) error

Validate runs validate against value, indirecting through a pointer if value is one.

Types

type BoundSchema

type BoundSchema func(v reflect.Value) error

BoundSchema validates a single reflect.Value, already bound to a known type.

func Empty

func Empty(t reflect.Type) BoundSchema

Empty checks the value is the zero value for its type.

func NonEmpty

func NonEmpty(t reflect.Type) BoundSchema

NonEmpty checks the value is not the zero value for its type.

func (BoundSchema) Check

func (v BoundSchema) Check(val any) error

Check adapts BoundSchema to validate a Go value directly.

type CheckError

type CheckError struct {
	CheckName string // the check that failed, e.g. "Gt"
	Param     string // optional schema-time argument, e.g. "5"
	Msg       string
}

CheckError is a leaf validation error: it is returned directly by a single, non-compound check (Gt, In, Format, Empty, ...) and terminates both the schema path and the data path at the point it occurs.

Param, when set, is the schema-time argument of the check (e.g. "5" for Gt(5)) and is rendered as part of the schema path so that two uses of the same check can be told apart there, e.g. "Gt(5)" vs "Gt(10)".

Checks that need to carry extra, programmatically accessible details (e.g. the threshold of a Gt check) embed CheckError in a more specific type; embedding promotes Error and leafSegment, so the specific type satisfies leafError too.

func (*CheckError) Error

func (err *CheckError) Error() string

type CompoundCheckError

type CompoundCheckError struct {
	CheckName string      // e.g. "Field", "Item", "Len", "AllOf"
	Param     string      // optional schema-time argument, e.g. the field name for Field
	DataItem  PathElement // zero value (NoElement) if this check validates the value itself
	Cause     error
}

CompoundCheckError is returned by checks that delegate to a nested schema (Structure, Item, Len, AllOf, AnyOf, OneOf, ...). It attributes the failure of Cause to a place in the schema (CheckName, qualified by Param when the check takes a schema-time argument that cannot otherwise be told apart in the schema path, e.g. the field name of Field) and, optionally, a place in the validated data (DataItem).

func (*CompoundCheckError) DataPath

func (err *CompoundCheckError) DataPath() []PathElement

DataPath returns the chain of field names and indices that led to the invalid value, e.g. []PathElement{Field("NestedArray"), Index(2), Field("Field2")}.

func (*CompoundCheckError) Error

func (err *CompoundCheckError) Error() string

func (*CompoundCheckError) SchemaPath

func (err *CompoundCheckError) SchemaPath() []string

SchemaPath returns the chain of check names that led to the failure, qualified by their schema-time argument where one exists, e.g. []string{"Item", "Field(Name1)", "Len", "Gt(5)"}.

func (*CompoundCheckError) Unwrap

func (err *CompoundCheckError) Unwrap() error

type PathElement

type PathElement struct {
	Kind  PathElementKind
	Name  string // set when Kind == FieldElement or KeyElement
	Index int    // set when Kind == IndexElement
}

PathElement is a single step of a DataPath: a struct field, an array/slice index, or some other keyed element a custom check describes with KeyElement.

func (PathElement) String

func (p PathElement) String() string

String renders the element the way it is appended to a path, e.g. ".Name" for a field, "[2]" for an index, and "[key]" for a KeyElement.

type PathElementKind

type PathElementKind int

PathElementKind identifies what a PathElement refers to in the validated data.

const (
	// NoElement marks a check that validates the value itself rather than
	// navigating into one of its sub-elements (e.g. Len, AllOf, AnyOf, OneOf).
	NoElement PathElementKind = iota
	// FieldElement marks a struct field, identified by Name.
	FieldElement
	// IndexElement marks an array/slice element, identified by Index.
	IndexElement
	// KeyElement marks any other keyed element (e.g. a map value),
	// identified by Name. Name is the key's already-rendered text, e.g.
	// fmt.Sprintf("%q", k) for a string key; a custom check picks the
	// rendering appropriate to its own key type.
	KeyElement
)

type Schema

type Schema func(t reflect.Type) BoundSchema

Schema builds a BoundSchema for t. It must check t is a type the schema supports and panic if it is not, unless it defers that check until validation time (see BindAny).

func AllOf

func AllOf(schema ...Schema) Schema

AllOf checks the value against every schema. The first failure is returned.

Example

ExampleAllOf shows AllOf failing on the first schema that does not match.

package main

import (
	"fmt"

	"github.com/sevlyar/vx"
)

func main() {
	schema := vx.AllOf(vx.Gt(0), vx.Lt(10)).BindAny()
	fmt.Println(schema.Check(5))
	fmt.Println(schema.Check(15))
}
Output:
<nil>
AllOf.Lt(10): value should be less than 10

func AnyOf

func AnyOf(schema ...Schema) Schema

AnyOf checks the value against each schema until one matches.

func BindCompound

func BindCompound(
	check func(t reflect.Type) error,
	early func(t reflect.Type) BoundSchema,
	late func() BoundSchema,
) Schema

BindCompound builds a Schema for a check that delegates to a nested schema reached through some transformation of the bound type (Item's element type, Len's length, ...). It is exported for writing new checks of this shape: early builds the BoundSchema when the type is known at bind time (so the nested schema's own checks run, and panic, then too); late builds it once, against "any", when it is not (so the nested schema's checks defer to validation time and return errors instead of panicking). See BindTypeCheck for checks with no such transformation.

func BindTypeCheck

func BindTypeCheck(check func(t reflect.Type) error, validate BoundSchema) Schema

BindTypeCheck builds a Schema for a check with a fixed validate func that requires a specific kind of type (check reports a non-nil error if t is unsupported). It is the building block behind most of this package's checks (Gt, Format, Structure, ...) and is exported for writing new ones: check runs, and panics on failure, at bind time when the type is known early; otherwise it runs on every Check call and returns its error instead of panicking. See CanCheckEarly.

func Format

func Format(name string, isValid func(str string) bool) Schema

Format checks a string value against isValid, requiring the bound type to be string.

func Ge

func Ge(x any) Schema

Ge checks the value is greater than or equal to x.

func Gt

func Gt(x any) Schema

Gt checks the value is greater than x.

Example
package main

import (
	"fmt"

	"github.com/sevlyar/vx"
)

func main() {
	schema := vx.Gt(0).BindAny()
	fmt.Println(schema.Check(5))
	fmt.Println(schema.Check(-1))
}
Output:
<nil>
value should be greater than 0

func In

func In(allowed ...any) Schema

In checks the value is one of allowed. A slice argument is unwound into its elements.

allowed is captured once, into a map, when In is called, not re-read afterward: pass a small, fixed set of immutable values (e.g. enum constants), not a large or later-mutated collection.

Example
package main

import (
	"fmt"

	"github.com/sevlyar/vx"
)

func main() {
	schema := vx.In("red", "green", "blue").BindAny()
	fmt.Println(schema.Check("green"))
	fmt.Println(schema.Check("purple"))
}
Output:
<nil>
value is not one of [red green blue]

func Item

func Item(schema Schema) Schema

Item checks every element of an array or slice against schema.

Example
package main

import (
	"fmt"

	"github.com/sevlyar/vx"
)

func main() {
	schema := vx.Item(vx.Gt(0)).BindAny()
	fmt.Println(schema.Check([]int{1, 2, 3}))
	fmt.Println(schema.Check([]int{1, -2, 3}))
}
Output:
<nil>
Item.Gt(0): invalid [1]: value should be greater than 0

func Le

func Le(x any) Schema

Le checks the value is less than or equal to x.

func Len

func Len(schema Schema) Schema

Len checks the length of a string, array, slice or map against schema. A string's length is its rune count, not its byte count.

Example
package main

import (
	"fmt"

	"github.com/sevlyar/vx"
)

func main() {
	schema := vx.Len(vx.Gt(3)).BindAny()
	fmt.Println(schema.Check("hello"))
	fmt.Println(schema.Check("hi"))
}
Output:
<nil>
Len.Gt(3): value should be greater than 3

func Lt

func Lt(x any) Schema

Lt checks the value is less than x.

func OneOf

func OneOf(schema ...Schema) Schema

OneOf checks the value matches exactly one schema.

Example

ExampleOneOf shows OneOf requiring exactly one schema to match; here neither Lt(0) nor Gt(10) matches 5.

package main

import (
	"fmt"

	"github.com/sevlyar/vx"
)

func main() {
	schema := vx.OneOf(vx.Lt(0), vx.Gt(10)).BindAny()
	fmt.Println(schema.Check(-1))
	fmt.Println(schema.Check(5))
}
Output:
<nil>
OneOf.Gt(10): value should be greater than 10

func RegexpFormat

func RegexpFormat(pattern string) Schema

RegexpFormat checks a string value matches pattern, requiring the bound type to be string.

Example
package main

import (
	"fmt"

	"github.com/sevlyar/vx"
)

func main() {
	schema := vx.RegexpFormat(`^\d+$`).BindAny()
	fmt.Println(schema.Check("12345"))
	fmt.Println(schema.Check("12a45"))
}
Output:
<nil>
value does not match ^\d+$

func Structure

func Structure(ptr any, opts ...StructOption) Schema

Structure checks a struct's fields according to the given options. ptr must point to the struct being described; it is used only to resolve field types and addresses, not validated itself.

Structure panics if any field has no Field option attached to it; use AllowUncheckedFields to opt specific fields out of this requirement.

func (Schema) BindAny

func (schema Schema) BindAny() BoundSchema

BindAny binds schema late, against values of any type. A type mismatch the schema would otherwise panic on at bind time is instead returned as an error from the resulting BoundSchema.

func (Schema) BindTypeOf

func (schema Schema) BindTypeOf(v any) BoundSchema

BindTypeOf binds schema early, against the type of v (or, if v is a pointer, the type it points to). A value of a different type passed to the resulting BoundSchema panics.

type StructOption

type StructOption func(*structureValidator)

StructOption configures a Structure schema; see Field and AllowUncheckedFields.

func AllowUncheckedFields

func AllowUncheckedFields(fieldPtrs ...any) StructOption

AllowUncheckedFields opts the addressed fields out of Structure's requirement that every field have a Field option.

func Field

func Field(fieldPtr any, schema Schema) StructOption

Field declares how to validate the struct field addressed by fieldPtr. fieldPtr must point to a field of the struct passed to Structure.

type UnknownValueError

type UnknownValueError struct {
	CheckError
	KnownValues []any
}

UnknownValueError is returned by In when the value is not one of KnownValues.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL