validator

July 7, 2026 · View on GitHub

Doc Go Release Test Report Card Stars License

A modern, zero-dependency, high-performance Go Struct and Field validator with a boolean rule DSL.

Features

  • Zero third-party dependencies — only the Go standard library.
  • Boolean DSL&&, ||, !, (), precedence ! > && > ||.
  • Compile-once, run-many — expressions compile to cached closures.
  • 6 entry pointsStruct, Map, JSON, URLValues, Any, Var.
  • And more — cross-field, diving, bind/SafeBind, context, optional concurrency, i18n.

Requires Go 1.25+.

Install

go get github.com/libtnb/validator

Quick start

import (
    "context"
    "fmt"

    "github.com/libtnb/validator"
)

type User struct {
    Email string `validate:"required && email"`
    Pwd   string `validate:"required && min:8"`
    Pwd2  string `validate:"required && same:Pwd"`   // cross-field
    Tags  []string `validate:"dive && alpha"`        // each element
    Role  string `validate:"required && in:admin,user,guest"`
}

user := User{Email: "a@b.com", Pwd: "secret12", Pwd2: "secret12", Role: "user"}

vd := validator.Struct(user)
vd.Validate(context.Background())
if vd.Fails() {
    fmt.Println(vd.Errors().All()) // map[field]map[rule]message
}

var out User
vd.SafeBind(&out) // bind the validated, filtered values into out

// just pass/fail? Valid takes the allocation-free fast path (0 B/op)
if validator.Valid(user) { /* ... */ }

Maps / JSON / form values, with explicit rules:

vd := validator.Map(
    map[string]any{"email": "a@b.com", "age": 30},
    map[string]string{
        "email": "required && email",
        "age":   "required && (gte:18 && lte:120)",
    },
)
vd.Validate(context.Background())

validator.JSON(`{"email":"a@b.com"}`, rules)           // decodes then validates
validator.URLValues(r.Form, rules)                     // form data
validator.Var("a@b.com", "required && email")          // a single value

The DSL

ConstructMeaning
a && bboth must pass
a || bat least one must pass
!amust NOT pass
(a || b) && cgrouping; precedence is ! > && > ||
rule:arg1,arg2rule with arguments (in:a,b,c, min:3)
regex:"^(a|b)+$"quoted argument: | & ( ) are literal inside quotes
diveapply the following rules to each slice/array/map element

