Validation

An input model declares its rules by implementing one method.

type Validatable interface {
    Validate(v *muzak.Validation)
}
func (in *CreateUserIn) Validate(v *muzak.Validation) {
    v.String(&in.Email).Trim().Lower().Required().Email()
    v.String(&in.Password).Required().MinLen(12).Must(NotACommonPassword)
    v.String(&in.Confirm).Equal(in.Password).Message("must match the password")
    v.Number(&in.Age).Between(18, 120)
    v.String(&in.Role).OneOf("admin", "editor", "viewer")
    v.Slice(&in.Tags).MaxItems(10).Unique().Each(validate.String().MaxLen(20))
}

Implementing it is the only thing needed. There is no option to remember and no pipe to install, so a model cannot be left unvalidated by forgetting one. The method belongs on the pointer type, which is what lets rules name fields by address.

&in.Email is the field, so renaming it is a change the compiler checks, and v.Number(&in.Email) does not compile. Type v. and your editor lists the kinds; type v.String(&in.Email). and it lists every rule that applies to a string.

How a rule set behaves

Four things govern every rule set:

  • Transforms mutate, and run first. Trim().Lower() changes what the handler receives. Because they run before the checks, Trim().Required() rejects a field of pure whitespace.
  • Optional means optional. A field without Required() skips its remaining checks when it is empty, so MaxLen(20) has nothing to say about a value nobody sent. A nil pointer field is skipped entirely.
  • One failure per field. The first check that fails reports; a single empty field does not also complain that it is too short and not an email address.
  • Guards run first. Validation happens after binding and after every guard, so an unauthenticated caller gets 401, not a map of your schema.

The entry points

CallField typesRule set
v.String(&in.F)string, *stringvalidate.StringRules
v.Number(&in.F)any integer, float, or pointer to onevalidate.NumberRules
v.Slice(&in.F)[]Evalidate.SliceRules[E]
v.Time(&in.F)time.Time, *time.Timevalidate.TimeRules
v.Value(&in.F)any typevalidate.ValueRules[T]
v.Nested(&in.F)another Validatable
v.When(cond)a cross-field condition
v.Reject(&in.F, issue)a failure recorded directly

String rules

v.String(&in.Username).Trim().Lower().Required().MinLen(2).MaxLen(32).
    Matches(`^[a-z0-9_]+$`).
    Message("may only contain lower case letters, digits and underscores").
    NotOneOf("admin", "root", "support").
    Message("is reserved")
RuleEffectMessage on failure
Trim()Transform: removes surrounding whitespace
Lower() / Upper()Transform: folds case
Required()Rejects an empty valueis required
MinLen(n) / MaxLen(n) / Len(n)Bounds the length in runesmust be at least 12 characters
Email()Parses with net/mailmust be a valid email address
URL()Requires an absolute http or https URL with a hostmust be a valid absolute http or https URL
UUID()Any of the usual spellingsmust be a valid UUID
Matches(pattern)A regular expressionis not in the expected format
OneOf(...)A fixed set, which also becomes the enum in the documentmust be one of "admin", "editor", "viewer"
NotOneOf(...)A set the value must never takeis not available
Prefix(s) / Suffix(s) / Contains(s)Substring checksmust begin with "user_"
Equal(other) / EqualFold(other)Matches another value, the second ignoring casedoes not match
MatchesNot(pattern)Rejects a regular expression, for a shape easier to name than the shapes it excludesis in a format that is not accepted
NotBlank()Rejects a value that is nothing but whitespace, and an absent onemust not be blank
NoControl()Rejects control characters, including tabs and line breaksmust not contain control characters
MinBytes(n) / MaxBytes(n)Bounds the length in bytes rather than in runesmust be at least 8 bytes
Must(fn)Your own func(string) errorwhatever the function returns
As(name)Renames the field in this rule set's messages
Message(text)Overrides the wording of the check written immediately before it
MessageKey(key, args...)Overrides it with a translation key instead, so the override is translated like every built-in rule

Lengths are counted as runes rather than bytes, so a name in any script is measured the way a person would count it.

