Validation
binder turns a request into a valid struct: it binds the data, then runs the validation your type defines, in the same call, so a handler has one error path rather than two. What it does not ship is a rule language — the rules are yours.
The Validator interface
type Validator interface {
Validate(ctx context.Context) error
}
If the target implements it, Bind calls
Validate once every field has bound, and returns its error
wrapped with %w, so errors.Is and
errors.As still reach it. Nothing to register, nothing to
configure:
type CreateUser struct {
Name string `body:"name,required"`
Email string `body:"email,required"`
Age int `body:"age"`
}
func (r CreateUser) Validate(ctx context.Context) error {
if !strings.Contains(r.Email, "@") {
return errors.New("email must be an address")
}
if r.Age < 18 {
return errors.New("user must be 18 or older")
}
return nil
}
func handler(w http.ResponseWriter, r *http.Request) {
var req CreateUser
// One step: bind, then validate.
if err := binder.Bind(r, &req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// req is bound and valid.
json.NewEncoder(w).Encode(createUser(req))
}
A value receiver, as above, works on the pointer Bind is
given. A pointer receiver works too, and is what you want if
Validate normalises as well as checks — trimming whitespace,
lower-casing an address, filling a default.
Validate runs only when every field bound. A field that
failed holds whatever was left in it, so running your rules over it would add
a second, misleading error for the same input; binding failures come back
as BindErrors
instead.
A Validate promoted from an embedded pointer, such as
*Audit, is not called when that pointer is still nil
because none of its fields was sent: there is nothing to validate, and
calling it would dereference nil. A type's own Validate
always runs, as does one promoted from an embedded value that is not
itself reached through a nil pointer. An embedded
Validator interface that is nil is skipped the same way.
The request's context
Bind passes r.Context(), so a rule can use the
authenticated user, a tenant, or the request's deadline for a lookup.
Rules that need none of that simply ignore it:
func (r CreateOrder) Validate(ctx context.Context) error {
user, ok := auth.UserFrom(ctx)
if !ok || !user.CanOrder(r.SKU) {
return errors.New("sku not available to this account")
}
return nil
}
If validation does I/O, a cancelled request surfaces as an error matching
errors.Is(err, context.Canceled).
Reporting several problems
binder reports every field that failed to bind; Validate is
your code, and can report every rule that failed. Return an error type of
your own, and a handler can recognise it with errors.As and
render it field by field:
// ValidationErrors holds problems keyed by the field name the client sent.
type ValidationErrors map[string]string
func (v ValidationErrors) Error() string {
return fmt.Sprintf("%d fields failed validation", len(v))
}
func (r CreateUser) Validate(ctx context.Context) error {
errs := ValidationErrors{}
if !strings.Contains(r.Email, "@") {
errs["email"] = "is not a valid address"
}
if r.Age < 18 {
errs["age"] = "must be 18 or older"
}
if len(errs) > 0 {
return errs
}
return nil
}
// In the handler's error handling:
var valErrs ValidationErrors
if errors.As(err, &valErrs) {
// respond with valErrs, e.g. as a 422 body
}
A named type is easier to recognise than an errors.Join of
plain errors, since other errors — ErrMalformedBody, for one
— also wrap more than one error, and a check for “something joined” would
catch them too.
Why there are no validation tags
A min, max, email tag vocabulary is
the obvious next feature and deliberately absent. Adding it would mean
inventing a rule language, an error format and a translation story, and
the result would be a worse version of what
go-playground/validator
already does well. binder composes with whichever validator you prefer:
var validate = validator.New()
type CreateUser struct {
Email string `body:"email" validate:"required,email"`
Age int `body:"age" validate:"gte=18"`
}
func (r CreateUser) Validate(ctx context.Context) error {
return validate.StructCtx(ctx, r)
}
The two tags coexist: binder reads body, the validator reads
validate, and neither knows about the other.
required in a binder tag is not validation — it asks whether
the request supplied the value, before any conversion. “Must be
present” is a binding question; “must be a valid email” is a validation
one. See Options.