Argument quoting. Operators are the double-character forms && / ||, so a single | inside a regex is never mistaken for OR. For arguments containing | & ( ), wrap them in quotes (regex:"...", only \" and \\ are escapes inside quotes) or escape with a backslash in bare form (\| \& \( \)). Regex metacharacters like \d are preserved.

Semantics

Empty values (omitempty). Non-presence rules pass on empty/zero values, so an optional field is only checked when present. This is why ! differs from not_*: in:a,b passes on "" (omitempty), so !in:a,b rejects "" while not_in:a,b passes it. Negate a non-presence rule with its not_*/ne form, not ! (which is for presence rules like !required).

required. By default required asserts present and non-nil: a Go zero value ("", 0, false) counts as provided and passes, while a nil pointer or an absent map/JSON key fails. To reject empty values, use notblank/filled for strings, or WithStrictRequired() to make required reject zero values too.

Sizes: length vs value. min/max/between/gt/gte/lt/lte/len/size compare numbers by value and strings by rune count; add numeric to compare a string by value:

"age": "numeric && gte:18"   // "30" compares as the number 30
"pwd": "required && min:8"   // "999" is 3 characters, fails

Built-in rules

Args are the rule:arg values ( = none; = repeatable; ? = optional).

Presence

RuleArgsPasses when
requiredpresent and non-nil (non-zero under WithStrictRequired)
fillednot empty
notblankhas a non-whitespace character
sometimesmarker: when the field is absent (missing key, nil pointer), skip every rule on the field — "sometimes && required && email" gives PATCH semantics
required_iffield,val…required when field equals any listed value
required_unlessfield,val…required unless field equals any listed value
required_withfield…required when any listed field is present
required_withoutfield…required when any listed field is missing
required_with_allfield…required when all listed fields are present
required_without_allfield…required when all listed fields are missing
excluded_iffield,val…must be empty when field equals any listed value
excluded_unlessfield,val…must be empty unless field equals any listed value
excluded_withfield…must be empty when any listed field is present
excluded_withoutfield…must be empty when any listed field is missing

String

RuleArgsPasses when
alphaletters only
alphanumletters and digits only
asciiASCII characters only
lowercaseall lowercase
uppercaseall uppercase
containssubcontains the substring
excludessubdoes not contain the substring
startswithprefixhas the prefix
endswithsuffixhas the suffix

Format

RuleArgsPasses when
emaila valid email address
url / uria valid URL / URI
uuid / ulida valid UUID / ULID
ip / ipv4 / ipv6a valid IP / IPv4 / IPv6 address
cidr / cidrv4 / cidrv6valid CIDR notation (any / IPv4 / IPv6)
maca valid MAC address
hostname / fqdna valid RFC 1123 hostname / FQDN (TLD required)
portan integer port in [1, 65535]
e164an E.164 phone number (+14155552671)
jsonvalid JSON
base64a valid base64 string
jwta three-segment JWT shape
semvera semantic version (1.2.3-rc.1)
hexcolora hex color (#fff, #ffaa00cc)
latitude / longitudea decimal coordinate in range
timezonean IANA time zone name (Asia/Shanghai)
luhndigits passing the Luhn checksum
credit_carda 12-19 digit card number passing Luhn
datetimelayout?parses with the Go time layout (default layouts if omitted)
datea valid date
regexpatternmatches the pattern
not_regexpatterndoes not match the pattern

Time — an arg that parses as a date is always a literal bound (after:2026-01-01; input keys can never shadow it), otherwise it resolves as a sibling field name (after:Start). Values may be time.Time, date strings, or unix timestamps.

RuleArgsPasses when
after / after_or_equalfield|datea date after (or equal to) the reference
before / before_or_equalfield|datea date before (or equal to) the reference

File (*multipart.FileHeader fields; ext also accepts filename strings)

RuleArgsPasses when
extjpg,png,…the filename has one of the extensions (case-insensitive)
mimetypestype…the sniffed content (first 512 bytes, not the client header) matches; image/* wildcards work
filemin / filemaxsizefile size ≥ / ≤ size (512kb, 10mb, 1.5gb; 1024-based)

Content starting with markup (< + letter/!///?) is never sniffed as text/plain<svg onload=…> and unlisted tags cannot pass a plain-text allowlist (stricter than http.DetectContentType). Literal < in prose (a < b, <3) stays plain text.

Numeric / sizesize = value for numbers, rune length for strings (see above); n is a number.

RuleArgsPasses when
min / maxnsize ≥ n / ≤ n
betweenmin,maxsize within [min, max]
gt / gtensize > n / ≥ n
lt / ltensize < n / ≤ n
len / sizensize == n
digitsnexactly n digits
numerica number or numeric string
numberan integer
booleanconvertible to a boolean

Comparison

RuleArgsPasses when
in / in_cia,b,…equals one of the values (_ci = case-insensitive)
not_ina,b,…equals none of the values
eq / nevequals / does not equal v
eq_ignore_case / ne_ignore_casevcase-insensitive equals / not equals
uniquea slice/array has no duplicate elements (a map no duplicate values)

Cross-field

RuleArgsPasses when
same / eqfieldfieldequals another field
different / nefieldfielddiffers from another field
gtfield / gtefieldfield> / ≥ another field (numbers, or time.Time pairs chronologically)
ltfield / ltefieldfield< / ≤ another field (numbers, or time.Time pairs chronologically)
confirmed<field>_confirmation exists and matches

Filters (transform the value before validation / SafeBind)

FilterArgsEffect
trim / ltrim / rtrimstrip surrounding / leading / trailing whitespace
lower / upper / titlelower-case / upper-case / title-case
int / float / bool / stringconvert to that type

The is subpackage exposes the format checks as plain functions (is.Email, is.URL, ...) for reuse outside the validator.

Custom rules

Field.Val() returns a reflect.Value (the value flows without boxing, so the validation hot path is allocation-free). Read it with the reflect.Value getters, or f.Val().Interface() to get the value as any for the conv helpers.

v := validator.NewValidator()

// string style — omitempty and string rendering are pre-applied,
// fn holds only the actual check
v.RegisterStringFunc("slug", func(s string, args ...string) bool {
    return !strings.Contains(s, " ")
}, "The {field} must be a slug.")

// function style — read the value via reflect.Value (0-alloc)
v.RegisterFunc("even", func(f validator.Field) bool {
    rv := f.Val()
    return rv.CanInt() && rv.Int()%2 == 0
}, "The {field} must be even.")

// interface style: implement Signature() / Passes(Field) bool / Message()
v.RegisterRule(&MyRule{})

Rules that need a context.Context or return an error (e.g. a DB uniqueness check) implement validator.ErrorRule instead and read f.Context().

Structured errors & tag linting

vd.Errors().Items()             // []FieldError{Field, Rule, Message, Params} — build API error payloads
es, ok := validator.AsErrors(err) // recover the collection from a (wrapped) Validation.Err()

// catch tag typos in a test instead of at request time:
// unknown rules, DSL syntax errors and bad static args, all fields at once
func TestRequestTags(t *testing.T) {
    if err := validator.CheckRules(CreateUserRequest{}); err != nil {
        t.Fatal(err)
    }
}

Messages, attributes & i18n

v := validator.NewValidator(
    validator.WithAttributes(map[string]string{"email": "Email address"}),
    validator.WithMessages(map[string]string{
        "email.required": "Please provide your email.", // field-level
        "required":       "This field is required.",     // rule-level
    }),
    validator.WithTranslation(translations.ZhHans()),    // i18n fallback
)

Locale packs (ZhHans, ZhHant, Ja, Ko, Es) live in the github.com/libtnb/validator/translations subpackage.

Templates use {field} (replaced by the attribute alias or field name) and {0}, {1}, ... (the rule arguments). Validation.AddMessages overrides templates for a single run:

vd := v.Struct(req)
vd.AddMessages(map[string]string{"email.required": "We need your email."})

Priority: AddMessages > WithMessages > i18n > built-in English; within a map, field.rule beats rule.

The package-level default

The top-level helpers (validator.Struct, Map, JSON, ...) run on a shared default instance. SetDefault replaces it process-wide — install a configured validator once at startup (like slog.SetDefault) and the rest of the program can validate through the package funcs without passing a *Validator around:

v := validator.NewValidator(validator.WithTranslation(translations.ZhHans()))
v.RegisterErrorRule(myDBRule)
validator.SetDefault(v)

// anywhere else
vd := validator.Struct(req) // uses the installed default

Default() returns the current instance; before any SetDefault it is a lazily-created plain NewValidator().

Contrib modules

Database-backed rules live in separate zero-impact modules so the core stays dependency-free:

go get github.com/libtnb/validator/contrib/gormrules

gormrules.Register(v, db) adds exists:table,col[,col...] and not_exists:table,col[,col...] backed by a *gorm.DB, with SQL-identifier validation on the rule arguments.

License

See LICENSE.