Matches compiles its expression when the rule is declared, so a malformed pattern is a panic at start-up rather than a failure on the first request that happens to reach it.

URL() rejects every scheme but http and https on purpose. Without that restriction, javascript:, data: and file: all parse as perfectly valid absolute URLs, and a value this check approves is exactly the kind of thing that ends up in a redirect, a fetch or an href with the validator's blessing.

Addresses and URLs

URL() accepts any absolute http or https URL. These narrow it, which is usually what a field means.

RuleEffectMessage on failure
HTTPS()An absolute https URL with a hostmust be a valid https URL
URLWithSchemes(...)An absolute URL using one of the given schemesmust be a URL using "s3" or "gs"
Host()A DNS hostname, with the label rules a resolver enforcesmust be a valid hostname
IP() / IPv4() / IPv6()An address of either family, or of onemust be a valid IPv4 address
CIDR()A network, as 10.0.0.0/8must be a valid network in CIDR notation
MAC()A hardware address in any of its spellingsmust be a valid MAC address

URL() refuses every scheme but http and https on purpose. Without a restriction, javascript:, data: and file: all parse as perfectly valid absolute URLs, and a value a validator has blessed is exactly the kind of thing that ends up in a redirect or an href.

IPv6() refuses an IPv4 address written in the mapped form, such as ::ffff:127.0.0.1. It is an IPv4 address wearing a costume, and a field asking for IPv6 wants one an IPv6-only network can route.

Character classes and formats

RuleEffectMessage on failure
Alpha()Letters onlymust contain only letters
Alphanumeric()Letters and digitsmust contain only letters and digits
Numeric()Digits only, as characters rather than as a numbermust contain only digits
ASCII()Printable ASCII, which is space through tildemust contain only printable ASCII characters
Slug()Lower case letters and digits in hyphen-separated groupsmust contain only lower case letters, digits and hyphens
Hex()Hexadecimal digits, in either casemust be hexadecimal
HexColour()A colour, as #1a2b3c, with three, four, six or eight digitsmust be a hexadecimal colour, such as #1a2b3c
Base64()Decodes as standard base64must be valid base64
JSON()A well-formed JSON document, for a field carrying JSON as textmust be valid JSON
Semver()A semantic version, as 2.0.0-rc.1+build.5must be a semantic version, such as 1.4.0
E164()A telephone number in international formatmust be a telephone number in international format
LanguageTag()A BCP 47 tag, as pt-BRmust be a valid language tag, such as pt-BR
Timezone()A zone the host knows, as Europe/Istanbulmust be a known time zone, such as Europe/Istanbul
CountryCode() / CurrencyCode()An assigned ISO 3166-1 alpha-2 or ISO 4217 codemust be a known country code

Letters are Unicode letters rather than the twenty-six of English, so Alpha() accepts a name in any script. Narrow it by composing: Alpha().ASCII().

CountryCode() and CurrencyCode() look the value up in the assigned registers rather than counting letters, so XQ is refused although it is two upper case letters, and a currency withdrawn from the standard is refused although it was valid a few years ago.

Both tables carry the date they were taken, and both need regenerating when the standards change. The generated document describes the shape rather than the table, because two hundred values in every schema that names a country would be noise rather than documentation.

Reach for OneOf(...) with your own list when a service trades in a known handful. It is narrower, it becomes an enum in the document, and it cannot go stale in a way that matters.

Timezone() resolves the name against the zone data the host or the binary carries, so what passes here is exactly what time.LoadLocation will later accept. Names that resolve are remembered, and a value that could not be one is rejected on its shape first, so a client sending nonsense pays for a scan of the string rather than for a search of the zone data.

Number rules

