An open-source library from uRadical · v1.1.0

Go 1.27 · MIT · zero dependencies

HTTP requests,
bound to your structs.

Path parameters, query strings, JSON, form and multipart bodies, cookies and headers — onto one struct, in one call. binder does that and nothing else, in one file with nothing in its go.mod but Go itself.

go get uradical.io/go/binder
before
// ...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 RequestBody
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
after
type Req struct {
    ID    int         `path:"id"`
    Name  string      `query:"name"`
    Body  RequestBody `body:"data"`
    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

Do one thing, do it well

binder only binds. It does not validate, 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.

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.

Small enough to read

One file, about 730 lines of code. When binding does something you did not expect, the answer is a short read away rather than three layers into a framework.

Sub-microsecond

105 ns to bind a path parameter, 1.3 µs for a full mixed request. Tags are reflected over once per type, not once per request, and the query string is parsed only if a field asks for it. That is level with the framework binders, and about 1.8× the cost of writing the extraction out by hand.

See the full benchmarks →  ·  Compared with Echo and Gin →

No magic

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.

Composable

Works with your validator, your logger, your router, your framework. Implement Validate() error and your own checks run as part of the same call.

Validation →

Middleware-friendly

The request body is restored after reading, so binding does not consume it. Middleware further down the chain still sees what the client sent.

Six sources, one struct

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.

path

Path parameters, read through net/http's own PathValue. binder does not route; it reads what your mux already resolved.

ID int `path:"id"`

query

The URL query string, parsed once per call and only when a field asks for it. Repeated parameters fill a slice.

Tags []string `query:"tag"`

body

JSON or form-encoded, chosen by Content-Type — including the +json suffix types. One struct serves an API call and an HTML form post.

Email string `body:"email"`

json

The body again, for types already tagged for encoding/json. Prefer body for anything that needs binder's options.

Email string `json:"email"`

cookie

Request cookies by name. A cookie that was not sent leaves the field at its zero value, unless it is tagged required.

Token string `cookie:"session"`

header

Request headers, matched case-insensitively, so the tag can spell one however it reads best. Repeated headers fill a slice.

Auth string `header:"Authorization"`

Types, not strings

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 — 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.

Types & conversion →

type Search struct {
	Q      string    `query:"q,required"`
	Page   int       `query:"page,omitempty"`
	Tags   []string  `query:"tag"`
	Since  time.Time `query:"since"`
	Cursor *string   `query:"cursor"`
}

var req = Search{Page: 1} // omitempty keeps the default
err := binder.Bind(r, &req)

Careful with what the client sends

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.

Bounded reads

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.

Nothing is swallowed

A body that declares JSON and is not JSON is ErrMalformedBody, not a struct full of zero values and a cheerful 200.

No panics

An unusable target or an unsettable field is reported as an error. Nothing binder does turns a strange request into a stack trace in your request path.

Their fault or yours

ErrInvalidTarget means the handler passed something unbindable and deserves a 500; every other failure is the client's and deserves a 400. The distinction is in the error, not in the message text.

Error handling →

Errors that name the field

Anything that concerns one field arrives as a *BindError carrying the Go field name, the source it was read from and the key looked up there — enough to write a useful 400 without guessing which of eleven fields the client got wrong.

Everything else carries a sentinel to match with errors.Is. Message text is deliberately not part of the API.

var bindErr *binder.BindError
if errors.As(err, &bindErr) {
	log.Printf("field %s from %s %q: %v",
		bindErr.Field, bindErr.Source,
		bindErr.Name, bindErr)
}

switch {
case errors.Is(err, binder.ErrInvalidTarget):
	// our bug: 500
case errors.Is(err, binder.ErrBodyTooLarge):
	// 413
case err != nil:
	// 400
}

How fast, next to what?

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.

Query string, 5 fields hand-written stdlib: 363 ns hand-written stdlib 363 binder: 661 ns binder 661 Echo: 871 ns Echo 871 Gin: 1104 ns Gin 1,104 gorilla/schema: 1944 ns gorilla/schema 1,944 JSON body, 5 fields hand-written stdlib: 741 ns hand-written stdlib 741 binder: 799 ns binder 799 Echo: 816 ns Echo 816 Gin: 856 ns Gin 856 Every source at once binder: 1131 ns binder 1,131 Echo: 1327 ns Echo 1,327 Gin: 1464 ns Gin 1,464
binder leads on the query string and on filling a struct from every source, and is level with Echo on a JSON body — where it also allocates more than the framework binders. Method, library versions and the allocation figures are on the comparison page.

Install

Requires Go 1.27 or newer, for native path values and the standard library uuid package.

go get uradical.io/go/binder

Then, three steps

  1. Describe the request as a struct. One field per value you need, each tagged with where it comes from.
    type CreateComment struct {
    	PostID int    `path:"id"`
    	Author string `body:"author,required"`
    	Text   string `body:"text,required"`
    }
  2. Bind it. One call reads every source and converts every value.
    var req CreateComment
    if err := binder.Bind(r, &req); err != nil {
    	http.Error(w, err.Error(), http.StatusBadRequest)
    	return
    }
  3. Handle the failures you care about. Match the sentinels rather than the message, and give ErrInvalidTarget a 500 — it is your bug, not theirs.

The documentation covers all six sources, type conversion, options, errors, validation and the compatibility contract — or start with the quick start.