Pular para o conteúdo
Esta página ainda não foi traduzida. O conteúdo abaixo está em inglês. Ler em inglês

The basics

Validation

The z package parses untrusted input into typed Go values with Zod-style schemas, and checks outgoing data with the same contract.

Última atualização:

z is a Zod-inspired schema library: build a schema once, then parse untrusted input into a typed Go value or check outgoing data against the same contract.

A taste#

app/requests/post.go
type StorePost struct {
	Title       string     `json:"title"`
	Body        string     `json:"body"`
	Tags        []string   `json:"tags"`
	Status      string     `json:"status"`
	PublishedAt *time.Time `json:"published_at"`
}

var StorePostSchema = z.Struct[StorePost](z.Shape{
	"title":        z.String().Trim().Min(3).Max(255),
	"body":         z.String().Min(1),
	"tags":         z.Slice(z.String().Trim().ToLower().Alphanum()).Max(5).Default([]string{}),
	"status":       z.Enum("draft", "published").Default("draft"),
	"published_at": z.Time().Optional(),
}).Refine(func(p StorePost) error {
	if p.Status == "published" && p.PublishedAt == nil {
		return z.FieldError("published_at", "required when status is published")
	}
	return nil
})
Go
// In a handler: decode + parse + validate; failures become 422.
in, err := web.Parse(c, requests.StorePostSchema) // in is requests.StorePost
if err != nil {
	return err
}

Schemas#

ConstructorGo typeChecks and transforms
z.String()stringMin, Max, Len, NonEmpty, Email, URL, UUID, ULID, Regex, Alpha, Alphanum, Numeric, StartsWith, EndsWith, Includes, OneOf; Trim, ToLower, ToUpper, Preprocess(fn); invalid UTF-8 and control characters rejected
z.Int(), z.Int32(), z.Int64(), z.Uint(), z.Uint64(), z.NumberOf[T]()integersMin, Max, Positive, NonNegative, MultipleOf; fractions and overflow rejected
z.Float(), z.Float32()floatsMin, Max, Positive; NaN and ±Inf rejected
z.Bool()bool—
z.Time()time.TimeAfter, Before, Layouts(...) (default RFC 3339, datetime-local, date)
z.Enum(vals...), z.EnumOf[T](vals...)string or a named string typemembership
z.Slice(elem)[]EMin, Max, Len, NonEmpty, Unique
z.Map(val)map[string]VMin, Max
z.Struct[T](z.Shape{...})Tfield schemas; Strict() rejects unknown keys
z.Custom[T](fn)Tuser parser

Every schema supports Optional(), Nullable(), Default(v), Refine(fn), Transform(fn), Message(code, msg) and Describe(text). Schemas are immutable values, safe to share across goroutines.

A parse runs in this order: coercion and type check, then string pre-steps (Trim, ToLower, ToUpper, Preprocess, in declaration order), then checks (Min, Regex, Email…), then Transform, then Refine. Normalize with Preprocess when the checks must see the normalized value:

Go
z.String().Preprocess(digitsOnly).Regex(e164) // Regex sees "11987654321"
z.String().Transform(digitsOnly).Regex(e164)  // Regex sees the raw input

The output of the pre-steps is checked again for invalid UTF-8 and control characters, and Preprocess(nil) panics. JSON Schema and OpenAPI keywords (pattern, minLength…) describe the normalized value, after Trim/ToLower/Preprocess, not the raw input.

Coercion#

Forms, query strings and route params arrive as strings; schemas coerce only where it is lossless: numeric strings to Int/Float, "on"/"1"/"true" to Bool, RFC 3339 strings to Time, repeated keys to slices and bracketed keys to structs.

Errors#

Parse returns z.Issues, collected for the whole input. Each issue has a dotted Path (tags.2, author.email), a stable Code (required, invalid_type, too_small, too_big, invalid_string, invalid_enum, custom), a Message and Params. The error handler converts them to an *errs.ValidationError:

RequestResponse
HTML form (session middleware installed)redirect back (Back) with errors and old input flashed; read them with c.Errors() / c.Old(field) or {{error "title"}} / {{old "title"}} in views
htmx422 HTML error fragment (htmx 4 swaps 4xx responses)
JSON / API422 application/problem+json with an errors map

Password fields, _token and _method are never flashed as old input.

Translated refinements#

Built-in messages are translated from validation.* catalog keys (see localization). A Refine callback makes its message translatable by returning z.FieldErrorKey(path, key, fallback, params) instead of z.FieldError; fallback is the English message, used when the locale has no validation.<key>. With an empty key, FieldErrorKey behaves like z.FieldError. Placeholder names in fallback and in catalog templates are lower snake_case ({field}, {other_field}); other braces are left as is. Messages are plain text: escaping is the renderer's job (html/template does it in views).

Go
.Refine(func(r Register) error {
	if r.Password != r.PasswordConfirmation {
		return z.FieldErrorKey("password_confirmation", "confirmed", "The {field} does not match.", nil)
	}
	return nil
})
resources/lang/pt-BR.json
{
  "validation": {
    "confirmed": "A {field} não confere.",
    "attributes": { "password_confirmation": "confirmação de senha" }
  }
}

A handler can also return z.FieldError, z.FieldErrorKey or z.Issues directly (wrapped with %w too), for checks that need the database or other services: the default error handler treats them like *errs.ValidationError, so the client gets a 422 with field errors (problem+json for JSON clients; with sessions, classic and Inertia form posts flash the errors and redirect back; an HTML page otherwise, and always for GET/HEAD, which never redirect back), localized when the i18n middleware runs. z.AsIssues returns the single issue of a FieldError, and errs.Status reports 422 for both. These 422s are logged at Info.

When the data being validated is not client input (a database row, an upstream API response), wrap the error so it is not reported to the client as its own mistake: return errs.Wrap(http.StatusInternalServerError, err) answers a plain 500 with no field errors.

Return z.FieldError or z.FieldErrorKey from Refine and Custom for any message meant for the client. Any other error fails closed: the issue gets the generic message The {field} field is invalid. (key invalid, so validation.invalid translates it), its text stays in Issue.Err, and web.Parse/the error handler log it at Warn. Before v0.25 the raw err.Error() text was the message.

Go
if taken {
	return z.FieldErrorKey("email", "unique", "The {field} has already been taken.", nil)
}

Struct-tag shorthand#

Go
type Login struct {
	Email    string `json:"email"    validate:"required,email"`
	Password string `json:"password" validate:"required,min=12"`
}

var LoginSchema = z.FromTags[Login]() // panics at boot on unknown rules

web.Bind[T](c) uses the tag schema for T (built once and cached by z.For[T]()).

required on a string means non-empty. On a list it means present and not null, as with OpenAPI required: an empty list passes, so use min=1 when the list must have items.

Typed boundaries#

BoundaryAPI
Body (JSON, form, multipart)web.Parse(c, schema), web.Bind[T](c)
Query stringweb.ParseQuery(c, schema)
Route paramsweb.ParseParams(c, schema, "id") — invalid params answer 404
Outgoing JSONweb.JSONWith(c, status, schema, v) — outside production a contract violation is logged and answered with 500
Any readerz.ParseJSON(r, schema)
422 response
{
  "type": "about:blank",
  "title": "Validation failed",
  "status": 422,
  "detail": "1 field(s) are invalid.",
  "instance": "/posts",
  "request_id": "Q2V5…",
  "errors": { "title": ["The title field must be at least 3 characters."] }
}