v.Number(&in.Age).Required().Between(18, 120)
v.Number(&in.Limit).Clamp(1, 100)
v.Number(&in.Quantity).Positive().MultipleOf(12)
RuleEffectMessage on failure
Required()Rejects a zero valueis required
Min(n) / Max(n) / Between(lo, hi)Inclusive boundsmust be between 18 and 120
GreaterThan(n) / LessThan(n)Exclusive bounds, which refuse the bound itselfmust be greater than 10
Positive() / Negative()Compared against zero, which is refusedmust be greater than zero
NonNegative() / NonPositive()Compared against zero, which is allowedmust not be negative
MultipleOf(n)For a quantity that only makes sense in stepsmust be a multiple of 12
Whole()Nothing after the decimal point, for a float carrying a countmust be a whole number
Port()A whole number from 1 to 65535must be a port number between 1 and 65535
OneOf(...)A fixed set, which also becomes the enum in the documentmust be one of 1 or 2
Clamp(lo, hi)Transform: pulls the value inside the range instead of rejecting it
Must(fn)Your own func(float64) errorwhatever the function returns

Bounds are written as ordinary untyped constants whatever the field's own numeric type, so Between(18, 120) reads the same on an int, an int64 and a float64.

The inclusive and exclusive pairs are worth keeping apart. A price that must be above zero and a quantity that may be zero are different rules, and the generated document says so: GreaterThan and Positive produce exclusiveMinimum, where Min and NonNegative produce minimum. A client reading minimum: 0 is being told that zero is allowed.

Required() on a number rejects zero, and zero is indistinguishable from absent for a numeric field. A field that may legitimately be zero should be a pointer and left optional rather than marked required.

Clamp is for a value outside the range being a client being imprecise rather than a client being wrong, such as a page size that should quietly cap rather than fail.

Slice rules

v.Slice(&in.Tags).MaxItems(10).Unique().Each(validate.String().MaxLen(20))
v.Slice(&in.Scores).MinItems(1).Each(validate.Value[int]().Must(positive))
RuleEffectMessage on failure
Required()Rejects an empty or absent collectionis required
MinItems(n) / MaxItems(n) / Items(n)Bounds the length, or fixes itmust have at most 10 items
NotEmpty()Rejects an empty or absent collection, in words a client can act onmust not be empty
Contains(v) / Excludes(v)An element that must be present, or must not bemust contain read
Unique()Every element differs, compared with reflect.DeepEqualmust not repeat fine
Each(rules)Applies a rule set to every elementreported against the position
Must(fn)Your own func([]E) errorwhatever the function returns

Each only accepts a rule set for the element's own type, so Each(validate.String()) on a slice of integers does not compile. A failure inside an element is reported against that position, as tags[2], so a client can tell which one to fix.

Time rules

v.Time(&in.StartsAt).Required().After(time.Now())
v.Time(&in.BornOn).Before(eighteenYearsAgo)
v.Time(&in.Window).Between(seasonStart, seasonEnd)
RuleEffectMessage on failure
Required()Rejects the zero timeis required
Before(t) / After(t) / Between(a, b)Bounds against a moment you namemust be before 2026-01-01T00:00:00Z
Past() / Future()Bounds against now, judged when the request is validatedmust be in the past
Within(d)No further from now than a distance, in either directionmust be within 5m0s of now
Must(fn)Your own func(time.Time) errorwhatever the function returns

The generated schema carries "format": "date-time".

Within is the rule a signed request or a replayed event wants: a timestamp far in the past is stale and one far in the future is a clock that cannot be trusted, and both are the same mistake.

v.Time(&in.Timestamp).Within(5 * time.Minute)

Value rules

v.Value is the escape hatch for anything the typed rule sets do not cover: a custom type, an enum with its own String method, a struct compared as a whole. Because it is generic over the field's type, Must and OneOf take that type directly and the compiler checks the values written at the call site.

type Currency string

v.Value(&in.Currency).Required().OneOf(Currency("EUR"), Currency("USD"), Currency("TRY"))
v.Value(&in.Coordinates).Must(insideServiceArea)

Rules of your own

A rule is an ordinary function, so it needs no registration and is testable on its own.

// commonPasswords stands in for the list a real service would load. Keeping it
// here rather than inside the rule means the rule stays a plain function that a
// test can call directly.
var commonPasswords = map[string]bool{
    "password1234":  true,
    "qwertyuiop12":  true,
    "letmeinplease": true,
}

