# valigo > Typed, zero allocation validation of Go structs, rules are configured by pointers to the fields valigo validates Go structs by rules configured once with pointers to the fields of a model: b.Field(&u.Name).As(str.Of).Required().MaxLen(64). The configurator has Field, Slice, Map, When and Custom, As converts a field to the configurator of its type by a conversion with a type constraint (str.Of, num.Of, num.Ptr, timex.Of, eq.Of, str.Slice...), so the compiler checks the field types and the rule arguments (Go 1.27 generic methods). Validation of valid objects doesn't allocate, absent values are skipped by all rules except Required, errors have field locations, translated messages (en, de, es, fr, ru), stable codes and named params; the problem package writes RFC 9457 problem details, the github.com/insei/valigo/integrations/huma module validates huma inputs. The module path is github.com/insei/valigo. # Start # valigo **valigo** is a validation library for Go structs. Rules are configured once, in code, by pointers to the fields of a model: `b.Field(&u.Name).As(str.Of).Required().MaxLen(64)`. The compiler checks that a rule fits the field type and that its arguments have the field type, the validation itself doesn't allocate on valid objects, and error messages are translated. One-line ideology *Rules are Go code next to the struct, not strings in tags: a typo or a wrong type doesn't compile.* ## Install ```bash go get github.com/insei/valigo@latest ``` Minimum Go version is **1.27**: the configurator uses generic methods. ## Quick start ```go package main import ( "context" "fmt" "regexp" "github.com/insei/valigo" "github.com/insei/valigo/rules/num" "github.com/insei/valigo/rules/str" ) type User struct { Name string Email string Age int Phone *string Tags []string } func main() { v := valigo.New() valigo.Configure[User](v, func(b valigo.Configurator[User], u *User) { b.Field(&u.Name).As(str.Of).Trim().Required().MaxLen(64) b.Field(&u.Email).As(str.Of).Required().Email() b.Field(&u.Age).As(num.Of).AnyOfInterval(18, 120) b.Field(&u.Phone).As(str.Of).Regexp(regexp.MustCompile(`^\+\d{10,15}$`)) // a nil phone is skipped b.Slice(&u.Tags).As(str.Slice).MaxLen(5).Each(func(tag *str.BaseConfigurator) { tag.Trim().Required().MaxLen(16) }) }) user := &User{Name: " Ann ", Email: "ann.example.com", Age: 16, Tags: []string{"go", " "}} for _, err := range v.Validate(context.Background(), user) { fmt.Println(err) } fmt.Printf("%q\n", user.Name) } ``` Output: ```text Should be email address (Email: ann.example.com) Only interval[18 - 120] is allowed (Age: 16) Should be fulfilled (Tags[1]: ) "Ann" ``` What happens here: 1. `valigo.New()` creates a validator with the messages in English, German, Spanish, French and Russian. 1. `valigo.Configure[User]` calls the function once with a *model*: a `*User` whose nested pointer structs are allocated. Rules are bound to the fields of the model by their pointers, `&u.Name`. 1. Every configurator method returns the configurator of the field, so rules are chained. They run in the order of the chain: `Trim` changes the value before `Required` checks it. 1. `v.Validate` runs the rules of the `*User` type and returns `nil` for a valid object. Every error has the message, the location of the field (`Tags[1]` for the element of a slice) and the value. ## Where to go next - [Why valigo?](https://insei.github.io/valigo/0.2/why-valigo/index.md) — what it gives compared to tag based validators. - [Features](https://insei.github.io/valigo/0.2/features/index.md) — the rules of every type, conditions, custom rules, errors, translations. - [How it works](https://insei.github.io/valigo/0.2/architecture/index.md) — the model, the field pointers and why valid objects don't allocate. - [Performance](https://insei.github.io/valigo/0.2/performance/index.md) — benchmarks with go-playground/validator, ozzo-validation, zog, gookit/validate and hand-written code. - [HTTP problem details](https://insei.github.io/valigo/0.2/features/problem/index.md) and [huma](https://insei.github.io/valigo/0.2/features/huma/index.md) — valigo in HTTP APIs. - [Upgrading from v0.0.x](https://insei.github.io/valigo/0.2/upgrading/index.md) — the configurator API has changed. - [For AI agents](https://insei.github.io/valigo/0.2/ai-agents/index.md) — the skill and the documentation in one file. # Why valigo? **valigo is for Go services that validate their input structs and want the rules checked by the compiler, the validation free on valid input, and the messages in the language of the user.** ## What you get ### Rules are code, checked by the compiler Rules are configured by pointers to the fields of the model, so the field and its type are known at compile time. `As` converts the field to the configurator of its type, and the conversion has a type constraint: ```go b.Field(&u.Name).As(str.Of).Required().MaxLen(64) // str.Of accepts string, *string and named strings b.Field(&u.ID).As(num.Of).Min(1) // Min takes an int64 for an int64 field b.Field(&u.ID).As(num.Of).Min("1") // doesn't compile b.Field(&u.Age).As(str.Of) // doesn't compile: int does not satisfy ~string | *string ``` A renamed field is renamed in the rules by the IDE, a removed field breaks the build instead of a silently skipped rule. Tag based validators keep the rules in strings, `validate:"required,max=64"`, and report a typo or a rule of a wrong type only at runtime. ### Any type, without changes of the library The configurator knows nothing about strings, numbers or uuids: they are conversions in their packages. A type of your domain, `time.Time` or a uuid of another package gets its rules by a conversion function of a few lines, see [Types and conversions](https://insei.github.io/valigo/0.2/features/conversions/index.md). ### Validation of valid objects doesn't allocate The rules are built once by `Configure`. `Validate` reads the fields through precomputed accessors and calls the rules of every field, allocations happen only to build errors. A struct with string, number, uuid and slice rules, conditions and a struct level custom rule is validated with 0 allocations — the tests check it. On the same rules a valid object is validated about 4 times faster than by go-playground/validator and 1.4 times slower than by the same rules written by hand, an invalid one with translated messages — about 2 times faster than go-playground/validator builds its messages, see [Performance](https://insei.github.io/valigo/0.2/performance/index.md). ### Absent values are handled, not panicked on A nil pointer field, a nil pointer to a slice and the fields of a nil nested struct are *absent*: every rule skips them, only `Required` fails. Optional fields need no special code: ```go b.Field(&u.Phone).As(str.Of).Regexp(phoneRe) // nil Phone is valid, a set one must match b.Field(&u.Discount).As(num.Ptr).Max(50) // nil Discount is valid b.Field(&u.Address.City).As(str.Of).Required() // fails for a nil Address too ``` ### Conditions and custom rules at every level `When` on the struct enables rules by the state of the object, `When` on a field — by the value of the field. `Custom` on a field gets its typed value, `Custom` on the struct gets the whole object and reports errors on any of its fields: ```go smtp := b.When(func(_ context.Context, s *Sender) bool { return s.Type == "smtp" }) smtp.Field(&s.Host).As(str.Of).Required() smtp.Field(&s.Port).As(num.Of).AnyOfInterval(1, 65535) ``` See [Conditions](https://insei.github.io/valigo/0.2/features/conditions/index.md) and [Custom rules](https://insei.github.io/valigo/0.2/features/custom/index.md). ### Messages in the language of the user, or codes for the client One validator serves all languages: the language of every request comes from the context, `NewAcceptLanguageMiddleware` puts the languages of the `Accept-Language` header there. The messages of the built-in rules are translated to English, German, Spanish, French and Russian with plural forms, your own messages are added to the same storage and work untranslated until you add translations. A client with its own locales gets a stable `Code` and named `Params` of every error instead. See [Translations](https://insei.github.io/valigo/0.2/features/translations/index.md) and [Codes for clients](https://insei.github.io/valigo/0.2/features/errors/#codes-for-clients). How the libraries compare, by their documentation: | | valigo | go-playground/validator | ozzo-validation | | --------------------------- | --------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- | --------------------------------------------- | | Translations out of the box | en, de, es, fr, ru | many languages, the packages of `validator/translations` | English messages | | Language of a request | from the context, one validator for all languages | a translator of `universal-translator` is picked and passed to `Translate` for every request | the messages are global | | Setup | none, `valigo.New()` | a translator per language and the registration of the translations of every tag | replacing the message templates of the errors | | Plural forms | CLDR categories in the locale files | cardinal rules of `universal-translator` | — | | Codes for clients | `Code` and named `Params` of every error | the tag and one string parameter of a field error | the code and the parameters of an error | | Messages of own rules | the message is its locale key, translated when a translation is added | a translation is registered for every tag | the message of the error | ### Slices, maps and other fields The rules of a slice check the slice itself and, by `Each`, every element with the rules of its type; maps have `EachKey` and `EachValue`; a rule can compare a field with another one by its pointer — `b.Field(&o.EndsAt).As(timex.Of).AfterField(&o.StartsAt)` — with the type of the other field checked by the compiler. See [Slices](https://insei.github.io/valigo/0.2/features/slices/index.md), [Maps](https://insei.github.io/valigo/0.2/features/maps/index.md) and [Rules on other fields](https://insei.github.io/valigo/0.2/features/fields/index.md). ### Ready for HTTP APIs The `problem` package writes the errors as `application/problem+json` of RFC 9457 with JSON pointers to the fields, the `github.com/insei/valigo/integrations/huma` module puts the rules into the inputs of huma operations with the locations of huma. See [HTTP problem details](https://insei.github.io/valigo/0.2/features/problem/index.md) and [huma](https://insei.github.io/valigo/0.2/features/huma/index.md). ### Errors that point to the field Every error has the message, the location and the value: `Tags[1]` for an element of a slice, `Address.City` for a nested field, or the JSON path of the field with a custom location function. See [Errors](https://insei.github.io/valigo/0.2/features/errors/index.md). ## When valigo is not the right tool - **The fields aren't known at compile time**, e.g. a form builder with fields defined by users: valigo validates the fields of Go structs. The limits and the conditions of the rules can come from the data — a database, a cache, the settings of a tenant — see [Rules from data](https://insei.github.io/valigo/0.2/features/custom/#rules-from-data). - **The input is not a struct**: valigo validates structs, decode maps and JSON into a struct first. - **Go older than 1.27**: the configurator uses generic methods. # Performance The [`bench`](https://github.com/Insei/valigo/tree/main/bench) module compares valigo with [go-playground/validator](https://github.com/go-playground/validator), [ozzo-validation](https://github.com/go-ozzo/ozzo-validation), [zog](https://github.com/Oudwins/zog), [gookit/validate](https://github.com/gookit/validate) and hand-written Go code on the same struct and the same rules: a required name up to 64 characters, a required email, an age in `[18, 120]`, up to 5 required tags up to 16 characters and a required city of a nested struct. A test checks that the libraries find the same errors. ```go b.Field(&u.Name).As(str.Of).Required().MaxRunes(64) b.Field(&u.Email).As(str.Of).Required().Email() b.Field(&u.Age).As(num.Of).AnyOfInterval(18, 120) b.Slice(&u.Tags).As(str.Slice).MaxLen(5).Each(func(tag *str.BaseConfigurator) { tag.Required().MaxRunes(16) }) b.Field(&u.Address.City).As(str.Of).Required() ``` ## Results Intel Core i9-14900HX, Go 1.27.0, one CPU (`-cpu 1`), the median of 6 runs. A valid object: | Library | ns/op | B/op | allocs/op | | ---------------------------------- | ------- | ----- | --------- | | hand-written code, the lower bound | 286 | 0 | 0 | | valigo | **407** | **0** | **0** | | zog | 1 690 | 6 | 2 | | go-playground/validator | 1 785 | 296 | 16 | | ozzo-validation | 2 950 | 2 000 | 38 | | gookit/validate | 6 880 | 4 560 | 65 | An invalid object with 6 errors: | Library | ns/op | B/op | allocs/op | | ------------------------------------------------------ | --------- | --------- | --------- | | hand-written code, errors without messages | 590 | 288 | 8 | | valigo, translated messages | **2 205** | **1 168** | **14** | | go-playground/validator, without messages | 2 665 | 1 688 | 45 | | go-playground/validator, with `Error()` of every error | 4 150 | 2 488 | 69 | | zog, issues with messages | 4 600 | 1 056 | 24 | | ozzo-validation, with `Error()` | 12 665 | 11 026 | 140 | | gookit/validate | 13 250 | 9 569 | 120 | valigo is about 1.4 times slower than the same rules written by hand on a valid object, it pays for the closures of the rules; it doesn't allocate either. The fields are read by the offsets of fmap without reflect, following the pointers of nested structs. Code generators of validators produce code like the hand-written one, so they are expected to be about as fast as it. - The [rules from tags](https://insei.github.io/valigo/0.2/features/tags/index.md) are the same rules: in one run a valid object took 392 ns by the tags of go-playground/validator and 373 ns by the rules in code, 0 allocations both; `max=64` is `MaxRunes`, `gte` and `lte` are two rules instead of one `AnyOfInterval`. - valigo builds the rules once in `Configure` and reads the fields by precomputed accessors, so valid objects cost no allocations; go-playground/validator parses the tags once too, but works through `reflect` on every call, ozzo-validation builds its rules on every call of `Validate`. - gookit/validate creates a validation per call, it is its API, and reports the errors of the elements of a slice under one key `Tags.*`; zog builds the messages of its issues. - valigo builds the translated messages of the errors at once, go-playground/validator returns the errors without messages and builds them by `Error` or `Translate`: compare valigo with the row "with `Error()`". - The rules of valigo append the errors to one pooled buffer, `Validate` copies it once; most allocations of an invalid object are the messages and the boxed values of the errors. ## Run ```bash cd bench go test -run XXX -bench . -benchmem -cpu 1 -count 6 ``` Every pull request gets a comment with the benchmarks of the core compared with the target branch by benchstat, the `bench-diff` workflow. # Upgrading from v0.0.x Rules are still configured by pointers to the fields of the model, but the configurator is typed now and knows nothing about the types of the values: a field is converted to the configurator of its type by `As`. Go 1.27 is required. | v0.0.x | Now | | ---------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | | `github.com/insei/valigo/str`, `github.com/insei/valigo/num` | `github.com/insei/valigo/rules/str`, `github.com/insei/valigo/rules/num`, also `rules/eq` and `rules/timex` | | `github.com/insei/valigo/uuid` of the core module | the separate module `github.com/insei/valigo/rules/uuid` | | — | the integrations are the separate modules `github.com/insei/valigo/integrations/huma`, `integrations/gin`, `integrations/echo` | | `b.String(&u.Name)` | `b.Field(&u.Name).As(str.Of)` | | `b.Number(&u.Age)` | `b.Field(&u.Age).As(num.Of)` | | `b.Number(&u.Score)` for `*int` | `b.Field(&u.Score).As(num.Ptr)` | | `b.UUID(&u.ID)` | `b.Field(&u.ID).As(vuuid.Of)` of the separate module `github.com/insei/valigo/rules/uuid` | | `b.StringSlice(&u.Tags)` | `b.Slice(&u.Tags).As(str.Slice)`, for `*[]string`: `b.Field(&u.Tags).As(shared.SlicePtr).As(str.Slice)` | | `b.UUIDSlice(&u.IDs)` | `b.Slice(&u.IDs).As(vuuid.Slice)` | | `b.Slice(any)` with `Custom(func(ctx, h, value []*any) ...)` | `b.Slice(&u.Items)` with `Custom(func(ctx, h, value shared.Elems[E]) ...)` and `Each` | | `Configurator[T]` is an interface | a struct with generic methods, the signature of the `Configure` function is the same | | field configurators are interfaces: `str.BaseConfigurator`, `num.BaseConfigurator` | pointers to concrete types: `*str.BaseConfigurator`, `*num.BaseConfigurator[N]`, `*vuuid.BaseConfigurator` | | `Max(any)`, `Min(any)`, `AnyOf(...any)`, `AnyOfInterval(any, any)` | the arguments have the type of the field, `Max(5)` works for `int64` | | `Custom(func(ctx, h, value any) ...)`, `When(func(ctx, value any) bool)` of fields | the value is typed: `string`, the number type, `uuid.UUID` | | `StringSlice(...).MaxLen(n)` | the same: the number of elements; rules on elements are set by `Each` | | `shared.NewSliceFieldConfigurator(params)`, `shared.FieldConfigurator` | `shared.NewSliceFieldConfigurator(field, helper, fields, add, describe)`, `shared.Field[T]` | ## Behavior changes - `Validate` returns `nil` for a valid object, it returned an empty slice. Check `len(errs) > 0` or `errs != nil`, both work now. - A struct value is validated by a copy, it was silently skipped. - Nil pointers and the fields of nil nested structs are skipped by rules, they panicked; `Required` fails on them. - `Required` of `num.Of` fails on zero, it did nothing. `Required` of `num.Ptr` fails on nil. - `AnyOfInterval(begin, end)` includes both ends. - The errors of slice elements have the index in the location: `Tags[1]`. - A field `When` isn't called for an absent value, it got the zero value. - A repeated `Configure` of the same type replaces its rules, it appended them. - `shared.Error` has `Code` and `Params`, its JSON has them when they aren't empty; the messages of counts have plural forms: "longer than 1 character". # For AI agents AI agents often write the code that uses a library, and a model trained before the typed configurator doesn't know its API: it writes `b.String(&u.Name)` of the old API instead of `b.Field(&u.Name).As(str.Of)`, or `Max(int64(5))` with an `any` argument. Give the agent the current documentation. ## The skill [`skills/valigo/SKILL.md`](https://github.com/Insei/valigo/blob/main/skills/valigo/SKILL.md) is a short guide for agents in the [Agent Skills](https://agentskills.io) format: the rules, complete programs, the configurators with their rules and the common mistakes. It is versioned with the code, and CI builds and runs its programs against the code of the repository, so it doesn't describe the API that doesn't exist. Claude Code finds the skill in the `.claude/skills` directory of the project or of the user: ```sh mkdir -p .claude/skills/valigo curl -fsSL https://raw.githubusercontent.com/Insei/valigo/main/skills/valigo/SKILL.md \ -o .claude/skills/valigo/SKILL.md ``` Replace `main` with the release tag to get the skill of the version in your `go.mod`. Other agents read it from any path: mention it in `AGENTS.md` or in the instructions of the agent. The skill describes the library, not your project. Keep the rules of your project — where the validators are configured, how the errors are returned to the clients — in your own instructions next to it. ## llms.txt The documentation is published in the [llms.txt](https://llmstxt.org) format for every version: - [`llms.txt`](https://insei.github.io/valigo/latest/llms.txt) — the index of the pages with descriptions; - [`llms-full.txt`](https://insei.github.io/valigo/latest/llms-full.txt) — the whole documentation as one Markdown file, to put into the context of the agent. Replace `latest` with the version, or with `dev` for the documentation of the `main` branch. # Configuration # Features The map of valigo, in the order of the menu. ## Configuration How the rules are configured and composed. | Page | What's inside | | ----------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | | [Validator and Configure](https://insei.github.io/valigo/0.2/features/configure/index.md) | `New` and its options, `Configure`, the model, field pointers, nested and recursive structs | | [Types and conversions](https://insei.github.io/valigo/0.2/features/conversions/index.md) | `Field`, `Slice`, `Map` and `As`, the built-in conversions, conversions of your types | | [Absent values](https://insei.github.io/valigo/0.2/features/absent/index.md) | nil pointers, nil slices and nil nested structs: which rules skip them | | [Conditions](https://insei.github.io/valigo/0.2/features/conditions/index.md) | `When` on the struct and on a field, conditional requiredness | | [Custom rules](https://insei.github.io/valigo/0.2/features/custom/index.md) | `Custom` on a field, on a slice and on the struct, rules with limits from data | | [Rules on other fields](https://insei.github.io/valigo/0.2/features/fields/index.md) | `EqualField`, `MinField`, `MaxField`, `BeforeField`, `AfterField` by the pointer of the other field | | [Rules from tags](https://insei.github.io/valigo/0.2/features/tags/index.md) | `Tags`: the tags of go-playground/validator, `validate:"required,max=64"`, for a migration | ## Rules The built-in rules of every type. | Page | What's inside | | -------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | [Strings](https://insei.github.io/valigo/0.2/features/strings/index.md) | `str.Of`: lengths, `AnyOf`, substrings, `Regexp`, `Email`, `URL`, `IP`, `CIDR`, `MAC`, `Hostname`, `UUID`, `E164`, `Base64`, `JSON`, `Semver`, `Datetime`, ISO codes, `CreditCard`, `Trim` | | [Numbers](https://insei.github.io/valigo/0.2/features/numbers/index.md) | `num.Of`, `num.Ptr`: `Required`, `Min`, `Max`, `AnyOf`, `AnyOfInterval`, `Latitude`, `Longitude` | | [Time](https://insei.github.io/valigo/0.2/features/time/index.md) | `timex.Of`: `Required`, `Before`, `After`, `Between`, `InPast`, `InFuture` | | [Comparable values](https://insei.github.io/valigo/0.2/features/values/index.md) | `eq.Of`, `eq.Ptr`, `eq.Slice`: enums, bools — `OneOf`, `NotOneOf`, `Equal`, `NotEqual`, `Unique` | | [UUID](https://insei.github.io/valigo/0.2/features/uuid/index.md) | `uuid.Of` of the separate module: `Required`, `AnyOf` | | [Slices](https://insei.github.io/valigo/0.2/features/slices/index.md) | `Slice` and the slice conversions: length rules, `Each` for the elements | | [Maps](https://insei.github.io/valigo/0.2/features/maps/index.md) | `Map`: length rules, `EachKey` and `EachValue` with conversions | ## Errors What a validation returns. | Page | What's inside | | ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------ | | [Errors](https://insei.github.io/valigo/0.2/features/errors/index.md) | `Validate` and `ValidateTyped`, `shared.Error`, locations, codes for clients, errors transformer | | [Translations](https://insei.github.io/valigo/0.2/features/translations/index.md) | built-in locales, plural forms, own messages, languages from the context, `Accept-Language` middleware | | [HTTP problem details](https://insei.github.io/valigo/0.2/features/problem/index.md) | `problem`: the errors as `application/problem+json` of RFC 9457 with JSON pointers and codes | ## Integrations The separate modules for the frameworks. | Page | What's inside | | -------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | | [JSON Schema](https://insei.github.io/valigo/0.2/features/schema/index.md) | the rules as the keywords of JSON Schema for the generators of API schemas, `Constraints` | | [huma](https://insei.github.io/valigo/0.2/features/huma/index.md) | `github.com/insei/valigo/integrations/huma`: the rules in the `Resolve` of huma inputs with the locations of huma and in its OpenAPI document | | [gin](https://insei.github.io/valigo/0.2/features/gin/index.md) | `github.com/insei/valigo/integrations/gin`: the `binding.StructValidator` of gin and validation with the language of the request | | [go-i18n](https://insei.github.io/valigo/0.2/features/goi18n/index.md) | `github.com/insei/valigo/integrations/goi18n`: the messages from a bundle of go-i18n, the built-in ones for the rest | | [echo](https://insei.github.io/valigo/0.2/features/echo/index.md) | `github.com/insei/valigo/integrations/echo`: the `echo.Validator` and validation with the language of the request | # Validator and Configure ## Validator `valigo.New` creates a validator. It holds the rules of all configured types and the translator of the messages, create one per application and share it. ```go v := valigo.New( valigo.WithTranslator(t), // translator of the messages, see Translations valigo.WithFieldLocationNamingFn(jsonPath), // location of the field in errors, see Errors valigo.WithErrorsTransformer(toProblemFields), // []shared.Error to []error for Validate, see Errors ) ``` All options are optional, a `nil` argument keeps the default. ## Configure `valigo.Configure[T]` configures the rules of the struct type `T`: ```go valigo.Configure[User](v, func(b valigo.Configurator[User], u *User) { b.Field(&u.Name).As(str.Of).Required() }) ``` The function is called once, with: - `b` — the configurator of `T`: `Field`, `Slice` and `Map` return the base configurators of fields, converted to the configurators of their types by `As`, see [Types and conversions](https://insei.github.io/valigo/0.2/features/conversions/index.md); [`When`](https://insei.github.io/valigo/0.2/features/conditions/index.md) and [`Custom`](https://insei.github.io/valigo/0.2/features/custom/index.md) are the rules of the struct; - `u` — the *model*, a `*User` created by `Configure`. Rules are bound to the fields of the model by their pointers. The rules are applied to objects of type `*T`: `v.Validate(ctx, &user)`. A struct value is validated too, by a copy, see [Errors](https://insei.github.io/valigo/0.2/features/errors/#validate-and-validatetyped). `Configure` is called on the application start. It isn't safe to call concurrently with `Validate`, and it **panics** on invalid configuration: a configuration error is a bug of the code, not a state of the running service. A repeated `Configure` of the same type replaces its rules. ## Field pointers A field pointer must point to a field of the model passed to the function: ```go valigo.Configure[User](v, func(b valigo.Configurator[User], u *User) { b.Field(&u.Name) // a field of the model b.Field(&u.Address.City) // a field of a nested struct of the model var name string b.Field(&name) // panic: *string is not a pointer to a field of *main.User: field not found }) ``` Types of the fields are checked by the compiler: `b.Field(&u.Age).As(str.Of)` doesn't compile for an `int` field, the conversions accept only the types they support. ## Nested structs Fields of nested structs are configured by the same pointers. The model allocates all nested structs stored by pointer, so `&u.Address.City` is valid even if `Address` is `*Address`: ```go package main import ( "context" "fmt" "github.com/insei/valigo" "github.com/insei/valigo/rules/str" ) type Address struct { City string } type User struct { Name string Home Address Work *Address Manager *User // recursive types are supported, the model doesn't allocate Manager } func main() { v := valigo.New() valigo.Configure[User](v, func(b valigo.Configurator[User], u *User) { b.Field(&u.Home.City).As(str.Of).Required() b.Field(&u.Work.City).As(str.Of).Required() }) fmt.Println(v.Validate(context.Background(), &User{Work: &Address{}})) fmt.Println(v.Validate(context.Background(), &User{Home: Address{City: "Berlin"}})) } ``` Output: ```text [Should be fulfilled (Home.City: ) Should be fulfilled (Work.City: )] [Should be fulfilled (Work.City: )] ``` When the validated object has a nil `Work`, the field `Work.City` is absent: rules skip it and `Required` fails, see [Absent values](https://insei.github.io/valigo/0.2/features/absent/index.md). Embedded structs work the same way, `&u.ID` for a field of an embedded `Base`. Unexported fields are allowed in the model, and unexported fields of the model itself can be configured too. ## Reusing rules Rules of nested types aren't inherited: configuring `Address` doesn't add its rules to `User`. Share the rules by a function: ```go func addressRules[T any](b valigo.Configurator[T], a *Address) { b.Field(&a.City).As(str.Of).Required() } valigo.Configure[User](v, func(b valigo.Configurator[User], u *User) { addressRules(b, &u.Home) addressRules(b, u.Work) }) ``` # Types and conversions The configurator knows nothing about the types of the values. It has two methods for the fields: - `b.Field(&m.X)` returns `*shared.Field[T]`, the base configurator of a field of type `T`; - `b.Slice(&m.X)` returns `*shared.SliceFieldConfigurator[E]` of a `[]E` field, with the rules of the slice itself: `Required`, `MinLen`, `MaxLen`, `Custom`, `When` and `Each`; - `b.Map(&m.X)` returns `*shared.MapFieldConfigurator[K, V]` of a `map[K]V` field, with the rules of the map itself, `EachKey` and `EachValue`, see [Maps](https://insei.github.io/valigo/0.2/features/maps/index.md). `As` converts a base configurator to the configurator of its type. A conversion is a function with a type constraint, so a conversion that doesn't fit the field doesn't compile: ```go b.Field(&u.Name).As(str.Of).Required() // string b.Field(&u.Discount).As(num.Ptr).Max(50) // *int b.Slice(&u.Tags).MaxLen(5).As(str.Slice).Email() b.Field(&u.Age).As(str.Of) // int does not satisfy ~string | *string (int missing in ~string | *string) ``` ## Built-in conversions | Conversion | Fields | Configurator | | ----------------- | ------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- | | `str.Of` | `string`, `*string`, named strings | [strings](https://insei.github.io/valigo/0.2/features/strings/index.md) | | `num.Of` | numbers and named numbers | [numbers](https://insei.github.io/valigo/0.2/features/numbers/index.md) | | `num.Slice` | `Slice` of numbers | [slices](https://insei.github.io/valigo/0.2/features/slices/index.md) with the number rules of elements | | `num.Ptr` | pointers to numbers | [numbers](https://insei.github.io/valigo/0.2/features/numbers/index.md), `Required` checks only that the pointer isn't nil | | `timex.Of` | `time.Time`, `*time.Time` | [time](https://insei.github.io/valigo/0.2/features/time/index.md) | | `eq.Of`, `eq.Ptr` | any `comparable` type and pointers to it: enums, `bool` | [comparable values](https://insei.github.io/valigo/0.2/features/values/index.md) | | `vuuid.Of` | `uuid.UUID`, `*uuid.UUID` of `github.com/google/uuid` | [uuid](https://insei.github.io/valigo/0.2/features/uuid/index.md), the separate module `github.com/insei/valigo/rules/uuid` | | `str.Slice` | `Slice` of strings, `*string` or named strings | [slices](https://insei.github.io/valigo/0.2/features/slices/index.md) with the string rules of elements | | `timex.Slice` | `Slice` of `time.Time`, `*time.Time` | [slices](https://insei.github.io/valigo/0.2/features/slices/index.md) with the time rules of elements | | `eq.Slice` | `Slice` of a `comparable` type | [slices](https://insei.github.io/valigo/0.2/features/slices/index.md) with `Unique` and the equality rules of elements | | `vuuid.Slice` | `Slice` of `uuid.UUID`, `*uuid.UUID` | [slices](https://insei.github.io/valigo/0.2/features/slices/index.md) with the uuid rules of elements | | `shared.SlicePtr` | `Field` of `*[]E` | the slice configurator, a nil pointer is absent | The elements of a slice are converted in `Each`: `b.Slice(&m.Sizes).Each(func(e *shared.Field[int]) { e.As(num.Of).Min(1) })`. ## The base rules `shared.Field[T]` has the primitives all configurators are built of. They are enough for a one-off rule without a conversion: | Rule | Behavior | | ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | | `Check(ok, key, args...)` | an error with the message `key` when `ok(value)` is false, absent values are skipped | | `Required(ok, key)` | an error when the value is absent or `ok(value)` is false | | `Modify(fn)` | changes the value in place, e.g. trims it | | `Custom(fn)` | your function with the typed value, see [Custom rules](https://insei.github.io/valigo/0.2/features/custom/index.md) | | `When(cond)` | the next rules are applied only if `cond(value)` is true, see [Conditions](https://insei.github.io/valigo/0.2/features/conditions/index.md) | ```go b.Field(&o.Paid).Check(func(paid bool) bool { return paid }, "Should be paid") ``` ## Your types A conversion of your type is a function that takes the base configurator and returns your configurator over `shared.Field` of the type. Use the conversions of `shared` for pointers and named types: - `shared.Deref` converts the rules of `*T` to the rules of `T`, a nil pointer is absent; - `shared.Retype` converts the rules of `T` to the rules of `U` with the same memory layout, e.g. of a named type; - `shared.RetypeSlice` does the same for a slice configurator, also for the elements stored by pointer; - `shared.SliceRules` gives `Required`, `MinLen`, `MaxLen`, `Custom` and `When` of the slice to your slice configurator, see the source of `str.Slice`; - `CheckMsg` and `RequiredMsg` take a message of `shared.M` with a stable [code](https://insei.github.io/valigo/0.2/features/errors/#codes-for-clients) and named parameters: `c.f.CheckMsg(ok, shared.M("time.after", key, shared.P("time", t)))`. A configurator of `time.Time` (the built-in one is [`timex`](https://insei.github.io/valigo/0.2/features/time/index.md), this one shows how it's made): ```go package main import ( "context" "fmt" "time" "github.com/insei/valigo" "github.com/insei/valigo/shared" ) // TimeConfigurator configures the rules of a time.Time value. type TimeConfigurator struct { f *shared.Field[time.Time] } // TimeOf converts a time.Time or *time.Time field: b.Field(&m.At).As(TimeOf). func TimeOf[T time.Time | *time.Time](f *shared.Field[T]) *TimeConfigurator { if p, ok := any(f).(*shared.Field[*time.Time]); ok { return &TimeConfigurator{f: shared.Deref(p)} } return &TimeConfigurator{f: shared.Retype[T, time.Time](f)} } func (c *TimeConfigurator) Required() *TimeConfigurator { c.f.Required(func(v time.Time) bool { return !v.IsZero() }, "Should be fulfilled") return c } func (c *TimeConfigurator) After(t time.Time) *TimeConfigurator { c.f.Check(func(v time.Time) bool { return v.After(t) }, "Should be after %s", t.Format(time.DateOnly)) return c } type Event struct { StartsAt time.Time EndsAt *time.Time } func main() { start := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) v := valigo.New() valigo.Configure[Event](v, func(b valigo.Configurator[Event], e *Event) { b.Field(&e.StartsAt).As(TimeOf).Required().After(start) b.Field(&e.EndsAt).As(TimeOf).After(start) // a nil EndsAt is skipped }) before := start.AddDate(0, 0, -1) fmt.Println(v.Validate(context.Background(), &Event{})) fmt.Println(v.Validate(context.Background(), &Event{StartsAt: start.AddDate(0, 1, 0), EndsAt: &before})) } ``` Output: ```text [Should be fulfilled (StartsAt: 0001-01-01 00:00:00 +0000 UTC) Should be after 2026-01-01 (StartsAt: 0001-01-01 00:00:00 +0000 UTC)] [Should be after 2026-01-01 (EndsAt: 2025-12-31 00:00:00 +0000 UTC)] ``` The type constraint of `TimeOf` makes `b.Field(&e.Title).As(TimeOf)` a compile error for a `string` field. The rules of another uuid package are added the same way, see the source of [`github.com/insei/valigo/rules/uuid`](https://github.com/Insei/valigo/tree/main/rules/uuid). # Absent values A value is *absent* when there is nothing to check: - a nil pointer field: `*string`, `*int`, `*uuid.UUID`; - a nil pointer to a slice: `*[]string`; - a field of a nested struct stored by a nil pointer: `City` of a nil `*Address`; - a nil element of a slice of pointers: `[]*string{nil}`. Every rule skips absent values, only `Required` fails on them. So an optional field needs no special code: the rules check it only when it is set. ```go package main import ( "context" "fmt" "github.com/insei/valigo" "github.com/insei/valigo/rules/num" "github.com/insei/valigo/rules/str" "github.com/insei/valigo/shared" ) type Address struct { City string } type Order struct { Comment *string Discount *int Coupons *[]string Delivery *Address } func main() { v := valigo.New() valigo.Configure[Order](v, func(b valigo.Configurator[Order], o *Order) { b.Field(&o.Comment).As(str.Of).MaxLen(200) b.Field(&o.Discount).As(num.Ptr).AnyOfInterval(1, 50) b.Field(&o.Coupons).As(shared.SlicePtr).As(str.Slice).MaxLen(3) b.Field(&o.Delivery.City).As(str.Of).Required() }) // nothing is set: only the Required rule fails fmt.Println(v.Validate(context.Background(), &Order{})) discount := 70 fmt.Println(v.Validate(context.Background(), &Order{Discount: &discount, Delivery: &Address{City: "Paris"}})) } ``` Output: ```text [Should be fulfilled (Delivery.City: )] [Only interval[1 - 50] is allowed (Discount: 70)] ``` ## Required What `Required` checks depends on the type: | Conversion | Fails on | | ---------------------------------- | ------------------------------------------------ | | `str.Of` | absent value, empty string | | `num.Of` | zero | | `num.Ptr` | absent value; zero is a valid value of a pointer | | `uuid.Of` | absent value, `uuid.Nil` | | `Slice`, `str.Slice`, `uuid.Slice` | absent slice, empty slice | Nil slices of value fields (`[]string(nil)`) are present and empty: length rules check them, `Each` has nothing to check. ## Conditions A field `When` doesn't call its condition for an absent value and skips its rules, including `Required`: the condition is about the value, and there is no value. Put `Required` before `When`, or use `When` of the struct, if the field is required under a condition. See [Conditions](https://insei.github.io/valigo/0.2/features/conditions/index.md). # Conditions ## When on the struct `When` of the configurator returns a configurator whose rules are applied only if the function returns true for the validated object: ```go package main import ( "context" "fmt" "github.com/insei/valigo" "github.com/insei/valigo/rules/num" "github.com/insei/valigo/rules/str" ) type Sender struct { Type string SMTPHost string SMTPPort int HTTPURL string } func main() { v := valigo.New() valigo.Configure[Sender](v, func(b valigo.Configurator[Sender], s *Sender) { b.Field(&s.Type).As(str.Of).AnyOf("smtp", "http") smtp := b.When(func(_ context.Context, s *Sender) bool { return s.Type == "smtp" }) smtp.Field(&s.SMTPHost).As(str.Of).Required() smtp.Field(&s.SMTPPort).As(num.Of).AnyOfInterval(1, 65535) http := b.When(func(_ context.Context, s *Sender) bool { return s.Type == "http" }) http.Field(&s.HTTPURL).As(str.Of).Required() }) fmt.Println(v.Validate(context.Background(), &Sender{Type: "smtp"})) fmt.Println(v.Validate(context.Background(), &Sender{Type: "http"})) } ``` Output: ```text [Should be fulfilled (SMTPHost: ) Only interval[1 - 65535] is allowed (SMTPPort: 0)] [Should be fulfilled (HTTPURL: )] ``` - The returned configurator has all methods of the original one, the conditions of chained `When` calls are combined with AND: `b.When(a).When(b)`. - `When` doesn't change the configurator it is called on: `b` keeps adding unconditional rules. - The context passed to `Validate` is passed to the condition, e.g. to enable rules by the role of the user. ## When on a field `When` of a field configurator returns a configurator of the same field whose rules are applied only if the function returns true for the value of the field. The rules before `When` stay unconditional: ```go b.Field(&u.Website).As(str.Of). Trim(). // always When(func(_ context.Context, v string) bool { return v != "" }). Regexp(urlRe) // only for a non-empty value ``` The value is typed: `string` for `str.Of`, the number type for `num.Of` and `num.Ptr`, `uuid.UUID` for `uuid.Of`, and `shared.Elems[E]` for slices, see [Slices](https://insei.github.io/valigo/0.2/features/slices/#custom-and-when). For an [absent value](https://insei.github.io/valigo/0.2/features/absent/index.md) the condition isn't called and the rules after it are skipped, including `Required`. ## Conditional requiredness Tag based validators have families of tags like `required_if`, `required_with` and `required_without`. In valigo they are `When` with a condition on the fields: ```go package main import ( "context" "fmt" "github.com/insei/valigo" "github.com/insei/valigo/rules/str" "github.com/insei/valigo/shared" ) type Customer struct { Kind string TaxID string Phone string Email string Company string Title string } func main() { v := valigo.New() valigo.Configure[Customer](v, func(b valigo.Configurator[Customer], c *Customer) { // required_if=Kind company b.When(func(_ context.Context, c *Customer) bool { return c.Kind == "company" }).Field(&c.TaxID).As(str.Of).Required() // required_without=Email: a phone or an email b.When(func(_ context.Context, c *Customer) bool { return c.Email == "" }).Field(&c.Phone).As(str.Of).Required() // required_with=Company: the title of the person in the company b.When(func(_ context.Context, c *Customer) bool { return c.Company != "" }).Field(&c.Title).As(str.Of).Required() // excluded_unless=Kind company b.When(func(_ context.Context, c *Customer) bool { return c.Kind != "company" }).Field(&c.Company).As(str.Of). Custom(func(ctx context.Context, h *shared.FieldCustomHelper, company string) []shared.Error { if company != "" { return []shared.Error{h.ErrorT(ctx, company, "Only companies have a company")} } return nil }) }) fmt.Println(v.Validate(context.Background(), &Customer{Kind: "company"})) fmt.Println(v.Validate(context.Background(), &Customer{Kind: "person", Email: "a@b.cc", Company: "ACME"})) } ``` Output: ```text [Should be fulfilled (TaxID: ) Should be fulfilled (Phone: )] [Should be fulfilled (Title: ) Only companies have a company (Company: ACME)] ``` ## Conditions and custom rules A condition on several fields can also be written as a [struct custom rule](https://insei.github.io/valigo/0.2/features/custom/#struct-rules), which reports errors itself. Prefer `When`: the rules after it are the regular ones, with their messages and translations. # Custom rules ## Field rules `Custom` of a field configurator adds a rule with your logic. The function gets the typed value of the field and a helper that builds errors with the location of the field: ```go package main import ( "context" "fmt" "strings" "github.com/insei/valigo" "github.com/insei/valigo/rules/num" "github.com/insei/valigo/rules/str" "github.com/insei/valigo/shared" ) type Account struct { Login string Age int } func main() { v := valigo.New() valigo.Configure[Account](v, func(b valigo.Configurator[Account], a *Account) { b.Field(&a.Login).As(str.Of).Required().Custom(func(ctx context.Context, h *shared.FieldCustomHelper, login string) []shared.Error { if strings.EqualFold(login, "admin") { return []shared.Error{h.ErrorT(ctx, login, "Login %q is reserved", login)} } return nil }) b.Field(&a.Age).As(num.Of).Custom(func(ctx context.Context, h *shared.FieldCustomHelper, age int) []shared.Error { if age%2 != 0 { return []shared.Error{h.ErrorT(ctx, age, "Should be even")} } return nil }) }) fmt.Println(v.Validate(context.Background(), &Account{Login: "Admin", Age: 3})) } ``` Output: ```text [Login "Admin" is reserved (Login: Admin) Should be even (Age: 3)] ``` `h.ErrorT(ctx, value, localeKey, args...)` translates the message: `localeKey` is looked up in the [translations](https://insei.github.io/valigo/0.2/features/translations/index.md), a key without a translation is used as is, as a format for `args`. The [code](https://insei.github.io/valigo/0.2/features/errors/#codes-for-clients) of the error is the key. For a stable code and named parameters build the message by `shared.M` and return `h.ErrorMsg(ctx, value, msg)`: ```go return []shared.Error{h.ErrorMsg(ctx, login, shared.M("login.reserved", "Login %q is reserved", shared.P("login", login)))} ``` The values of `shared.P` are the format arguments of the message in their order. A message without the checked value can be created once on the configuration, its parameters are shared by the errors. The function isn't called for an [absent value](https://insei.github.io/valigo/0.2/features/absent/index.md). ## Rules on slice elements The `Each` of `Slice` gives a `*shared.Field[E]` of the elements. Besides `Custom` and `When` it has `Check`, the base of all built-in rules: a predicate and a message for the elements that don't match it. ```go b.Slice(&o.Quantities).Each(func(q *shared.Field[int]) { q.Check(func(v int) bool { return v > 0 }, "Should be positive") }) ``` `Required(ok, localeKey)` fails for absent elements and the elements that don't match `ok`, `Modify(fn)` changes the elements in place. See [Slices](https://insei.github.io/valigo/0.2/features/slices/index.md). ## Struct rules `Custom` of the struct configurator gets the whole object, for the rules on several fields. `h.ErrorT` takes the pointer to the field of the *validated object* to build its location: ```go package main import ( "context" "fmt" "time" "github.com/insei/valigo" "github.com/insei/valigo/shared" ) type Booking struct { From time.Time To time.Time } func main() { v := valigo.New() valigo.Configure[Booking](v, func(b valigo.Configurator[Booking], _ *Booking) { b.Custom(func(ctx context.Context, h shared.StructCustomHelper, obj *Booking) []shared.Error { if !obj.To.After(obj.From) { return []shared.Error{h.ErrorT(ctx, &obj.To, obj.To.Format(time.DateOnly), "Should be after %s", obj.From.Format(time.DateOnly))} } return nil }) }) from := time.Date(2026, 5, 10, 0, 0, 0, 0, time.UTC) fmt.Println(v.Validate(context.Background(), &Booking{From: from, To: from.AddDate(0, 0, -1)})) } ``` Output: ```text [Should be after 2026-05-10 (To: 2026-05-09)] ``` - Struct `Custom` can be combined with [`When`](https://insei.github.io/valigo/0.2/features/conditions/index.md): `b.When(cond).Custom(fn)`. - The helper is valid only during the call of the function, don't keep it. - A pointer that doesn't point to a field of the validated object panics. ## Rules from data The rules are configured once, but they don't have to be constant: a `Custom` rule is a closure, it can read its limits from a database, a cache or the settings of a tenant on every validation, and it gets the context of the request. A `When` condition can do the same. Use the built-in message keys and codes, and the errors are translated and coded like the built-in ones: ```go package main import ( "context" "fmt" "unicode/utf8" "github.com/insei/valigo" "github.com/insei/valigo/rules/str" "github.com/insei/valigo/shared" ) type tenantKey struct{} // Limits are the settings of the tenants, a database or a cache in a real service. type Limits map[string]int func (l Limits) MaxTitle(ctx context.Context) int { tenant, _ := ctx.Value(tenantKey{}).(string) return l[tenant] } type Post struct { Title string } func main() { limits := Limits{"free": 10, "pro": 100} v := valigo.New() valigo.Configure[Post](v, func(b valigo.Configurator[Post], p *Post) { b.Field(&p.Title).As(str.Of).Required().Custom(func(ctx context.Context, h *shared.FieldCustomHelper, title string) []shared.Error { if maxLen := limits.MaxTitle(ctx); utf8.RuneCountInString(title) > maxLen { msg := shared.M("string.max_runes", "validation:string:Cannot be longer than %d characters", shared.P("max", maxLen)) return []shared.Error{h.ErrorMsg(ctx, title, msg)} } return nil }) }) post := &Post{Title: "Rules from the data"} free := context.WithValue(context.Background(), tenantKey{}, "free") pro := context.WithValue(context.Background(), tenantKey{}, "pro") fmt.Println(v.Validate(free, post)) fmt.Println(v.Validate(pro, post)) } ``` Output: ```text [Cannot be longer than 10 characters (Title: Rules from the data)] [] ``` The message of the error is translated by the plural forms of the built-in key, and the client gets the code `string.max_runes` with the `max` of the tenant. The module [`examples/limits`](https://github.com/Insei/valigo/tree/main/examples/limits) is a fuller example: the limits of a title and of the number of tags by the plan of the tenant from a store that changes at runtime, and the rules of a field enabled by a feature flag. # Rules on other fields A rule can compare the value with the value of another field of the same object. The other field is passed by its pointer to the model, like the field itself, so its type is checked by the compiler and a pointer that isn't a field of the model panics in `Configure`: ```go b.Field(&o.EndsAt).As(timex.Of).AfterField(&o.StartsAt) b.Field(&o.Price).As(num.Of).MinField(&o.MinPrice).MaxField(&o.MaxPrice) b.Field(&u.Confirmation).As(str.Of).EqualField(&u.Password) b.Field(&u.Plan).As(eq.Of).EqualField(&u.CurrentPlan) ``` | Configurator | Rule | Fails when | Message | | -------------------------------------------------------------------------------- | ------------------- | ----------------------------- | ------------------------- | | [strings](https://insei.github.io/valigo/0.2/features/strings/index.md) | `EqualField(&m.X)` | the value isn't equal to `X` | Should be equal to %s | | [numbers](https://insei.github.io/valigo/0.2/features/numbers/index.md) | `MinField(&m.X)` | the value is less than `X` | Cannot be less than %v | | [numbers](https://insei.github.io/valigo/0.2/features/numbers/index.md) | `MaxField(&m.X)` | the value is greater than `X` | Cannot be greater than %v | | [time](https://insei.github.io/valigo/0.2/features/time/index.md) | `BeforeField(&m.X)` | the value isn't before `X` | Should be before %s | | [time](https://insei.github.io/valigo/0.2/features/time/index.md) | `AfterField(&m.X)` | the value isn't after `X` | Should be after %s | | [comparable values](https://insei.github.io/valigo/0.2/features/values/index.md) | `EqualField(&m.X)` | the value isn't equal to `X` | Should be equal to %s | - The other field has the type of the configurator: `string` for `str.Of`, the number type for `num.Of`, `time.Time` for `timex.Of`. - The message has the location of the other field, by the [location function](https://insei.github.io/valigo/0.2/features/errors/#location-of-fields) of the validator: `Should be after StartsAt`, or `Should be after starts_at` with JSON paths. - The rule is skipped when the value or the value of the other field is [absent](https://insei.github.io/valigo/0.2/features/absent/index.md), e.g. a field of a nil nested struct. - In `Each` the elements are compared with a field of the object: `e.As(timex.Of).AfterField(&m.StartsAt)`. ## Example ```go package main import ( "context" "fmt" "time" "github.com/insei/valigo" "github.com/insei/valigo/rules/num" "github.com/insei/valigo/rules/str" "github.com/insei/valigo/rules/timex" ) type Auction struct { StartsAt time.Time EndsAt time.Time MinBid int Bids []int Password string Repeat string } func main() { v := valigo.New() valigo.Configure[Auction](v, func(b valigo.Configurator[Auction], a *Auction) { b.Field(&a.EndsAt).As(timex.Of).AfterField(&a.StartsAt) b.Slice(&a.Bids).As(num.Slice).Each(func(bid *num.BaseConfigurator[int]) { bid.MinField(&a.MinBid) }) b.Field(&a.Repeat).As(str.Of).EqualField(&a.Password) }) start := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC) auction := &Auction{ StartsAt: start, EndsAt: start.Add(-time.Hour), MinBid: 100, Bids: []int{150, 90}, Password: "secret", Repeat: "Secret", } for _, err := range v.Validate(context.Background(), auction) { fmt.Println(err) } } ``` Output: ```text Should be after StartsAt (EndsAt: 2026-04-30 23:00:00 +0000 UTC) Cannot be less than MinBid (Bids[1]: 90) Should be equal to Password (Repeat: Secret) ``` For a comparison of several fields that doesn't fit one rule, use a [struct custom rule](https://insei.github.io/valigo/0.2/features/custom/#struct-rules). # Rules from tags `b.Tags("validate")` configures the rules of the tags of the fields of the struct and of its nested structs in the syntax of [go-playground/validator](https://github.com/go-playground/validator): `validate:"required,max=64"`. It is for a migration: the structs keep their tags, and the rules move to code one by one — the tags and the rules in code are mixed in one `Configure`. The rules of the tags are the rules of valigo: the same messages in the languages of the users, the [codes](https://insei.github.io/valigo/0.2/features/errors/#codes-for-clients), the [JSON Schema](https://insei.github.io/valigo/0.2/features/schema/index.md) and the speed — a valid object is validated without allocations, see [Performance](https://insei.github.io/valigo/0.2/performance/index.md). ## Example ```go package main import ( "context" "fmt" "github.com/insei/valigo" "github.com/insei/valigo/rules/str" ) type Address struct { City string `validate:"required,max=32"` } type SignUp struct { Email string `validate:"required,email"` Password string `validate:"required,min=8"` Confirm string `validate:"eqfield=Password"` Age int `validate:"omitempty,gte=18,lte=120"` Tags []string `validate:"max=3,dive,required,max=16"` Address *Address // the rules of City apply when the address is present Nick string } func main() { v := valigo.New() valigo.Configure[SignUp](v, func(b valigo.Configurator[SignUp], s *SignUp) { b.Tags("validate") // the rules in code with the tags b.Field(&s.Nick).As(str.Of).Trim().Alphanumeric() }) errs := v.ValidateTyped(context.Background(), &SignUp{ Email: "ann", Password: "secret", Confirm: "secrets", Age: 16, Tags: []string{"go", ""}, Nick: " a b ", }) for _, err := range errs { fmt.Println(err.Location, "-", err.Message, "-", err.Code) } } ``` Output: ```text Email - Should be email address - string.email Password - Cannot be shorter than 8 characters - string.min_runes Confirm - Should be equal to Password - string.equal_field Age - Cannot be less than 18 - number.min Tags[1] - Should be fulfilled - string.required Nick - Should contain only letters and digits - string.alphanumeric ``` ## Rules | Fields | Tags of go-playground/validator | Rules of valigo | | -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | all | `required` | `Required`: a string is not empty, a number is not zero, a bool is `true`, a slice and a map have elements, a pointer is not nil | | all | `omitempty`, `omitnil` | the next rules are skipped for the zero value; a nil pointer is absent anyway | | all | `required_if=F v`, `required_unless=F v`, `required_with=F G`, `required_with_all`, `required_without`, `required_without_all` | `Required` under the condition of the fields `F`, `G` of the same struct: their values, or their values are not zero | | all | `a\|b` | `Or`: one of the alternatives passes, the errors of the first one when none does | | strings | `min`, `max`, `len`, `gt`, `gte`, `lt`, `lte` | `MinRunes`, `MaxRunes`: the lengths in characters | | strings | `eq`, `ne`, `oneof=a b`, `eq_ignore_case` | `AnyOf`, `eq.NotEqual`, `AnyOf`, `EqualFold` | | strings | `contains`, `excludes`, `startswith`, `endswith`, `startsnotwith`, `endsnotwith`, `containsany`, `excludesall` | `Contains`, `Excludes`, `Prefix`, `Suffix`, `NotPrefix`, `NotSuffix`, `ContainsAny`, `ExcludesAny` | | strings | `alpha`, `alphanum`, `number`, `numeric`, `hexadecimal`, `ascii`, `lowercase`, `uppercase` | `Alpha`, `Alphanumeric`, `Digits`, `Numeric`, `Hexadecimal`, `ASCII`, `Lowercase`, `Uppercase` | | strings | `email`, `url`, `http_url`, `uuid`, `ip`, `ipv4`, `ipv6`, `cidr`, `mac`, `hostname`, `hostname_rfc1123`, `e164` | `Email`, `URL`, `URL`, `UUID`, `IP`, `IPv4`, `IPv6`, `CIDR`, `MAC`, `Hostname`, `Hostname`, `E164` | | strings | `base64`, `base64url`, `json`, `semver`, `hexcolor`, `datetime=2006-01-02`, `iso3166_1_alpha2`, `iso4217`, `credit_card`, `luhn_checksum` | `Base64`, `Base64URL`, `JSON`, `Semver`, `HexColor`, `Datetime`, `CountryCode`, `CurrencyCode`, `CreditCard`, `Luhn` | | strings | `eqfield`, `nefield` | `EqualField`, `NotEqualField` | | numbers | `min`, `gte`, `max`, `lte`, `gt`, `lt`, `eq`, `ne`, `oneof=1 2` | `Min`, `Max`, `GreaterThan`, `LessThan`, `AnyOf`, `eq.NotEqual`, `AnyOf` | | numbers | `gtefield`, `ltefield`, `gtfield`, `ltfield`, `eqfield`, `nefield` | `MinField`, `MaxField`, `GreaterField`, `LessField`, `eq.EqualField`, `eq.NotEqualField` | | numbers | `latitude`, `longitude` | `Latitude`, `Longitude` | | `bool` | `eq=true`, `ne=true` | `eq.Equal`, `eq.NotEqual` | | `time.Time` | `gt`, `gte`, `lt`, `lte` without a parameter, `gtfield`, `ltfield` | `InFuture`, `InPast`, `AfterField`, `BeforeField` | | slices, maps | `min`, `max`, `len`, `gt`, `gte`, `lt`, `lte`, `eq` | the rules of the length | | slices | `unique` | `eq.Slice(...).Unique()` of the elements of comparable types | | slices | `dive` | the next rules are the rules of the elements; the elements of struct types are validated by the tags of their type, `Items[1].Title`, `dive,required` checks nil elements of `[]*T` | | maps | `dive`, `dive,keys,...,endkeys,...` | the rules of the values and of the keys of `map[string]V` | | pointers to nested structs | `required` | the fields of a nested struct have their own tags | Pointers to the values have the rules of the values, a nil pointer is absent. The rules on fields and the conditions take the names of the fields of the same struct, or their nested fields `Inner.Field`. `0x2C` in a parameter is a comma, `0x7C` is a pipe, like in go-playground/validator. ## Not supported These tags panic in `Configure`: a typo or an unsupported rule doesn't turn into a missing check. Write their rules in code. | Tags | In code | | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- | | `excluded_if`, `excluded_unless`, `excluded_with`, `excluded_with_all`, `excluded_without`, `excluded_without_all` | `b.When(cond).Field(&m.X).Custom(...)` that fails on a present value | | `eqcsfield`, `necsfield`, `gtcsfield`, `gtecsfield`, `ltcsfield`, `ltecsfield`, `fieldcontains`, `fieldexcludes` | the rules on other fields with the pointers to any field of the model, `EqualField(&m.Other.Field)`, or `b.Custom` | | `alphaunicode`, `alphanumunicode`, `boolean`, `containsrune`, `excludesrune`, `multibyte`, `printascii`, `ne_ignore_case`, `oneofci` | `Custom` or `Regexp` | | `uuid3`, `uuid4`, `uuid5`, `uuid_rfc4122`... , `ulid`, `cve` | `UUID` with `Custom` of the version, `Regexp` | | `uri`, `urn_rfc2141`, `url_encoded`, `datauri`, `fqdn`, `hostname_port`, `tcp_addr`, `udp_addr`, `unix_addr`, `ip_addr`... , `cidrv4`, `cidrv6` | `URL`, `Hostname`, `IP`, `CIDR` with `Custom`, or `Custom` with the parsers of the standard library | | `isbn`, `issn`, `bic`, `ssn`, `jwt`, `html`, `html_encoded`, `rgb`, `rgba`, `hsl`, `hsla`, `iscolor`, `md5`, `sha256`..., `btc_addr`, `eth_addr`, `mongodb`, `cron`, `timezone`, `bcp47_language_tag`, `iso3166_1_alpha3`, `iso3166_1_alpha_numeric`, `iso3166_2`, `iso4217_numeric`, `postcode_iso3166_alpha2` | `Custom` or `Regexp` | | `dir`, `dirpath`, `file`, `filepath`, `image` | `Custom`: valigo doesn't touch the file system | | `isdefault`, `omitzero`, `structonly`, `nostructlevel`, `skip_unless` | `When` and `Custom` | | `dive` into the elements of slices of slices, of maps with other keys than strings, of maps of pointers and structs; `unique` of the slices of pointers and structs, `unique=Field` | `Each`, `EachKey`, `EachValue` and `Custom` in code | | the custom tags of `RegisterValidation`, the aliases of `RegisterAlias` | the rules of the type in code, see [Your types](https://insei.github.io/valigo/0.2/features/conversions/#your-types) | ## Differences from go-playground/validator - `required` is `Required` of valigo: a string is not empty, a number is not zero, a slice and a map have elements — go-playground/validator accepts an empty non-nil slice and map, and a pointer to an empty string. - The fields of a nil nested struct are skipped, like by go-playground/validator. The rules in code check `Required` of these fields, see [Absent values](https://insei.github.io/valigo/0.2/features/absent/index.md). - The messages are the messages of valigo in the languages of the users, the errors have the [codes](https://insei.github.io/valigo/0.2/features/errors/#codes-for-clients) of valigo; the alternatives `a|b` report the errors of the first one. - The formats are the formats of valigo, see [Strings](https://insei.github.io/valigo/0.2/features/strings/index.md): `email`, `url` and `hostname` can differ from go-playground/validator in details. - `gt`, `lt` of `time.Time` compare with the current time without a parameter, like go-playground/validator, and fail on a parameter. - The rules of the tags after `omitempty` and the conditional ones aren't in the [JSON Schema](https://insei.github.io/valigo/0.2/features/schema/index.md): they don't apply to every value. - A valid object is validated without allocations, also with `dive` into the elements of structs; the alternatives allocate the errors of the failed ones. # Rules # Strings `str.Of` converts a `string` field, a `*string` field or a field of a named string type to the string configurator: ```go type Status string type User struct { Name string Nickname *string Status Status } b.Field(&u.Name).As(str.Of).Trim().Required().MaxLen(64) b.Field(&u.Nickname).As(str.Of).MinLen(3) // a nil Nickname is skipped b.Field(&u.Status).As(str.Of).AnyOf("active", "blocked") ``` Slices of strings are converted by `str.Slice`, see [Slices](https://insei.github.io/valigo/0.2/features/slices/index.md). ```go b.Slice(&u.Emails).As(str.Slice).MaxLen(3).Email() ``` ## Rules | Rule | Fails when | Message | | --------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | | `Required()` | the value is absent or empty | Should be fulfilled | | `MinLen(n)` | the length in bytes is less than `n` | Cannot be shorter than %d characters | | `MaxLen(n)` | the length in bytes is greater than `n` | Cannot be longer than %d characters | | `MinRunes(n)` | the number of characters (runes) is less than `n` | Cannot be shorter than %d characters | | `MaxRunes(n)` | the number of characters (runes) is greater than `n` | Cannot be longer than %d characters | | `AnyOf(values...)` | the value isn't one of `values` | Only %s values is allowed | | `EqualField(&m.X)` | the value isn't equal to the value of the string field `X`, see [Rules on other fields](https://insei.github.io/valigo/0.2/features/fields/index.md) | Should be equal to %s | | `NotEqualField(&m.X)` | the value is equal to the value of the string field `X` | Cannot be equal to %s | | `EqualFold(s)` | the value isn't equal to `s` ignoring case, by Unicode case folding | Should be equal to %s ignoring case | | `Prefix(s)`, `Suffix(s)`, `Contains(s)` | the value doesn't start with, end with or contain `s` | Should start with %s, Should end with %s, Should contain %s | | `NotPrefix(s)`, `NotSuffix(s)`, `Excludes(s)` | the value starts with, ends with or contains `s` | Cannot start with %s, Cannot end with %s, Cannot contain %s | | `ContainsAny(chars)`, `ExcludesAny(chars)` | the value has none / one of the characters of `chars` | Should contain any of the characters %s, Cannot contain the characters %s | | `Regexp(re, opts...)` | the value doesn't match `re` | Doesn't match required regexp pattern | | `Email()` | the value isn't an email address | Should be email address | | `URL()` | the value isn't an absolute URL with a scheme and a host | Should be URL | | `IP()`, `IPv4()`, `IPv6()` | the value isn't an IP address of the version | Should be IP address, Should be IPv4 address, Should be IPv6 address | | `Hostname()` | the value isn't a hostname by RFC 1123 | Should be hostname | | `UUID()` | the value isn't a UUID in the canonical form, any case | Should be UUID | | `Alphanumeric()` | the value is empty or has characters other than ASCII letters and digits | Should contain only letters and digits | | `Alpha()` | the value is empty or has characters other than ASCII letters | Should contain only letters | | `Digits()` | the value is empty or has characters other than ASCII digits, `0042` | Should contain only digits | | `Numeric()` | the value isn't a decimal number with an optional sign and fraction, `-12`, `+3.5` | Should be a number | | `Hexadecimal()` | the value isn't a hexadecimal number with an optional `0x`, `ff`, `0x1A` | Should be hexadecimal | | `ASCII()` | the value is empty or has non-ASCII characters | Should contain only ASCII characters | | `Lowercase()`, `Uppercase()` | the value is empty or has upper / lower case letters | Should be lowercase, Should be uppercase | | `Base64()`, `Base64URL()` | the value isn't base64 of RFC 4648 with the standard alphabet and padding / with the URL alphabet, padding optional | Should be base64 encoded, Should be base64url encoded | | `JSON()` | the value isn't a valid JSON value | Should be JSON | | `Semver()` | the value isn't a semantic version of semver.org, `1.2.3-rc.1+build.5`, without `v` | Should be semantic version | | `CIDR()` | the value isn't an IPv4 or IPv6 prefix, `10.0.0.0/8` | Should be CIDR | | `MAC()` | the value isn't a MAC address of 6 or 8 bytes, `00:1a:2b:3c:4d:5e`, `00-1a-...`, `001a.2b3c.4d5e` | Should be MAC address | | `E164()` | the value isn't a phone number of E.164, `+14155550123` | Should be E.164 phone number | | `HexColor()` | the value isn't `#rgb`, `#rgba`, `#rrggbb` or `#rrggbbaa` | Should be hex color | | `Datetime(layout)` | the value isn't parsed by `time.Parse(layout, value)` | Should be date and time in %s format | | `CountryCode()` | the value isn't an upper case code of ISO 3166-1 alpha-2, `US` | Should be ISO 3166-1 alpha-2 country code | | `CurrencyCode()` | the value isn't an upper case active code of ISO 4217, `EUR` | Should be ISO 4217 currency code | | `Luhn()` | the value isn't 2 or more digits with a valid Luhn checksum | Should have valid Luhn checksum | | `CreditCard()` | the value isn't 12-19 digits, grouped by spaces or hyphens, with a valid Luhn checksum | Should be credit card number | | `Custom(fn)` | `fn` returns errors | your messages, see [Custom rules](https://insei.github.io/valigo/0.2/features/custom/index.md) | | `When(fn)` | — | the next rules are applied only if `fn` returns true, see [Conditions](https://insei.github.io/valigo/0.2/features/conditions/index.md) | `MinLen` and `MaxLen` count bytes, like `len`: `"яя"` has the length 4. `MinRunes` and `MaxRunes` count characters: `"яя"` has 2. ## Formats The format rules don't allocate on valid values, like the other rules, and fail on empty strings: use [`When`](https://insei.github.io/valigo/0.2/features/conditions/#when-on-a-field) for optional ones. | Rule | Valid | Invalid | | ------------ | ---------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- | | `URL()` | `https://example.com/path?q=1`, `http://localhost:8080`, `https://[::1]/x`, `ftp://user@files.example.com` | `example.com`, `/relative`, `https://exa mple.com`, `https://example.com:99999` | | `Hostname()` | `example.com`, `localhost`, `1.example` | `-example.com`, `exa_mple.com`, `example.com.` | | `IP()` | `192.168.0.1`, `2001:db8::1` | `256.0.0.1`, `1.2.3` | | `UUID()` | `6ba7b810-9dad-11d1-80b4-00c04fd430c8` | `6ba7b8109dad11d180b400c04fd430c8`, `{6ba7b810-...}` | ```go package main import ( "context" "fmt" "github.com/insei/valigo" "github.com/insei/valigo/rules/str" ) type Webhook struct { URL string Host string ClientIP string Secret string } func main() { v := valigo.New() valigo.Configure[Webhook](v, func(b valigo.Configurator[Webhook], w *Webhook) { b.Field(&w.URL).As(str.Of).Required().URL().Prefix("https://") b.Field(&w.Host).As(str.Of).Hostname() b.Field(&w.ClientIP).As(str.Of).When(func(_ context.Context, ip string) bool { return ip != "" }).IP() b.Field(&w.Secret).As(str.Of).Alphanumeric().MinRunes(16) }) for _, err := range v.Validate(context.Background(), &Webhook{URL: "http://example.com", Host: "my_host", Secret: "s3cr3t!"}) { fmt.Println(err) } } ``` Output: ```text Should start with https:// (URL: http://example.com) Should be hostname (Host: my_host) Should contain only letters and digits (Secret: s3cr3t!) Cannot be shorter than 16 characters (Secret: s3cr3t!) ``` ## Trim `Trim()` removes the leading and trailing whitespace of the value **in the validated object**. Rules run in the order of the chain, so the rules after `Trim` check the trimmed value: ```go b.Field(&u.Name).As(str.Of).Trim().Required() // " " fails, " Ann " becomes "Ann" ``` The struct is validated in place, `Validate(ctx, &user)` trims `user.Name`. See the copy semantics of the values passed by value in [Errors](https://insei.github.io/valigo/0.2/features/errors/#validate-and-validatetyped). ## Regexp message `str.WithRegexpLocaleKey` replaces the message of a `Regexp` rule, the key can have its own [translations](https://insei.github.io/valigo/0.2/features/translations/index.md): ```go package main import ( "context" "fmt" "regexp" "github.com/insei/valigo" "github.com/insei/valigo/rules/str" ) type Product struct { SKU string } func main() { v := valigo.New() valigo.Configure[Product](v, func(b valigo.Configurator[Product], p *Product) { b.Field(&p.SKU).As(str.Of).Regexp(regexp.MustCompile(`^[A-Z]{3}-\d{4}$`), str.WithRegexpLocaleKey("Should look like ABC-1234")) }) fmt.Println(v.Validate(context.Background(), &Product{SKU: "abc-1"})) } ``` Output: ```text [Should look like ABC-1234 (SKU: abc-1)] ``` # Numbers `num.Of` converts a numeric field to the number configurator, `num.Ptr` — a pointer to a numeric field. Supported types are `int`, `int8`, `int16`, `int32`, `int64`, `uint`, `uint8`, `uint16`, `uint32`, `uint64`, `float32`, `float64` and named types based on them. The arguments of the rules have the type of the field. Untyped constants fit any numeric type, a value of another type doesn't compile: ```go type Price int64 type Product struct { Stock int Price Price Rating float32 Discount *uint8 } b.Field(&p.Stock).As(num.Of).Min(0) b.Field(&p.Price).As(num.Of).AnyOfInterval(1, 1_000_000) // the arguments are Price b.Field(&p.Rating).As(num.Of).Max(5) b.Field(&p.Discount).As(num.Ptr).Max(90) // a nil Discount is skipped var limit int32 = 10 b.Field(&p.Stock).As(num.Of).Max(limit) // doesn't compile: int32 isn't int b.Field(&p.Discount).As(num.Of) // doesn't compile: *uint8 isn't a number, use num.Ptr ``` Slices of numbers are converted by `num.Slice`: `b.Slice(&p.Sizes).As(num.Slice).Each(func(e *num.BaseConfigurator[int]) { e.Min(1) })`, see [Slices](https://insei.github.io/valigo/0.2/features/slices/index.md). ## Rules | Rule | Fails when | Message | | --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------- | | `Required()` | `num.Of`: the value is zero; `num.Ptr`: the value is absent | Should be fulfilled | | `Min(n)` | the value is less than `n` | Cannot be less than %v | | `Max(n)` | the value is greater than `n` | Cannot be greater than %v | | `GreaterThan(n)`, `LessThan(n)` | the value isn't greater / less than `n` | Should be greater than %v, Should be less than %v | | `GreaterField(&m.X)`, `LessField(&m.X)` | the value isn't greater / less than the value of the field `X` | Should be greater than %v, Should be less than %v | | `MinField(&m.X)`, `MaxField(&m.X)` | the value is less or greater than the value of the field `X`, see [Rules on other fields](https://insei.github.io/valigo/0.2/features/fields/index.md) | Cannot be less than %v, Cannot be greater than %v | | `AnyOf(values...)` | the value isn't one of `values` | Only %v values is allowed | | `AnyOfInterval(begin, end)` | the value is outside `[begin, end]`, both ends are included | Only interval[%v - %v] is allowed | | `Latitude()`, `Longitude()` | the value is outside `[-90, 90]` / `[-180, 180]` | Should be latitude, Should be longitude | | `Custom(fn)` | `fn` returns errors | your messages, see [Custom rules](https://insei.github.io/valigo/0.2/features/custom/index.md) | | `When(fn)` | — | the next rules are applied only if `fn` returns true, see [Conditions](https://insei.github.io/valigo/0.2/features/conditions/index.md) | ## Zero and absent Zero is a valid value of a pointer: `b.Field(&p.Discount).As(num.Ptr).Required()` passes for a pointer to `0` and fails for `nil`. Use a pointer field when zero must be distinguished from "not set", e.g. in PATCH requests: ```go package main import ( "context" "fmt" "github.com/insei/valigo" "github.com/insei/valigo/rules/num" ) type UpdateProduct struct { Stock *int } func main() { v := valigo.New() valigo.Configure[UpdateProduct](v, func(b valigo.Configurator[UpdateProduct], p *UpdateProduct) { b.Field(&p.Stock).As(num.Ptr).Min(0) }) zero, negative := 0, -1 fmt.Println(v.Validate(context.Background(), &UpdateProduct{})) fmt.Println(v.Validate(context.Background(), &UpdateProduct{Stock: &zero})) fmt.Println(v.Validate(context.Background(), &UpdateProduct{Stock: &negative})) } ``` Output: ```text [] [] [Cannot be less than 0 (Stock: -1)] ``` # Time `timex.Of` converts a `time.Time` field or a `*time.Time` field to the time configurator, `timex.Slice` — a slice of them: ```go type Booking struct { CheckIn time.Time CheckOut *time.Time Reminders []time.Time } b.Field(&bk.CheckIn).As(timex.Of).Required().InFuture() b.Field(&bk.CheckOut).As(timex.Of).After(start) // a nil CheckOut is skipped b.Slice(&bk.Reminders).As(timex.Slice).MaxLen(3).Each(func(r *timex.BaseConfigurator) { r.InFuture() }) ``` The package is named `timex` to not shadow the standard `time`. ## Rules | Rule | Fails when | Message | | --------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | | `Required()` | the value is absent or zero | Should be fulfilled | | `Before(t)` | the value isn't before `t` | Should be before %s | | `After(t)` | the value isn't after `t` | Should be after %s | | `Between(from, to)` | the value is outside `[from, to]`, both ends are included | Should be between %s and %s | | `BeforeField(&m.X)`, `AfterField(&m.X)` | the value isn't before or after the time of the field `X`, see [Rules on other fields](https://insei.github.io/valigo/0.2/features/fields/index.md) | Should be before %s, Should be after %s | | `InPast()` | the value isn't before the moment of the validation | Should be in the past | | `InFuture()` | the value isn't after the moment of the validation | Should be in the future | | `Custom(fn)` | `fn` returns errors | your messages, see [Custom rules](https://insei.github.io/valigo/0.2/features/custom/index.md) | | `When(fn)` | — | the next rules are applied only if `fn` returns true, see [Conditions](https://insei.github.io/valigo/0.2/features/conditions/index.md) | The times in the messages are formatted by RFC 3339. `Before`, `After` and `Between` take the bounds of the configuration, `BeforeField` and `AfterField` — of another field of the object. ## Example ```go package main import ( "context" "fmt" "time" "github.com/insei/valigo" "github.com/insei/valigo/rules/timex" ) type Report struct { From time.Time To *time.Time } func main() { start := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) end := time.Date(2026, 12, 31, 0, 0, 0, 0, time.UTC) v := valigo.New() valigo.Configure[Report](v, func(b valigo.Configurator[Report], r *Report) { b.Field(&r.From).As(timex.Of).Required().Between(start, end) b.Field(&r.To).As(timex.Of).Before(end) }) late := end.AddDate(0, 1, 0) fmt.Println(v.Validate(context.Background(), &Report{})) fmt.Println(v.Validate(context.Background(), &Report{From: start, To: &late})) } ``` Output: ```text [Should be fulfilled (From: 0001-01-01 00:00:00 +0000 UTC) Should be between 2026-01-01T00:00:00Z and 2026-12-31T00:00:00Z (From: 0001-01-01 00:00:00 +0000 UTC)] [Should be before 2026-12-31T00:00:00Z (To: 2027-01-31 00:00:00 +0000 UTC)] ``` # Comparable values `eq.Of` converts a field of any `comparable` type — an enum of a named type, a `bool`, a number, a string — to the configurator of equality rules. `eq.Ptr` converts a pointer to such a field, `eq.Slice` — a slice of them: ```go type Status string const ( Active Status = "active" Blocked Status = "blocked" ) b.Field(&u.Status).As(eq.Of).Required().OneOf(Active, Blocked) // the arguments are Status b.Field(&u.Agreed).As(eq.Of).Equal(true) // a bool b.Field(&u.Role).As(eq.Ptr).OneOf(Admin, Editor) // a nil Role is skipped b.Slice(&u.Roles).As(eq.Slice).Unique() ``` For strings and numbers [`str.Of`](https://insei.github.io/valigo/0.2/features/strings/index.md) and [`num.Of`](https://insei.github.io/valigo/0.2/features/numbers/index.md) have more rules, `eq.Of` is for the types without their own configurator. `eq.Of` of a pointer field compares the pointers, use `eq.Ptr` to compare the values. ## Rules | Rule | Fails when | Message | | --------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | | `Required()` | `eq.Of`: the value is zero; `eq.Ptr`: the value is absent | Should be fulfilled | | `OneOf(values...)` | the value isn't one of `values` | Only %v values is allowed | | `NotOneOf(values...)` | the value is one of `values` | Values %v are not allowed | | `Equal(v)` | the value isn't `v` | Should be %v | | `NotEqual(v)` | the value is `v` | Cannot be %v | | `EqualField(&m.X)` | the value isn't equal to the value of the field `X`, see [Rules on other fields](https://insei.github.io/valigo/0.2/features/fields/index.md) | Should be equal to %s | | `NotEqualField(&m.X)` | the value is equal to the value of the field `X` | Cannot be equal to %s | | `Custom(fn)` | `fn` returns errors | your messages, see [Custom rules](https://insei.github.io/valigo/0.2/features/custom/index.md) | | `When(fn)` | — | the next rules are applied only if `fn` returns true, see [Conditions](https://insei.github.io/valigo/0.2/features/conditions/index.md) | `eq.Slice` has the rules of the [slice itself](https://insei.github.io/valigo/0.2/features/slices/index.md), `Each` with the rules above for the elements, `OneOf` for every element and `Unique`: | Rule | Fails when | Message | | ---------- | ------------------------------------------------------------------------ | ---------------------------- | | `Unique()` | the slice has duplicate elements, the error value is the first duplicate | Should contain unique values | `Unique` compares the elements pairwise without allocations for slices up to 32 elements, and by a map for longer ones. ## Example ```go package main import ( "context" "fmt" "github.com/insei/valigo" "github.com/insei/valigo/rules/eq" ) type Plan string const ( Free Plan = "free" Pro Plan = "pro" ) type SignUp struct { Plan Plan Terms bool Features []string } func main() { v := valigo.New() valigo.Configure[SignUp](v, func(b valigo.Configurator[SignUp], s *SignUp) { b.Field(&s.Plan).As(eq.Of).Required().OneOf(Free, Pro) b.Field(&s.Terms).As(eq.Of).Equal(true) b.Slice(&s.Features).As(eq.Slice).MaxLen(5).Unique() }) for _, err := range v.Validate(context.Background(), &SignUp{Plan: "gold", Features: []string{"sso", "api", "sso"}}) { fmt.Println(err) } } ``` Output: ```text Only [free pro] values is allowed (Plan: gold) Should be true (Terms: false) Should contain unique values (Features: sso) ``` # UUID The rules of [`github.com/google/uuid`](https://pkg.go.dev/github.com/google/uuid) are in the separate module `github.com/insei/valigo/rules/uuid`, so the core doesn't depend on a uuid package: ```bash go get github.com/insei/valigo/rules/uuid@latest ``` `uuid.Of` converts a `uuid.UUID` field or a `*uuid.UUID` field to the uuid configurator, `uuid.Slice` — a slice of them. The module's package is also named `uuid`, import it with an alias next to `github.com/google/uuid`: ```go import ( "github.com/google/uuid" vuuid "github.com/insei/valigo/rules/uuid" ) type Comment struct { ID uuid.UUID ParentID *uuid.UUID } b.Field(&c.ID).As(vuuid.Of).Required() b.Field(&c.ParentID).As(vuuid.Of).Custom(notDeleted) // a nil ParentID is skipped b.Slice(&c.Mentions).As(vuuid.Slice).MaxLen(10).AnyOf(allowed...) ``` ## Rules | Rule | Fails when | Message | | ------------------ | --------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | | `Required()` | the value is absent or `uuid.Nil` | Should be fulfilled | | `AnyOf(values...)` | the value isn't one of `values` | Only %s values is allowed | | `Custom(fn)` | `fn` returns errors | your messages, see [Custom rules](https://insei.github.io/valigo/0.2/features/custom/index.md) | | `When(fn)` | — | the next rules are applied only if `fn` returns true, see [Conditions](https://insei.github.io/valigo/0.2/features/conditions/index.md) | Another uuid package gets its rules by a conversion of the same kind, see [Types and conversions](https://insei.github.io/valigo/0.2/features/conversions/#your-types). ## Example ```go package main import ( "context" "fmt" "github.com/google/uuid" "github.com/insei/valigo" vuuid "github.com/insei/valigo/rules/uuid" "github.com/insei/valigo/shared" ) type Comment struct { ID uuid.UUID ParentID *uuid.UUID } func main() { v := valigo.New() valigo.Configure[Comment](v, func(b valigo.Configurator[Comment], c *Comment) { b.Field(&c.ID).As(vuuid.Of).Required().Custom(func(ctx context.Context, h *shared.FieldCustomHelper, id uuid.UUID) []shared.Error { if id.Version() != 7 { return []shared.Error{h.ErrorT(ctx, id, "Should be UUID v7")} } return nil }) }) fmt.Println(v.Validate(context.Background(), &Comment{})) fmt.Println(v.Validate(context.Background(), &Comment{ID: uuid.MustParse("6ba7b810-9dad-11d1-80b4-00c04fd430c8")})) } ``` Output: ```text [Should be fulfilled (ID: 00000000-0000-0000-0000-000000000000) Should be UUID v7 (ID: 00000000-0000-0000-0000-000000000000)] [Should be UUID v7 (ID: 6ba7b810-9dad-11d1-80b4-00c04fd430c8)] ``` # Slices `b.Slice(&m.X)` returns the configurator of a `[]E` field with the rules of the slice itself. `As` converts it to a typed slice configurator, the rules of the slice stay available after the conversion: | Configurator | Elements | Rules on elements | | ----------------------------------- | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `b.Slice(&m.X)` | any `E` | `Each` with `*shared.Field[E]`: `Check`, `Required`, `Modify`, `Custom`, `When`, and `As` to convert the element | | `.As(str.Slice)` | `string`, `*string`, named strings | `Each` with the [string configurator](https://insei.github.io/valigo/0.2/features/strings/index.md), `Trim`, `Regexp`, `Email` | | `.As(num.Slice)` | numbers and named numbers | `Each` with the [number configurator](https://insei.github.io/valigo/0.2/features/numbers/index.md) | | `.As(timex.Slice)`, `.As(eq.Slice)` | times, comparable values | `Each` with the [time](https://insei.github.io/valigo/0.2/features/time/index.md) and the [equality](https://insei.github.io/valigo/0.2/features/values/index.md) configurators, `Unique` of `eq.Slice` | | `.As(vuuid.Slice)` | `uuid.UUID`, `*uuid.UUID` | `Each` with the [uuid configurator](https://insei.github.io/valigo/0.2/features/uuid/index.md), `AnyOf` | A pointer to a slice, `*[]E`, is converted from its field: `b.Field(&m.Tags).As(shared.SlicePtr).As(str.Slice)`, a nil pointer is [absent](https://insei.github.io/valigo/0.2/features/absent/index.md). ## The slice and its elements The rules of the configurator check **the slice itself**, the rules inside `Each` check **every element**: | Rule | Fails when | Message | | ------------ | ------------------------------------ | ----------------------------------------- | | `Required()` | the slice is absent or empty | Should be fulfilled | | `MinLen(n)` | the slice has less than `n` elements | Cannot contain less than %d elements | | `MaxLen(n)` | the slice has more than `n` elements | Cannot contain more than %d elements | | `Each(fn)` | — | `fn` configures the rules of the elements | `Trim`, `Regexp` and `Email` of `str.Slice` and `AnyOf` of `uuid.Slice` are shortcuts for the same rules in `Each`. Any element of `b.Slice` is converted in `Each` too: `e.As(num.Ptr).Max(100)` for `[]*int`. ```go package main import ( "context" "fmt" "github.com/google/uuid" "github.com/insei/valigo" "github.com/insei/valigo/rules/str" vuuid "github.com/insei/valigo/rules/uuid" "github.com/insei/valigo/shared" ) type Post struct { Tags []string Editors []*uuid.UUID Quantities []int } func main() { v := valigo.New() valigo.Configure[Post](v, func(b valigo.Configurator[Post], p *Post) { b.Slice(&p.Tags).As(str.Slice).Required().MaxLen(3).Each(func(tag *str.BaseConfigurator) { tag.Trim().Required().MaxLen(10) }) b.Slice(&p.Editors).As(vuuid.Slice).Each(func(id *vuuid.BaseConfigurator) { id.Required() }) b.Slice(&p.Quantities).Each(func(q *shared.Field[int]) { q.Check(func(v int) bool { return v > 0 }, "Should be positive") }) }) editor := uuid.MustParse("6ba7b810-9dad-11d1-80b4-00c04fd430c8") post := &Post{ Tags: []string{"go", " ", "validation-library"}, Editors: []*uuid.UUID{&editor, nil}, Quantities: []int{1, 0}, } for _, err := range v.Validate(context.Background(), post) { fmt.Println(err) } } ``` Output: ```text Should be fulfilled (Tags[1]: ) Cannot be longer than 10 characters (Tags[2]: validation-library) Should be fulfilled (Editors[1]: ) Should be positive (Quantities[1]: 0) ``` The location of an element error has the index of the element, the errors of the slice itself have the location of the field. Nil elements of slices of pointers are [absent](https://insei.github.io/valigo/0.2/features/absent/index.md): only `Required` fails on them. ## Custom and When `Custom` and `When` of the slice configurators get the elements as `shared.Elems[E]`, a view over `[]E` and `[]*E` without copying: ```go b.Slice(&p.Tags).As(str.Slice).Custom(func(ctx context.Context, h *shared.FieldCustomHelper, tags shared.Elems[string]) []shared.Error { seen := make(map[string]bool, tags.Len()) for _, tag := range tags.All() { // tag is *string, nil for nil elements of []*string if tag != nil && seen[*tag] { return []shared.Error{h.ErrorT(ctx, *tag, "Tags should be unique")} } if tag != nil { seen[*tag] = true } } return nil }) ``` `Len()` returns the number of elements, `At(i)` the pointer to the element `i`, `All()` iterates over the indexes and the pointers. ## Slices of pointers `Slice` works with the declared element type: for `[]*int` the element is `*int`. Convert the element to check the pointed values, nil elements are absent then: ```go b.Slice(&p.Limits).Each(func(limit *shared.Field[*int]) { limit.As(num.Ptr).Required().Max(100) }) ``` # Maps `b.Map(&m.X)` returns the configurator of a `map[K]V` field: the rules of the map itself, of its keys and of its values. The keys and the values are converted to the configurators of their types by `As`, like the elements of [slices](https://insei.github.io/valigo/0.2/features/slices/index.md): ```go b.Map(&s.Labels).Required().MaxLen(10). EachKey(func(k *shared.Field[string]) { k.As(str.Of).Alphanumeric().MaxLen(32) }). EachValue(func(v *shared.Field[string]) { v.As(str.Of).MaxLen(64) }) b.Map(&s.Ports).EachValue(func(p *shared.Field[int]) { p.As(num.Of).AnyOfInterval(1, 65535) }) ``` ## Rules | Rule | Fails when | Message | | --------------- | ------------------------------------------ | ---------------------------------------------------------------------------------------------- | | `Required()` | the map is absent or empty | Should be fulfilled | | `MinLen(n)` | the map has less than `n` elements | Cannot contain less than %d elements | | `MaxLen(n)` | the map has more than `n` elements | Cannot contain more than %d elements | | `EachKey(fn)` | — | `fn` configures the rules of the keys | | `EachValue(fn)` | — | `fn` configures the rules of the values | | `Custom(fn)` | `fn` returns errors, it gets the whole map | your messages, see [Custom rules](https://insei.github.io/valigo/0.2/features/custom/index.md) | | `When(fn)` | — | the next rules are applied only if `fn` returns true for the map, a nil map for an absent one | - The location of an error of a key or a value is `Labels[key]`, the error value is the key or the value. - The order of a map is random, so the errors of the keys and the errors of the values are sorted by location. - The values of a map aren't addressable: [`Trim`](https://insei.github.io/valigo/0.2/features/strings/#trim) and other modifying rules change a copy, which is checked by the next rules, the map keeps the original value. - A nil map of a value field is present and empty, the map of a nil nested struct is [absent](https://insei.github.io/valigo/0.2/features/absent/index.md). - Valid maps are validated without allocations, the copy of the checked key or value is pooled. ## Example ```go package main import ( "context" "fmt" "github.com/insei/valigo" "github.com/insei/valigo/rules/num" "github.com/insei/valigo/rules/str" "github.com/insei/valigo/shared" ) type Service struct { Labels map[string]string Ports map[string]int } func main() { v := valigo.New() valigo.Configure[Service](v, func(b valigo.Configurator[Service], s *Service) { b.Map(&s.Labels).Required().MaxLen(10). EachKey(func(k *shared.Field[string]) { k.As(str.Of).Alphanumeric() }). EachValue(func(v *shared.Field[string]) { v.As(str.Of).MaxLen(5) }) b.Map(&s.Ports).EachValue(func(p *shared.Field[int]) { p.As(num.Of).AnyOfInterval(1, 65535) }) }) errs := v.Validate(context.Background(), &Service{ Labels: map[string]string{"app": "valigo-api", "tier!": "web"}, Ports: map[string]int{"http": 80, "admin": 0}, }) for _, err := range errs { fmt.Println(err) } } ``` Output: ```text Should contain only letters and digits (Labels[tier!]: tier!) Cannot be longer than 5 characters (Labels[app]: valigo-api) Only interval[1 - 65535] is allowed (Ports[admin]: 0) ``` # Errors # Errors ## Validate and ValidateTyped ```go func (v *Validator) Validate(ctx context.Context, obj any) []error func (v *Validator) ValidateTyped(ctx context.Context, obj any) []shared.Error ``` - `ValidateTyped` returns the errors as `shared.Error` values, `Validate` passes them through the [errors transformer](#errors-transformer), by default every `shared.Error` becomes an `error`. - Both return `nil` when the object is valid, when the type of the object has no rules, and for a nil pointer. - `obj` is a pointer to the configured struct. A struct value is validated too, by a shallow copy: [`Trim`](https://insei.github.io/valigo/0.2/features/strings/#trim) of its fields isn't visible to the caller, but the values behind its pointers and the elements of its slices are shared with the caller and can be changed. - The errors go in the order of the configuration: the rules of a field in the order of the chain, the fields in the order of their first rule. ## shared.Error ```go type Error struct { Message string // translated message Location string // location of the field, Address.City or Tags[1] by default Value any // the checked value, nil for absent values Code string // stable code of the rule, e.g. string.max_len Params map[string]any // named parameters of the message, e.g. {"max": 64} } ``` `Error()` returns `Message (Location: Value)`, or `Message` alone when both the location and the value are empty. `Error` has JSON field names of its own: `{"Message":"...","Location":"...","Value":...,"Code":"...","Params":{...}}`, `Code` and `Params` are omitted when empty. Convert it to your API format by the [transformer](#errors-transformer). ## Codes for clients The message is translated on the backend, see [Translations](https://insei.github.io/valigo/0.2/features/translations/index.md). A client that translates the messages itself — a web or a mobile application with its own locales — uses `Code` and `Params` instead: the code is stable, the parameters are named. ```go package main import ( "context" "encoding/json" "fmt" "time" "github.com/insei/valigo" "github.com/insei/valigo/rules/num" "github.com/insei/valigo/rules/str" "github.com/insei/valigo/rules/timex" ) type Event struct { Title string Seats int StartsAt time.Time EndsAt time.Time } func main() { v := valigo.New() valigo.Configure[Event](v, func(b valigo.Configurator[Event], e *Event) { b.Field(&e.Title).As(str.Of).MaxLen(5) b.Field(&e.Seats).As(num.Of).AnyOfInterval(1, 100) b.Field(&e.EndsAt).As(timex.Of).AfterField(&e.StartsAt) }) start := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC) for _, err := range v.ValidateTyped(context.Background(), &Event{Title: "Conference", StartsAt: start, EndsAt: start}) { body, _ := json.Marshal(map[string]any{"field": err.Location, "code": err.Code, "params": err.Params}) fmt.Println(string(body)) } } ``` Output: ```text {"code":"string.max_len","field":"Title","params":{"max":5}} {"code":"number.interval","field":"Seats","params":{"max":100,"min":1}} {"code":"time.after_field","field":"EndsAt","params":{"field":"StartsAt"}} ``` | Code | Params | | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------- | | `string.required`, `string.email`, `string.url`, `string.ip`, `string.ipv4`, `string.ipv6`, `string.hostname`, `string.uuid`, `string.alphanumeric`, `string.regexp` | — | | `string.min_len`, `string.min_runes` | `min` | | `string.max_len`, `string.max_runes` | `max` | | `string.any_of` | `values` | | `string.prefix`, `string.suffix`, `string.contains` | `prefix`, `suffix`, `substring` | | `string.equal_field`, `string.not_equal_field` | `field` | | `string.excludes`, `string.not_prefix`, `string.not_suffix` | `substring`, `prefix`, `suffix` | | `string.contains_any`, `string.excludes_any` | `chars` | | `string.equal_fold` | `value` | | `string.alpha`, `string.digits`, `string.numeric`, `string.hexadecimal` | — | | `string.base64`, `string.base64url`, `string.json`, `string.semver`, `string.cidr`, `string.mac`, `string.e164`, `string.hex_color`, `string.ascii`, `string.lowercase`, `string.uppercase`, `string.country_code`, `string.currency_code`, `string.luhn`, `string.credit_card` | — | | `string.datetime` | `layout` | | `number.required` | — | | `number.min`, `number.max` | `min`, `max` | | `number.min_field`, `number.max_field`, `number.greater_field`, `number.less_field` | `field` | | `number.greater_than`, `number.less_than` | `min`, `max` | | `number.any_of` | `values` | | `number.interval` | `min`, `max` | | `number.latitude`, `number.longitude` | — | | `time.required`, `time.in_past`, `time.in_future` | — | | `time.before`, `time.after` | `time` (RFC 3339) | | `time.between` | `from`, `to` (RFC 3339) | | `time.before_field`, `time.after_field` | `field` | | `value.required`, `value.unique` | — | | `value.one_of`, `value.not_one_of` | `values` | | `value.equal`, `value.not_equal` | `value` | | `value.equal_field`, `value.not_equal_field` | `field` | | `uuid.required` | — | | `uuid.any_of` | `values` | | `slice.required`, `map.required` | — | | `slice.min_len`, `map.min_len` | `min` | | `slice.max_len`, `map.max_len` | `max` | The `field` parameter is the location of the other field by the location function of the validator. The errors of [custom rules](https://insei.github.io/valigo/0.2/features/custom/index.md) have the locale key as the code, or the code of `shared.M` with `ErrorMsg`. ## Location of fields The location is the path of the field in the struct by default: `Address.City`. `WithFieldLocationNamingFn` replaces it, e.g. with the path of the JSON tags, so the client sees the names of its request: ```go package main import ( "context" "fmt" "github.com/insei/fmap/v3" "github.com/insei/valigo" "github.com/insei/valigo/rules/str" ) type Address struct { City string `json:"city"` } type SignUp struct { Email string `json:"email"` Address Address `json:"address"` Tags []string `json:"tags"` } func main() { v := valigo.New(valigo.WithFieldLocationNamingFn(func(field fmap.Field) string { return field.GetTagPath("json", false) })) valigo.Configure[SignUp](v, func(b valigo.Configurator[SignUp], s *SignUp) { b.Field(&s.Email).As(str.Of).Email() b.Field(&s.Address.City).As(str.Of).Required() b.Slice(&s.Tags).As(str.Slice).Email() }) for _, err := range v.ValidateTyped(context.Background(), &SignUp{Email: "a", Tags: []string{"b"}}) { fmt.Println(err.Location, err.Message) } } ``` Output: ```text email Should be email address address.city Should be fulfilled tags[0] Should be email address ``` The index of a slice element is appended to the location returned by the function. ## Errors transformer `WithErrorsTransformer` sets how `Validate` converts the errors, e.g. to one error of the API: ```go type FieldError struct { Field string `json:"field"` Message string `json:"message"` } type ValidationError struct { Fields []FieldError `json:"fields"` } func (e *ValidationError) Error() string { return "validation failed" } v := valigo.New(valigo.WithErrorsTransformer(func(errs []shared.Error) []error { fields := make([]FieldError, len(errs)) for i, err := range errs { fields[i] = FieldError{Field: err.Location, Message: err.Message} } return []error{&ValidationError{Fields: fields}} })) ``` `shared.Errors` is a `[]shared.Error` as one error, its `Error()` joins the errors by `;`; the adapters of the frameworks return it, get it back by `errors.As(err, &errs)`. The transformer is called only when there are errors. For an HTTP API the [`problem`](https://insei.github.io/valigo/0.2/features/problem/index.md) package has the transformer of the problem details of RFC 9457. # Translations Messages are translated by a `translator.Translator`. The default one of `valigo.New` has the English, German, Spanish, French and Russian messages of the built-in rules, English is the default language. ## Locale keys The rules build messages by *locale keys*: `validation:string:Cannot be longer than %d characters`. The translator looks the key up in the languages of the context, then in the default language, and formats the found message with the arguments of the rule. A key without a translation is used as is: so the messages of [custom rules](https://insei.github.io/valigo/0.2/features/custom/index.md) work without translations. The keys of the built-in rules are in [`translator/locales`](https://github.com/Insei/valigo/tree/main/translator/locales): ```yaml validation: string: "Cannot be longer than %d characters": Не может быть длиннее %d символов ``` ## Plural forms A message with a count has plural forms: the value of its key is a map of the plural categories of [CLDR](https://cldr.unicode.org/index/cldr-spec/plural-rules) — `one`, `few`, `many`, `other`. The form is selected by the first integer argument of the message, the categories of the language are built in for English and the default, French, Russian, Ukrainian and Belarusian; a missing category falls back to `other`. ```yaml validation: string: "Cannot be longer than %d characters": one: Не может быть длиннее %d символа few: Не может быть длиннее %d символов many: Не может быть длиннее %d символов other: Не может быть длиннее %d символа ``` The forms are stored as the keys `key#one`, `key#few`, `key#many` and `key#other`, so they are added by `storage.Add` too: ```go package main import ( "context" "fmt" "github.com/insei/valigo" "github.com/insei/valigo/shared" "github.com/insei/valigo/translator" ) type Cart struct { Items []string Name string } func main() { storage := translator.NewInMemStorage() storage.Add("ru", map[string]string{ "Up to %d items in a cart#one": "В корзине может быть не больше %d товара", "Up to %d items in a cart#many": "В корзине может быть не больше %d товаров", }) t := translator.New(translator.WithStorage(storage), translator.WithPreferredLanguagesFn(func(context.Context) []string { return []string{"ru"} })) v := valigo.New(valigo.WithTranslator(t)) valigo.Configure[Cart](v, func(b valigo.Configurator[Cart], c *Cart) { b.Slice(&c.Items).Custom(func(ctx context.Context, h *shared.FieldCustomHelper, items shared.Elems[string]) []shared.Error { if items.Len() > 5 { return []shared.Error{h.ErrorT(ctx, items.Len(), "Up to %d items in a cart", 5)} } return nil }) b.Slice(&c.Items).MaxLen(1) }) for _, err := range v.Validate(context.Background(), &Cart{Items: []string{"a", "b", "c", "d", "e", "f"}}) { fmt.Println(err) } } ``` Output: ```text В корзине может быть не больше 5 товаров (Items: 6) Не может содержать больше 1 элемента (Items: 6) ``` ## Languages from the context The languages of the request are taken from the context, `translator.NewAcceptLanguageMiddleware` puts the languages of the `Accept-Language` header there, ordered by their `q`: ```go handler := translator.NewAcceptLanguageMiddleware()(mux) ``` Without the middleware of `net/http`, e.g. in the `Resolve` of a huma input, put the languages of the header to the context by `translator.ContextWithAcceptLanguage(ctx, header)`, or any languages by `translator.ContextWithLanguages(ctx, "ru")`, e.g. the language of the user profile. Another source of the languages is set by `translator.WithPreferredLanguagesFn`. ## Own messages Add the translations of your messages to a storage and pass the translator to the validator: ```go package main import ( "context" "fmt" "github.com/insei/valigo" "github.com/insei/valigo/rules/str" "github.com/insei/valigo/shared" "github.com/insei/valigo/translator" ) type language struct{} type Account struct { Login string } const reservedKey = "validation:login:Login %q is reserved" func main() { storage := translator.NewInMemStorage() // the built-in locales are loaded storage.Add("en", map[string]string{reservedKey: "Login %q is reserved"}) storage.Add("ru", map[string]string{reservedKey: "Логин %q зарезервирован"}) t := translator.New( translator.WithStorage(storage), translator.WithDefaultLang("en"), translator.WithPreferredLanguagesFn(func(ctx context.Context) []string { lang, _ := ctx.Value(language{}).(string) return []string{lang} }), ) v := valigo.New(valigo.WithTranslator(t)) valigo.Configure[Account](v, func(b valigo.Configurator[Account], a *Account) { b.Field(&a.Login).As(str.Of).MinLen(3).Custom(func(ctx context.Context, h *shared.FieldCustomHelper, login string) []shared.Error { if login == "root" { return []shared.Error{h.ErrorT(ctx, login, reservedKey, login)} } return nil }) }) ctx := context.WithValue(context.Background(), language{}, "ru") fmt.Println(v.Validate(ctx, &Account{Login: "ro"})) fmt.Println(v.Validate(ctx, &Account{Login: "root"})) fmt.Println(v.Validate(context.Background(), &Account{Login: "root"})) } ``` Output: ```text [Не может быть короче 3 символов (Login: ro)] [Логин "root" зарезервирован (Login: root)] [Login "root" is reserved (Login: root)] ``` - `storage.Merge(locales)` adds the translations of several languages at once, e.g. from your own YAML files read by `translator.LocalesFromFS(fsys)`: `locales//data.yaml` with the same nested format as the built-in locales. - `translator.NewInMemStorage(translator.WithInMemData(data))` replaces the built-in locales with yours. - [`str.WithRegexpLocaleKey`](https://insei.github.io/valigo/0.2/features/strings/#regexp-message) sets the key of a `Regexp` rule. ## Sources of translations The translator looks the messages up in its sources: the built-in YAML locales by default, your catalogs of an i18n library, your own source. `translator.WithSources` chains them — for every language of a request in order, the first source with a translation of the key wins: ```go tr := translator.New(translator.WithSources(goi18n.Source(bundle), translator.Embedded())) v := valigo.New(valigo.WithTranslator(tr)) ``` Your messages in go-i18n override the built-in ones, the built-in YAML translates the rest; a German request gets the built-in German before your English. `translator.Embedded(opts...)` is the built-in YAML locales as a source, with your additions by `WithInMemData`. A source implements `translator.Source`: ```go type Source interface { Translate(lang, key string, args []any, params map[string]any) (string, bool) } ``` `args` are the format arguments of the key, `params` are the same values by their names, `{"max": 64}`, for the libraries with named placeholders; `false` passes the language to the next source. The sources: | Source | Module | | ---------------------------------------------------------------------- | --------------------------------------------- | | the built-in YAML locales | `translator.Embedded()` | | [go-i18n](https://insei.github.io/valigo/0.2/features/goi18n/index.md) | `github.com/insei/valigo/integrations/goi18n` | # HTTP problem details The `problem` package writes the validation errors as the problem details of [RFC 9457](https://www.rfc-editor.org/rfc/rfc9457) (it obsoletes RFC 7807): an `application/problem+json` response with the errors of the fields in the `errors` member. ```go v := valigo.New(valigo.WithErrorsTransformer(problem.Transformer())) if errs := v.Validate(r.Context(), &req); errs != nil { problem.From(errs).Write(w) // 422 Unprocessable Content return } ``` - `problem.Transformer(opts...)` makes `Validate` return one error, the `*problem.Details` of all errors; `problem.From(errs)` gets it back, `problem.New(errs)` builds it from the errors of `ValidateTyped`. - `Details.Write(w)` writes the status and the JSON with the `application/problem+json` content type. - Every error of a field has the translated message `detail`, the JSON Pointer `pointer` of RFC 6901 (built from the location: `tags[1]` is `#/tags/1`), the `location`, and the [`code` and `params`](https://insei.github.io/valigo/0.2/features/errors/#codes-for-clients) for the clients that translate the messages themselves. - Options: `WithType` (the URI of the problem type, `about:blank` when it is empty), `WithTitle` ("Validation failed" by default), `WithStatus` (422 by default), `WithDetail`. Use JSON locations, so the pointers point to the fields of the request, see [Errors](https://insei.github.io/valigo/0.2/features/errors/#location-of-fields). ## Example ```go package main import ( "bytes" "encoding/json" "fmt" "net/http" "net/http/httptest" "strings" "github.com/insei/fmap/v3" "github.com/insei/valigo" "github.com/insei/valigo/problem" "github.com/insei/valigo/rules/str" "github.com/insei/valigo/translator" ) type SignUp struct { Email string `json:"email"` Tags []string `json:"tags"` } func main() { v := valigo.New( valigo.WithFieldLocationNamingFn(func(f fmap.Field) string { return f.GetTagPath("json", false) }), valigo.WithErrorsTransformer(problem.Transformer(problem.WithType("https://example.com/problems/validation"))), ) valigo.Configure[SignUp](v, func(b valigo.Configurator[SignUp], s *SignUp) { b.Field(&s.Email).As(str.Of).Required().Email() b.Slice(&s.Tags).As(str.Slice).MaxLen(2).Each(func(tag *str.BaseConfigurator) { tag.Alphanumeric() }) }) handler := translator.NewAcceptLanguageMiddleware()(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var req SignUp if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } if errs := v.Validate(r.Context(), &req); errs != nil { _ = problem.From(errs).Write(w) return } w.WriteHeader(http.StatusCreated) })) r := httptest.NewRequest(http.MethodPost, "/sign-up", strings.NewReader(`{"email": "ann", "tags": ["go", "a b", "api"]}`)) r.Header.Set("Accept-Language", "ru") w := httptest.NewRecorder() handler.ServeHTTP(w, r) var body bytes.Buffer _ = json.Indent(&body, w.Body.Bytes(), "", " ") fmt.Println(w.Code, w.Header().Get("Content-Type")) fmt.Print(body.String()) } ``` Output: ```text 422 application/problem+json { "type": "https://example.com/problems/validation", "title": "Validation failed", "status": 422, "errors": [ { "detail": "Должно быть электронным адресом", "pointer": "#/email", "location": "email", "code": "string.email" }, { "detail": "Не может содержать больше 2 элементов", "pointer": "#/tags", "location": "tags", "code": "slice.max_len", "params": { "max": 2 } }, { "detail": "Должно содержать только латинские буквы и цифры", "pointer": "#/tags/1", "location": "tags[1]", "code": "string.alphanumeric" } ] } ``` The title isn't translated: set it by `WithTitle`, e.g. with a translator of your messages. # Integrations # JSON Schema The rules that JSON Schema can express are described by its keywords, so the generators of the schemas of an API can show them to the clients: [huma](https://insei.github.io/valigo/0.2/features/huma/#the-rules-in-the-openapi-document) puts them into its OpenAPI document. `v.Constraints()` returns them for other generators. ```go for _, c := range v.Constraints() { fmt.Println(c.Struct, c.Field.GetStructPath(), c.Path, c.Keyword, c.Value) } ``` Every `shared.Constraint` has the configured struct, the field, the path from the schema of the field to the constrained value — `[items]` for the elements of a slice, `[additionalProperties]` for the values of a map, `[propertyNames]` for its keys — the keyword and its value. The keyword `required` means the field is in the required properties of its object. | Rules | Keywords | | ------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------ | | `str`: `Required` | `required`, `minLength: 1` | | `str`: `MinRunes`, `MaxRunes`, `MaxLen` | `minLength`, `maxLength` | | `str`: `Regexp`, `Prefix`, `Suffix`, `Contains`, `Alphanumeric`, `Alpha`, `Digits`, `Numeric`, `Hexadecimal`, `Semver`, `E164`, `HexColor` | `pattern` | | `str`: `Email`, `URL`, `IPv4`, `IPv6`, `Hostname`, `UUID` | `format`: `email`, `uri`, `ipv4`, `ipv6`, `hostname`, `uuid` | | `str`: `Datetime(time.RFC3339)`, `Datetime(time.DateOnly)` | `format`: `date-time`, `date` | | `str`: `Base64`, `Base64URL` | `contentEncoding` | | `str`, `num`, `uuid`: `AnyOf`; `eq`: `OneOf` | `enum` | | `eq`: `Equal` | `const` | | `num`: `Min`, `Max`, `AnyOfInterval`, `Latitude`, `Longitude`, `GreaterThan`, `LessThan` | `minimum`, `maximum`, `exclusiveMinimum`, `exclusiveMaximum` | | `num`, `eq`, `uuid`: `Required` | `required` | | slices: `Required`, `MinLen`, `MaxLen`, `Unique`, `Each` | `required` and `minItems: 1`, `minItems`, `maxItems`, `uniqueItems`, `items` | | maps: `Required`, `MinLen`, `MaxLen`, `EachKey`, `EachValue` | `required` and `minProperties: 1`, `minProperties`, `maxProperties`, `propertyNames`, `additionalProperties` | The schema describes the rules, valigo validates the values: the descriptions are chosen not to be stricter than the rules, so a value valid for valigo is valid for the schema. The formats are annotations of JSON Schema, the validators that check them can differ in details. - `MinLen` of strings counts bytes, `minLength` counts characters: it isn't described. `MaxLen` is `maxLength`, a string of `n` bytes has no more than `n` characters. - The rules under `When` of the struct and of fields aren't described, they don't apply to every value. - After `Trim` and other `Modify` rules only `Required` is described: the schema describes the value of the request, the rules check the modified one. - Rules on other fields, custom rules and the formats without a keyword of JSON Schema aren't described. ## Your conversions A [conversion](https://insei.github.io/valigo/0.2/features/conversions/#your-types) describes its rules by `Schema` of the field, `Schema` of a slice configurator describes the rules of the slice: ```go // Percent checks that the number is in [0, 100]. func (c *RatioConfigurator) Percent() *RatioConfigurator { c.f.Schema("minimum", 0).Schema("maximum", 100) c.f.CheckMsg(func(v Ratio) bool { return v >= 0 && v <= 100 }, shared.M("ratio.percent", percentKey)) return c } ``` # huma The module `github.com/insei/valigo/integrations/huma` validates the inputs of [huma](https://huma.rocks) operations by valigo. The errors are the error details of huma with its locations — `body.email`, `query.limit`, `path.id` — in the 422 response of huma, and the messages are translated to the languages of the `Accept-Language` header. ```bash go get github.com/insei/valigo/integrations/huma@latest ``` ```go import vhuma "github.com/insei/valigo/integrations/huma" var validator = valigo.New(valigo.WithFieldLocationNamingFn(vhuma.Location)) func (i *CreateUserInput) Resolve(ctx huma.Context) []error { return vhuma.Resolve(ctx, validator, i) } ``` - `vhuma.Location` is the location function of the input structs of huma: the fields of `Body` are `body` and their JSON path, the parameters are their kind and name from the `path`, `query`, `header`, `cookie` and `form` tags. - `vhuma.Resolve` validates the input with the languages of the `Accept-Language` header and returns the error details of huma, huma calls `Resolve` of the input after it has parsed the request. - `vhuma.Errors` converts the errors of `ValidateTyped` to the error details of huma. The error details of huma have no codes, use [`problem`](https://insei.github.io/valigo/0.2/features/problem/index.md) when the clients need them. huma validates the input by its JSON schema first, so the rules of valigo extend the schema: formats, rules on other fields, rules from data, translated messages. ## Example ```go package main import ( "context" "encoding/json" "fmt" "net/http" "net/http/httptest" "strings" "github.com/danielgtaylor/huma/v2" "github.com/danielgtaylor/huma/v2/adapters/humago" "github.com/insei/valigo" vhuma "github.com/insei/valigo/integrations/huma" "github.com/insei/valigo/rules/num" "github.com/insei/valigo/rules/str" ) type CreateUserInput struct { Limit int `query:"limit"` Body struct { Email string `json:"email"` Tags []string `json:"tags"` } } var validator = valigo.New(valigo.WithFieldLocationNamingFn(vhuma.Location)) func init() { valigo.Configure[CreateUserInput](validator, func(b valigo.Configurator[CreateUserInput], i *CreateUserInput) { b.Field(&i.Limit).As(num.Of).Max(100) b.Field(&i.Body.Email).As(str.Of).Email() b.Slice(&i.Body.Tags).As(str.Slice).Each(func(tag *str.BaseConfigurator) { tag.Alphanumeric() }) }) } func (i *CreateUserInput) Resolve(ctx huma.Context) []error { return vhuma.Resolve(ctx, validator, i) } func main() { mux := http.NewServeMux() api := humago.New(mux, huma.DefaultConfig("Users", "1.0.0")) huma.Post(api, "/users", func(context.Context, *CreateUserInput) (*struct{}, error) { return nil, nil }) r := httptest.NewRequest(http.MethodPost, "/users?limit=500", strings.NewReader(`{"email": "ann", "tags": ["go", "a b"]}`)) r.Header.Set("Content-Type", "application/json") r.Header.Set("Accept-Language", "ru") w := httptest.NewRecorder() mux.ServeHTTP(w, r) var resp struct { Status int `json:"status"` Errors []struct { Location string `json:"location"` Message string `json:"message"` } `json:"errors"` } _ = json.Unmarshal(w.Body.Bytes(), &resp) fmt.Println(resp.Status) for _, err := range resp.Errors { fmt.Println(err.Location, "-", err.Message) } } ``` Output: ```text 422 query.limit - Не может быть больше 100 body.email - Должно быть электронным адресом body.tags[1] - Должно содержать только латинские буквы и цифры ``` ## The rules in the OpenAPI document `vhuma.OpenAPI(api, v)` returns the OpenAPI document of the api with the rules of valigo in the schemas of the request bodies and their nested structs, by [JSON Schema](https://insei.github.io/valigo/0.2/features/schema/index.md): `maxLength` of `MaxRunes`, `format: email` of `Email`, `items` of `Each`, the required properties of `Required`. Serve it, so the clients and the generators of SDKs see the rules: ```go mux.HandleFunc("GET /openapi.json", func(w http.ResponseWriter, r *http.Request) { doc, err := vhuma.OpenAPI(api, validator) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } _ = json.NewEncoder(w).Encode(doc) }) ``` with `config.OpenAPIPath = ""` instead of the document of huma, or under another path. - The schemas of huma are not changed: huma validates the requests by its tags, the rules of valigo are checked in `Resolve` with translated messages, without the duplicates of huma. - A keyword with another value than the one of the tags of huma, e.g. two `maxLength`, is added to `allOf`: the value must satisfy both. - The parameters keep the schemas of huma: an operation of huma doesn't know its input type. - The schemas are found by the registry of huma with the prefix `#/components/schemas/`, the default one. ```go package main import ( "context" "encoding/json" "fmt" "net/http" "github.com/danielgtaylor/huma/v2" "github.com/danielgtaylor/huma/v2/adapters/humago" "github.com/insei/valigo" vhuma "github.com/insei/valigo/integrations/huma" "github.com/insei/valigo/rules/str" ) type CreateUserInput struct { Body struct { Name string `json:"name"` Email string `json:"email,omitempty"` Tags []string `json:"tags,omitempty"` } } func main() { validator := valigo.New(valigo.WithFieldLocationNamingFn(vhuma.Location)) valigo.Configure[CreateUserInput](validator, func(b valigo.Configurator[CreateUserInput], i *CreateUserInput) { b.Field(&i.Body.Name).As(str.Of).MaxRunes(64) b.Field(&i.Body.Email).As(str.Of).Required().Email() b.Slice(&i.Body.Tags).As(str.Slice).MaxLen(5).Each(func(tag *str.BaseConfigurator) { tag.Alphanumeric() }) }) api := humago.New(http.NewServeMux(), huma.DefaultConfig("Users", "1.0.0")) huma.Post(api, "/users", func(context.Context, *CreateUserInput) (*struct{}, error) { return nil, nil }) doc, err := vhuma.OpenAPI(api, validator) if err != nil { panic(err) } body := doc["components"].(map[string]any)["schemas"].(map[string]any)["CreateUserInputBody"].(map[string]any) delete(body["properties"].(map[string]any), "$schema") // the property of huma for the link to the schema out, _ := json.MarshalIndent(body, "", " ") fmt.Println(string(out)) } ``` Output: ```text { "additionalProperties": false, "properties": { "email": { "format": "email", "minLength": 1, "type": "string" }, "name": { "maxLength": 64, "type": "string" }, "tags": { "items": { "pattern": "^[a-zA-Z0-9]+$", "type": "string" }, "maxItems": 5, "type": [ "array", "null" ] } }, "required": [ "name", "email" ], "type": "object" } ``` `email` is `omitempty` for huma, `Required` of valigo makes it required in the document; `tags` has the rules of its elements in `items`. # gin The module `github.com/insei/valigo/integrations/gin` validates the requests of [gin](https://gin-gonic.com) by valigo. ```bash go get github.com/insei/valigo/integrations/gin@latest ``` - `vgin.New(v)` is the `binding.StructValidator` of gin: set `binding.Validator = vgin.New(v)`, and `c.ShouldBindJSON` and the other binding methods validate the requests by the rules of valigo. Slices are validated element by element, the location of an error has the index: `[1].email`. - gin doesn't pass the request to its validator, so these messages are in the default language. `vgin.Validate(c, v, &req)` validates with the languages of the `Accept-Language` header of the request. - The error is [`shared.Errors`](https://insei.github.io/valigo/0.2/features/errors/#errors-transformer), get the errors by `errors.As` and write them, e.g. as [problem details](https://insei.github.io/valigo/0.2/features/problem/index.md). ## Example ```go package main import ( "errors" "fmt" "net/http" "net/http/httptest" "strings" "github.com/gin-gonic/gin" "github.com/gin-gonic/gin/binding" "github.com/insei/fmap/v3" "github.com/insei/valigo" vgin "github.com/insei/valigo/integrations/gin" "github.com/insei/valigo/problem" "github.com/insei/valigo/rules/str" "github.com/insei/valigo/shared" ) type SignUp struct { Email string `json:"email"` Name string `json:"name"` } func main() { v := valigo.New(valigo.WithFieldLocationNamingFn(func(f fmap.Field) string { return f.GetTagPath("json", false) })) valigo.Configure[SignUp](v, func(b valigo.Configurator[SignUp], s *SignUp) { b.Field(&s.Email).As(str.Of).Required().Email() b.Field(&s.Name).As(str.Of).Required().MaxRunes(16) }) // the binding of gin validates by valigo, without the validation of its own tags binding.Validator = nil gin.SetMode(gin.ReleaseMode) r := gin.New() r.POST("/sign-up", func(c *gin.Context) { var req SignUp if err := c.ShouldBindJSON(&req); err != nil { c.Status(http.StatusBadRequest) return } if err := vgin.Validate(c, v, &req); err != nil { var errs shared.Errors if errors.As(err, &errs) { _ = problem.New(errs).Write(c.Writer) return } } c.Status(http.StatusCreated) }) req := httptest.NewRequest(http.MethodPost, "/sign-up", strings.NewReader(`{"email": "ann", "name": ""}`)) req.Header.Set("Content-Type", "application/json") req.Header.Set("Accept-Language", "de") w := httptest.NewRecorder() r.ServeHTTP(w, req) fmt.Println(w.Code) fmt.Print(w.Body.String()) } ``` Output: ```text 422 {"title":"Validation failed","status":422,"errors":[{"detail":"Muss eine E-Mail-Adresse sein","pointer":"#/email","location":"email","code":"string.email"},{"detail":"Muss ausgefüllt sein","pointer":"#/name","location":"name","code":"string.required"}]} ``` To validate by the binding methods instead, set `binding.Validator = vgin.New(v)`: the messages are in the default language then. # echo The module `github.com/insei/valigo/integrations/echo` validates the requests of [echo](https://echo.labstack.com) by valigo. ```bash go get github.com/insei/valigo/integrations/echo@latest ``` - `vecho.New(v)` is the `echo.Validator`: set `e.Validator = vecho.New(v)`, and `c.Validate(&req)` validates by the rules of valigo. - echo doesn't pass the request to its validator, so these messages are in the default language. `vecho.Validate(c, v, &req)` validates with the languages of the `Accept-Language` header of the request. - The error is [`shared.Errors`](https://insei.github.io/valigo/0.2/features/errors/#errors-transformer), get the errors by `errors.As` and write them, e.g. as [problem details](https://insei.github.io/valigo/0.2/features/problem/index.md). ## Example ```go package main import ( "errors" "fmt" "net/http" "net/http/httptest" "strings" "github.com/insei/valigo" vecho "github.com/insei/valigo/integrations/echo" "github.com/insei/valigo/rules/str" "github.com/insei/valigo/shared" "github.com/labstack/echo/v4" ) type SignUp struct { Email string `json:"email"` } func main() { v := valigo.New() valigo.Configure[SignUp](v, func(b valigo.Configurator[SignUp], s *SignUp) { b.Field(&s.Email).As(str.Of).Required().Email() }) e := echo.New() e.Validator = vecho.New(v) e.POST("/sign-up", func(c echo.Context) error { var req SignUp if err := c.Bind(&req); err != nil { return err } if err := c.Validate(&req); err != nil { var errs shared.Errors if errors.As(err, &errs) { return c.JSON(http.StatusUnprocessableEntity, errs) } return err } return c.NoContent(http.StatusCreated) }) req := httptest.NewRequest(http.MethodPost, "/sign-up", strings.NewReader(`{"email": "ann"}`)) req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) w := httptest.NewRecorder() e.ServeHTTP(w, req) fmt.Println(w.Code) fmt.Print(w.Body.String()) } ``` Output: ```text 422 [{"Message":"Should be email address","Location":"Email","Value":"ann","Code":"string.email"}] ``` # go-i18n The module `github.com/insei/valigo/integrations/goi18n` translates the messages of valigo by a bundle of [go-i18n](https://github.com/nicksnyder/go-i18n), so the messages of the validation live in the catalogs of your service with the rest of its texts. ```bash go get github.com/insei/valigo/integrations/goi18n@latest ``` - `goi18n.Source(bundle)` is a [source of translations](https://insei.github.io/valigo/0.2/features/translations/#sources-of-translations): `translator.WithSources(goi18n.Source(bundle), translator.Embedded())` takes your messages from the bundle and the built-in YAML locales translate the rest. - The message IDs are the locale keys of valigo, `validation:string:Cannot be longer than %d characters`, see [`translator/locales`](https://github.com/Insei/valigo/tree/main/translator/locales). - A message uses the named parameters of the rule, `{{.max}}`, `{{.min}}`, `{{.field}}` — the params of the [codes](https://insei.github.io/valigo/0.2/features/errors/#codes-for-clients) — or the format verbs of the key, `%d`. The plural forms are chosen by the first integer argument, `{{.PluralCount}}` is it. - A language without the message in the bundle has no translation: the fallback of go-i18n to its default language isn't used, the next sources translate the language of the request. - `goi18n.Messages(lang)` exports the built-in messages of a language with their plural forms, to add them to a bundle or to write them to the files of the translators. ## Example ```go package main import ( "context" "fmt" "github.com/insei/valigo" "github.com/insei/valigo/integrations/goi18n" "github.com/insei/valigo/rules/str" "github.com/insei/valigo/translator" "github.com/nicksnyder/go-i18n/v2/i18n" "golang.org/x/text/language" ) type SignUp struct { Name string Email string } func main() { bundle := i18n.NewBundle(language.English) _ = bundle.AddMessages(language.Russian, &i18n.Message{ ID: "validation:string:Cannot be longer than %d characters", One: "Имя — не больше {{.max}} буквы", Few: "Имя — не больше {{.max}} букв", Many: "Имя — не больше {{.max}} букв", Other: "Имя — не больше {{.max}} буквы", }) tr := translator.New(translator.WithSources(goi18n.Source(bundle), translator.Embedded())) v := valigo.New(valigo.WithTranslator(tr)) valigo.Configure[SignUp](v, func(b valigo.Configurator[SignUp], s *SignUp) { b.Field(&s.Name).As(str.Of).MaxRunes(3) b.Field(&s.Email).As(str.Of).Email() }) ctx := translator.ContextWithLanguages(context.Background(), "ru") for _, err := range v.ValidateTyped(ctx, &SignUp{Name: "Анастасия", Email: "ann"}) { fmt.Println(err.Location, "-", err.Message) } } ``` Output: ```text Name - Имя — не больше 3 букв Email - Должно быть электронным адресом ``` # Architecture # How it works This page is for the contributors and for those who want to know the cost of a rule. ## Configure: from pointers to fields `Configure[T]` creates the *model*, a `*T` with all exported nested structs stored by pointer allocated (pointers to the types being allocated are left nil, so recursive types stop). The model gives every field a unique address, so a pointer `&u.Address.City` identifies the field: [fmap](https://github.com/Insei/fmap) finds the field descriptor by the address relative to the model. The descriptor knows the offset of the field and the chain of the nested pointer structs, so it reads the field of any object of the type: `Address.City` of a `*User` follows the `Address` pointer and returns a typed nil pointer when it is nil. That's how [absent values](https://insei.github.io/valigo/0.2/features/absent/index.md) appear. ## Typed rules The configurator methods are generic: `Field[F any](p *F) *shared.Field[F]`, `Slice[E any](p *[]E)`. The type of the field becomes the type parameter of the base configurator. `As` is a generic method too, it takes a conversion with a type constraint, `str.Of[S ~string | *string](f *shared.Field[S])`: the compiler checks the constraint against the type of the field, and the conversion fixes the types of the rule arguments. Every typed configurator is a thin wrapper over one core type, `shared.Field[T]`. It has four primitives, all built-in rules are made of them: | Primitive | Behavior | | ------------------------- | -------------------------------------------------------------------------- | | `Check(ok, key, args...)` | an error with the key when `ok(value)` is false, absent values are skipped | | `Required(ok, key)` | an error when the value is absent or `ok(value)` is false | | `Modify(fn)` | changes the value in place, e.g. `Trim` | | `Custom(fn)` | your function with the typed value and the field helper | `When(cond)` wraps the next rules of the field with the condition. `MaxLen` of a string is `Check(func(v string) bool { return len(v) <= n }, ...)`. `shared.Field[T]` doesn't know where the value comes from. For a struct field the source reads the field, for `Each` the source iterates over the elements of the slice and adds the index to the locations of the errors, for the keys and the values of a map it copies them to a pooled slot. All rules of a source share one read of the value. That's why the same string configurator validates a `Name string` field and every element of `Tags []*string`. ## Validate: the hot path `Configure` registers one validation function per field source in the validator, keyed by the type `*T`. `Validate`: 1. finds the functions of the type of the object, a map lookup; 1. calls every function: it reads the field once through the descriptor, a pointer into the object, and runs the rules of the field; 1. a rule returns `nil` for a valid value, and builds a `shared.Error` with the translated message otherwise. Nothing in this path allocates for valid objects: the values are read by pointers, the rules are closures created by `Configure`, the regexps are compiled once, the messages and their parameters are built by `shared.M` on the configuration, and the helper of struct `Custom` rules is pooled. A test of every package checks it with `testing.AllocsPerRun`. The rules append their errors to one pooled buffer of the validation, and `Validate` copies it once: an invalid object allocates the copy, the translated messages and the boxed values of its errors. ## Packages | Package | Contents | | ------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `valigo` | `Validator`, `Configure`, `Configurator` with `Field`, `Slice`, `Map`, `When`, `Custom` and `Tags`, options | | `internal/zero`, `internal/helper`, `internal/tags` | the allocation of the models of `Configure`, the helper of the errors with the translator and the locations, the rules of the tags | | `shared` | the rules core `Field[T]`, slice configurator and `Elems[E]`, `As` and the conversions `Deref`, `Retype`, `RetypeSlice`, `SlicePtr`, `SliceRules`, `Error`, helpers | | `rules/str`, `rules/num`, `rules/timex`, `rules/eq` | configurators of strings, numbers, times and comparable values built on `shared.Field[T]`, their conversions `str.Of`, `num.Of`, `num.Ptr`, `timex.Of`, `eq.Of`... and slice conversions | | `problem` | the problem details of RFC 9457 | | `github.com/insei/valigo/rules/uuid` | the separate module with the rules of `github.com/google/uuid`, the core doesn't depend on a uuid package | | `github.com/insei/valigo/integrations/huma`, `integrations/gin`, `integrations/echo` | the separate modules of huma, gin and echo | | `translator` | translator, in-memory storage, built-in locales, `Accept-Language` middleware | ## Contributing - Commits follow [Conventional Commits](https://www.conventionalcommits.org/), the changelog is generated from them by git-cliff. - `go test -race ./...` and the examples modules run in CI. Programs in the documentation and in the skill for AI agents are built and run by `scripts/check-examples.sh`. - Fuzz tests check the formats of strings against the parsers of the standard library and the patterns of their JSON Schema, the reading of the fields by the offsets against the fields themselves, and that any tag either configures the rules or panics with its error. `scripts/fuzz.sh` runs every target for `FUZZTIME`, 30s in CI; a failing input is saved in `testdata/fuzz` of the package and stays a regression test. - A new rule of an existing type is a method of its configurator over `Check` or `Required`, with a locale key in `translator/locales/*/data.yaml`.