binder / Docs / Validation

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.