// NotACommonPassword rejects a password from the known-bad list.
//
// Its message is phrased to read after the field name, the way every built-in
// rule words its own failures.
func NotACommonPassword(password string) error {
    if commonPasswords[strings.ToLower(password)] {
        return errors.New("is too common, choose something less guessable")
    }
    return nil
}
v.String(&in.Password).Required().MinLen(12).Must(NotACommonPassword)
func TestNotACommonPassword(t *testing.T) {
    if err := NotACommonPassword("password1234"); err == nil {
        t.Error("want a common password to be rejected")
    }
}

Reusing a rule set

validate.String(), validate.Number(), validate.Slice[E](), validate.Time() and validate.Value[T]() build unbound rule sets, which is how a constraint stays consistent across the models that share it.

// validateTag returns the rules every tag must satisfy.
func validateTag() *validate.StringRules {
    return validate.String().Trim().Lower().MaxLen(20).
        Matches(`^[a-z0-9-]+$`).
        Message("may only contain lower case letters, digits and hyphens")
}
v.Slice(&in.Tags).MaxItems(10).Unique().Each(validateTag())

An unbound rule set is also usable directly, which is what makes it testable without a request:

if err := validateTag().Check("Not A Tag"); err == nil {
    t.Error("want a spaced tag to be rejected")
}

Cross-field rules

in is right there, so a cross-field rule is ordinary Go.

// A confirmation field.
v.String(&in.Confirm).Equal(in.Password).Message("must match the password")

// A condition that reads better as a rule.
v.When(in.Role == "admin" && in.Age < 21).
    Reject(&in.Role, "an admin must be at least 21")

// The same, naming a translation rather than fixing the wording in English.
v.When(in.Price < in.Cost).
    RejectKey(&in.Price, "errors.item.price_below_cost")

// A condition that reads better as an if.
if in.Start.After(in.End) {
    v.Reject(&in.End, "must not be before the start")
}

When returns a condition, and several failures can hang off one test:

v.When(in.Recurring).
    Reject(&in.Interval, "is required for a recurring booking").
    Reject(&in.EndsAt, "is required for a recurring booking")

Messages in another language

Every rule's wording is translated when the application has a locale configured, and nothing in the model says so. A failure reports the rule it came from as well as its English, and the wording is looked up under that rule at render time.

es:
  errors:
    messages:
      blank: "es obligatorio"
      too_short:
        one: "debe tener al menos 1 carácter"
        other: "debe tener al menos %{count} caracteres"

Message fixes the wording in one language on purpose, and clears the rule with it: a sentence you wrote is the sentence you meant. MessageKey names a translation instead, and the rule's own values still fill it in, so %{count} works exactly as it does in the built-in message.

v.String(&in.Password).MinLen(12).MessageKey("errors.password.too_short")

The full picture, including the four scopes a message is looked up under, is in Internationalization.

Nested models

type CreateOrderIn struct {
    Customer CustomerIn `json:"customer"`
    Address  AddressIn  `json:"address"`
}

func (in *CreateOrderIn) Validate(v *muzak.Validation) {
    v.Nested(&in.Customer)
    v.Nested(&in.Address)
}

func (in *AddressIn) Validate(v *muzak.Validation) {
    v.String(&in.City).Trim().Required().MaxLen(64)
    v.String(&in.PostCode).Trim().Upper().Required().Matches(`^[0-9]{5}$`)
}

A failure on the nested model's City field is reported as address.city. A nil pointer is skipped, so an optional nested model needs no guard of its own.

Validating what did not come from a body

Rules bind to the field, not to the location, so a header is checked exactly as a query parameter is and its failure is reported against the name the client actually sent.

func (in *FeedIn) Validate(v *muzak.Validation) {
    v.String(&in.SaveData).Trim().Lower().OneOf("on", "off")
    v.String(&in.Traceparent).Matches(`^[0-9a-f]{2}-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$`).
        Message("must be a W3C trace context, such as 00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01")
    v.Slice(&in.Tags).MaxItems(5).Each(validate.String().MaxLen(24))
    v.String(&in.SessionID).Trim().MinLen(4).MaxLen(64)
    v.Number(&in.Limit).Between(1, 100)
}

