binder / Docs / Error handling

Error handling

binder distinguishes a bad request from a bad handler. Failures about one field arrive as a *BindError naming it; failures about the request as a whole carry a sentinel you can match with errors.Is. Between them a handler can answer with the right status code without parsing any message text.

BindError

Anything that concerns a single field — a value that would not convert, a required value that was not sent, a TextUnmarshaler that refused — is a *BindError:

type BindError struct {
	Field   string // name of the Go struct field
	Source  string // tag source the value came from, such as "query"
	Name    string // key looked up in that source
	Message string // complete description of what went wrong
	Err     error  // underlying cause, reachable with errors.Is and errors.As
}

Those four strings are what turns “400 Bad Request” into a message the client can act on:

if err := binder.Bind(r, &req); err != nil {
	var bindErr *binder.BindError
	if errors.As(err, &bindErr) {
		log.Printf("field %s from %s %q: %v",
			bindErr.Field, bindErr.Source, bindErr.Name, bindErr)
	}
	http.Error(w, err.Error(), http.StatusBadRequest)
	return
}

BindError implements Unwrap, so the cause stays reachable. A required field failing matches both errors.As(err, &bindErr) for the detail and errors.Is(err, binder.ErrMissingRequired) for the category.

The sentinels

Five errors describe the request as a whole. Match on these, never on message text — the text is explicitly outside the compatibility contract.

ErrorMeaningStatus
ErrMalformedBody The body could not be parsed as its Content-Type declares. 400 Bad Request
ErrMissingRequired A field tagged required had no value. Wrapped by a BindError. 400 Bad Request
ErrUnknownField The body carried a key nothing binds, with DisallowUnknownFields set. 400 Bad Request
ErrBodyTooLarge The body exceeded MaxBodySize. 413 Content Too Large
ErrInvalidTarget The target was not a non-nil pointer to a struct. 500 Internal Server Error

Whose fault is it

ErrInvalidTarget is the one case that should not be blamed on the client. It means the handler passed something that was never bindable — a value instead of a pointer, a nil pointer, a pointer to a map — and no request would have made it work:

switch {
case errors.Is(err, binder.ErrInvalidTarget):
	http.Error(w, "server error", http.StatusInternalServerError)
case errors.Is(err, binder.ErrBodyTooLarge):
	http.Error(w, "request body too large", http.StatusRequestEntityTooLarge)
case err != nil:
	http.Error(w, err.Error(), http.StatusBadRequest)
}

Wrapping that switch in a small helper is usually worth it, so that every handler answers the same way:

// bind fills dst from r, writing the response itself on failure. It reports
// whether the handler should continue.
func bind(w http.ResponseWriter, r *http.Request, dst any) bool {
	err := binder.Bind(r, dst)
	switch {
	case err == nil:
		return true
	case errors.Is(err, binder.ErrInvalidTarget):
		log.Printf("bind: %v", err) // our bug, not theirs
		http.Error(w, "internal server error", http.StatusInternalServerError)
	case errors.Is(err, binder.ErrBodyTooLarge):
		http.Error(w, "request body too large", http.StatusRequestEntityTooLarge)
	default:
		http.Error(w, err.Error(), http.StatusBadRequest)
	}
	return false
}

func handler(w http.ResponseWriter, r *http.Request) {
	var req CreateComment
	if !bind(w, r, &req) {
		return
	}
	// ...
}

Malformed bodies

A body that declares JSON and is not JSON is an error. In 1.0 it bound nothing and reported success, which meant a client's syntax mistake surfaced as an empty struct several layers away:

if errors.Is(err, binder.ErrMalformedBody) {
	http.Error(w, "malformed request body", http.StatusBadRequest)
	return
}

A body whose Content-Type is neither JSON nor form-encoded is not parsed at all, so it is never malformed — such a request binds from its path, query, cookie and header values and leaves body fields at their zero values.

Built with Go 1.27, errors.As(err, &*json.SyntaxError{}) no longer matches: encoding/json is implemented on json/v2 and returns different error types. Test for ErrMalformedBody instead — it is the reason the sentinel exists.

What binder will not do