Zero dependencies
Nothing in go.mod but Go itself. Nothing to audit,
nothing to upgrade in a hurry, no transitive surprise arriving
through a package you chose for something else.
An open-source library from uRadical · v1.2.0
Go 1.27 · MIT · zero dependencies
Path parameters, query strings, JSON, form and multipart bodies, cookies and headers — onto one struct, in one call, with every bad field reported at once. binder does that, runs your own validation in the same call, and nothing else, with nothing in its go.mod but Go itself.
go get uradical.io/go/binder// ...in every handler, again
id := r.PathValue("id")
n, err := strconv.Atoi(id)
if err != nil { /* 400 */ }
name := r.URL.Query().Get("name")
var body struct{ Email string }
if err := json.NewDecoder(r.Body).
Decode(&body); err != nil {
/* 400 */
}
trace := r.Header.Get("X-Request-ID")
// ...and a body someone forgot to cap
type Req struct {
ID int `path:"id"`
Name string `query:"name"`
Email string `body:"email"`
Trace string `header:"X-Request-ID"`
}
var req Req
if err := binder.Bind(r, &req); err != nil {
http.Error(w, err.Error(),
http.StatusBadRequest)
return
}
// every field filled, body capped,
// one error path
binder binds, then runs the validation your type defines. It ships no rule language, and does not log, route or transform. That refusal is what keeps it small enough to read in one sitting and cheap enough to leave in the hot path.
Nothing in go.mod but Go itself. Nothing to audit,
nothing to upgrade in a hurry, no transitive surprise arriving
through a package you chose for something else.
About 2,700 lines of code, and nothing else to read beneath them. When binding does something you did not expect, the answer is a short read away rather than three layers into a framework.
60 ns to bind a path parameter, 0.75 µs for a full mixed request. Tags are reflected over once per type, path and header values are looked up directly, and query and cookie values are found by scanning rather than parsing, allocating nothing. Faster than Echo and Gin in every comparison, with as few allocations or fewer, and a fraction of the memory.
A field's tag says where its value comes from, and that is the whole model. No conventions inferred from names, no ambient configuration, no behaviour that changes with what else is imported.
Works with your validator, your logger, your router, your framework.
Implement Validate(ctx) error and your own checks run
as part of the same call, with the request's context.
The request body is restored after reading, so binding does not consume it. Middleware further down the chain still sees what the client sent.
Each field names where its value comes from. When a field carries more than one tag the first in this order wins, so precedence is something you can read off the page rather than discover in production.
pathPath parameters, read through net/http's own PathValue. binder does not route; it reads what your mux already resolved.
ID int `path:"id"`
queryThe URL query string, scanned for just the keys your fields ask for, with no map built for up to 64 parameters. Repeated parameters fill a slice.
Tags []string `query:"tag"`
bodyJSON, form-encoded or multipart, chosen by Content-Type — including the +json suffix types. One struct serves an API call, an HTML form post and a file upload, and form: reads a form body or else the query, as r.FormValue does.
Email string `body:"email"`
jsonThe body again, for types already tagged for encoding/json. json:"-" is never bound, and binder reads its options from this tag too, so json:"x,omitempty" skips empty values here as well.
Email string `json:"email"`
cookieRequest cookies by name. A cookie that was not sent leaves the field as it was, unless it is tagged required.
Token string `cookie:"session"`
headerRequest headers, matched case-insensitively, so the tag can spell one however it reads best. Repeated headers fill a slice.
Auth string `header:"Authorization"`
A query string has no types; the field decides what its value means.
Numbers, booleans and floats parse, slices fill, pointers tell
“absent” from “sent as zero”, nested structs bind from nested JSON,
and anything implementing encoding.TextUnmarshaler —
including time.Time, net.IP and
uuid.UUID — or UnmarshalJSON decides for
itself.
A value that will not convert is an error naming the field, the source and the key, not a zero quietly left in place.
type Search struct {
Q string `query:"q,required"`
Page int `query:"page"`
Tags []string `query:"tag"`
Since time.Time `query:"since"`
Cursor *string `query:"cursor"`
}
var req = Search{Page: 1} // no ?page=, or an empty one, keeps it
err := binder.Bind(r, &req)Binding is where untrusted input first meets your types, so the failure modes matter more than the feature list. Each of these exists because the quiet version of it costs someone an afternoon.
Bodies are capped at 10 MB by default, enforced while reading rather
than trusted from Content-Length. An oversized body is
rejected whole — never truncated, so a handler never sees half a
request. Binding costs time and memory in proportion to the body,
and at most 100 failures are reported.
A body that declares JSON and is not JSON is
ErrMalformedBody, not a struct full of zero values and
a cheerful 200.
An unusable target is reported as an error, and an unexported field
is skipped, as encoding/json skips one. Nothing binder does turns a strange request into a stack trace in
your request path.
ErrInvalidTarget means the handler passed something
unbindable and deserves a 500; ErrBodyTooLarge deserves a
413; the rest point at the request and deserve a 400. The distinction is in the error, not in the
message text.
binder does not stop at the first bad field. Every field that fails
comes back together as BindErrors, each entry carrying
the key the client sent and the source it was read from — enough to
tell the client everything it got wrong in one 400, rather than one
field per round trip.
Failures of the request as a whole carry a sentinel to match with
errors.Is, except a body that could not be read at all,
which returns the I/O error. Message text is deliberately not part of the
API.
var errs binder.BindErrors
switch {
case errors.As(err, &errs):
for _, e := range errs {
// e.Name, e.Source, e.Err
}
// 400, listing every field
case errors.Is(err, binder.ErrInvalidTarget):
// our bug: 500
case errors.Is(err, binder.ErrBodyTooLarge):
// 413
case err != nil:
// 400
}The same request bound into the same shape of struct, by each library in turn. Nanoseconds per bind, lower is better, shared scale across the three panels.
Requires Go 1.27 or newer.
go get uradical.io/go/bindertype CreateComment struct {
PostID int `path:"id"`
Author string `body:"author,required"`
Text string `body:"text,required"`
}var req CreateComment
if err := binder.Bind(r, &req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}ErrInvalidTarget a 500 — it is your bug, not theirs.
The documentation covers every source, type conversion, options, errors, validation and the compatibility contract — or start with the quick start.