Transforms run before the checks, which is what lets Save-Data be compared against a single spelling in the handler afterwards.

What the client sees

Rule failures merge into the same envelope as binding failures, with the location the binder already established, and every field is reported at once.

{
  "error": {
    "code": "validation_error",
    "message": "The request could not be validated.",
    "status": 422,
    "details": [
      { "field": "username", "location": "body",  "issue": "is reserved" },
      { "field": "email",    "location": "body",  "issue": "must be a valid email address" },
      { "field": "password", "location": "body",  "issue": "must be at least 12 characters" },
      { "field": "tags",     "location": "body",  "issue": "must not repeat fine" },
      { "field": "limit",    "location": "query", "issue": "must be between 1 and 100" }
    ]
  },
  "request_id": "0611f4b2-2f0a-4b57-9c1a-6e6a2e2f9b31"
}

The field name is derived from the same struct tag the binder used, so a query parameter and a body member are reported the same way. As(name) overrides it for a field whose tag reads worse than its purpose:

v.String(&in.DOB).As("date_of_birth").Required()

Rules feed the documentation

The constraints a rule set declares become JSON Schema keywords, so the OpenAPI document carries the limits the code actually enforces.

RuleKeyword
Required()membership of required
MinLen / MaxLenminLength / maxLength
Min / Max / Betweenminimum / maximum
MultipleOfmultipleOf
MinItems / MaxItemsminItems / maxItems
Unique()uniqueItems
OneOf(...)enum
Matches(p)pattern
Email() / URL() / UUID()format of email, uri, uuid
Each(...)the array's items
"role": { "type": "string", "enum": ["admin", "editor", "viewer"] },
"tags": {
  "type": "array", "maxItems": 10, "uniqueItems": true,
  "items": { "type": "string", "maxLength": 20, "pattern": "^[a-z0-9-]+$" }
}

The document cannot drift from the validation, because both are read from the same declaration. A Must rule is opaque by nature and contributes nothing.

Turning validation off for a route

r.Post("/admin/repair", handlers.RepairRecord, muzak.SkipValidation())

Reach for this only where a route must accept input the model itself would reject, such as an administrative endpoint that repairs bad data.

The full example

// CreateUserIn is the JSON body for creating a user.
//
// It is the fullest example in this service: transforms, a custom rule, a
// cross-field comparison and a collection with per-element rules.
type CreateUserIn struct {
    Username string   `json:"username" doc:"Lower case letters, digits and underscores"`
    Email    string   `json:"email" doc:"Where we reach the user"`
    Password string   `json:"password" doc:"At least 12 characters"`
    Confirm  string   `json:"confirm_password"`
    Age      int      `json:"age"`
    Role     string   `json:"role" doc:"One of admin, editor or viewer"`
    Tags     []string `json:"tags,omitzero"`
    Website  *string  `json:"website,omitzero"`
}

// Validate declares what a valid signup looks like.
func (in *CreateUserIn) Validate(v *muzak.Validation) {
    v.String(&in.Username).Trim().Lower().Required().MinLen(2).MaxLen(32).
        Matches(`^[a-z0-9_]+$`).
        Message("may only contain lower case letters, digits and underscores").
        NotOneOf("admin", "root", "support").
        Message("is reserved")

    v.String(&in.Email).Trim().Lower().Required().Email()

    v.String(&in.Password).Required().MinLen(12).Must(NotACommonPassword)

    v.String(&in.Confirm).Equal(in.Password).Message("must match the password")

    v.Number(&in.Age).Required().Between(18, 120)

    v.String(&in.Role).Required().OneOf("admin", "editor", "viewer")

    v.Slice(&in.Tags).MaxItems(10).Unique().Each(validateTag())

    v.String(&in.Website).URL()

    // A cross-field rule is an ordinary Go expression over the model's fields.
    v.When(in.Role == "admin" && in.Age < 21).
        Reject(&in.Role, "an admin must be at least 21")
}

Where to go next

Error Handling covers the envelope these failures arrive in, and OpenAPI covers the document the constraints reach.

Open source under MIT / Apache-2.0.Built on net/http, and nothing else.