Guide
Typed Handlers
Write a handler that takes a struct and returns a value, and let Zinc bind, validate and send the JSON.
A typed handler is a function that takes a struct and returns a value. Zinc fills the struct from the request, validates it, calls your function and sends the result as JSON. Use it for JSON API routes, where most handlers would otherwise start with the same bind-and-check lines.
package main
import ( "log"
"github.com/0mjs/zinc")
type CreateUser struct { OrgID string `path:"org"` DryRun bool `query:"dry_run"` Email string `json:"email" validate:"required,email"`}
type User struct { ID int `json:"id"` OrgID string `json:"org_id"` Email string `json:"email"`}
func createUser(c *zinc.Context, in CreateUser) (User, error) { user := User{ID: 1, OrgID: in.OrgID, Email: in.Email} if in.DryRun { return user, nil } // save user in your data layer return user, nil}
func main() { app := zinc.New()
app.Post("/orgs/{org}/users", zinc.Typed(createUser)).Status(zinc.StatusCreated)
log.Fatal(app.Listen(":8080"))}curl -i -X POST localhost:8080/orgs/acme/users \ -H 'Content-Type: application/json' -d '{"email":"ada@example.com"}'# HTTP/1.1 201 Created# Content-Type: application/json; charset=utf-8## {"id":1,"org_id":"acme","email":"ada@example.com"}
curl -X POST 'localhost:8080/orgs/acme/users?dry_run=yes' \ -H 'Content-Type: application/json' -d '{"email":"ada@example.com"}'# {"error":{"status":400,"message":"invalid query parameter","fields":{"dry_run":"must be a boolean"}}}
curl -X POST localhost:8080/orgs/acme/users \ -H 'Content-Type: application/json' -d '{"email":"not-an-email"}'# {"error":{"status":422,"message":"validation failed","fields":{"email":"must be an email address"}}}zinc.Typed(createUser) turns the function into an ordinary handler, so you can use it anywhere a handler goes, including after route middleware. .Status(zinc.StatusCreated) sets the status for a successful call; a status the function sets itself with c.Status, 200 included, wins over it. createUser only runs when the input is valid.
Compare with a plain handler
Section titled “Compare with a plain handler”This is the same route written without zinc.Typed:
app.Post("/orgs/{org}/users", func(c *zinc.Context) error { var in CreateUser if err := c.Bind().All(&in); err != nil { return err } user, err := createUser(c, in) if err != nil { return err } return c.Status(zinc.StatusCreated).JSON(user)})The typed version does the same work: bind, return binding and validation errors, call your code, send JSON. What’s left in your function is the part only you can write. It’s also easier to test, because it’s a plain function with a struct in and a value out.
Say where each field comes from
Section titled “Say where each field comes from”The input is a struct, and each field’s tag says where its value comes from:
| Tag | Source |
|---|---|
path:"org" |
A route parameter |
query:"dry_run" |
The query string |
header:"X-Trace-ID" |
A request header |
cookie:"session" |
A cookie |
json:"email" (or xml, form) |
The body |
Only tagged fields are filled. Binding covers the supported types and tag options.
The body is decoded by its Content-Type, so a typed handler also accepts any format you add a decoder for. A decoder reads its own library’s tags: to accept JSON and YAML bodies, give the field both json:"email" and yaml:"email".
For a route with no input, use struct{}:
func health(c *zinc.Context, _ struct{}) (zinc.Map, error) { return zinc.Map{"ok": true}, nil}
app.Get("/health", zinc.Typed(health))curl localhost:8080/health# {"ok":true}What bad input gets back
Section titled “What bad input gets back”Your function isn’t called when the input is wrong. The client gets one of these instead:
| Problem | Response |
|---|---|
A value doesn’t parse, such as ?dry_run=yes |
400, naming the field: "fields":{"dry_run":"must be a boolean"} |
| The body isn’t valid JSON | 400: {"error":{"status":400,"message":"invalid request body"}} |
A validate rule fails |
422, naming the field: "fields":{"email":"must be an email address"} |
Choose the success status
Section titled “Choose the success status”A typed handler answers 200 by default. Declare another success status on the route:
app.Post("/users", zinc.Typed(createUser)).Status(zinc.StatusCreated) // 201Status accepts 2xx codes, and the redirect codes 301, 302, 303, 307 and 308 for a Redirect output. A Redirect output takes only the redirect codes. Anything else panics when the route is registered.
The status you declare is the one sent and the one the spec documents. A status that middleware or the handler sets with c.Status wins over it.
Send no body
Section titled “Send no body”Return zinc.NoContent for a response without a body. The route answers 204 unless you declare another status:
type UserID struct { ID int `path:"id"`}
app.Delete("/users/{id}", zinc.Typed(func(c *zinc.Context, in UserID) (zinc.NoContent, error) { return zinc.NoContent{}, nil // delete user in.ID in your data layer}))
app.Post("/jobs", zinc.Typed(enqueue)).Status(zinc.StatusAccepted) // 202, no bodycurl -i -X DELETE localhost:8080/users/7# HTTP/1.1 204 No Content
curl -X DELETE localhost:8080/users/abc# {"error":{"status":400,"message":"invalid path parameter","fields":{"id":"must be an integer"}}}Here enqueue is any function of the form func(*zinc.Context, T) (zinc.NoContent, error).
Send text, files and redirects
Section titled “Send text, files and redirects”The output type says what the response is. Anything else is sent as JSON; these types are sent as what they are, and the OpenAPI spec documents each one’s media type:
| Return | Sends |
|---|---|
zinc.Text("hello") |
text/plain |
zinc.HTML("<h1>Hi</h1>") |
text/html |
zinc.Bytes{Type: "text/csv", Data: b} |
Data as Type, or application/octet-stream |
zinc.File{Path: "report.pdf", Name: "q3.pdf"} |
A file from disk, or from FS when set; Name makes it a download |
zinc.Stream{Type: "text/event-stream", Reader: r} |
r copied to the response, then closed if it’s an io.Closer |
zinc.Redirect("/new") |
302, or the route’s redirect status, with Location |
type Range struct { From string `query:"from"`}
app.Get("/reports/sales.csv", zinc.Typed(func(c *zinc.Context, in Range) (zinc.Bytes, error) { return zinc.Bytes{Type: "text/csv", Data: salesCSV(in.From)}, nil // salesCSV: your code})).Produces(http.StatusOK, "text/csv")
app.Get("/old-docs", zinc.Typed(func(*zinc.Context, struct{}) (zinc.Redirect, error) { return "/docs", nil})).Status(http.StatusMovedPermanently)Bytes, File and Stream choose their media type when the handler runs, so the spec lists them as application/octet-stream unless the route says otherwise with .Produces(status, mediaTypes...).
Send headers and cookies
Section titled “Send headers and cookies”Tag an output field header and it’s sent as that response header instead of in the body. Add json:"-" too, so it isn’t also in the JSON. A *http.Cookie tagged header:"Set-Cookie" sets a cookie:
type Created struct { Location string `header:"Location" json:"-"` Session *http.Cookie `header:"Set-Cookie" json:"-"` ID string `json:"id"` Name string `json:"name"`}
app.Post("/pets", zinc.Typed(func(c *zinc.Context, in CreatePet) (Created, error) { pet := pets.Add(in.Name) // pets: your store return Created{ Location: "/pets/" + pet.ID, Session: &http.Cookie{Name: "last_pet", Value: pet.ID, Path: "/"}, ID: pet.ID, Name: pet.Name, }, nil})).Status(http.StatusCreated)curl -i -X POST localhost:8080/pets -H 'Content-Type: application/json' -d '{"name":"Tom"}'# HTTP/1.1 201 Created# Content-Type: application/json; charset=utf-8# Set-Cookie: last_pet=7; Path=/## {"id":"7","name":"Tom"}The spec lists each header on the response. What a field sends:
| Field | Sends |
|---|---|
string |
The value; nothing when it’s empty |
A number or bool |
The value, always, 0 and false included |
time.Time |
An HTTP date, such as Fri, 02 Oct 2026 09:30:00 GMT; nothing when it’s zero |
| A pointer | The value it points to; nothing when it’s nil |
A slice, such as []string |
One header line per element |
*http.Cookie or []*http.Cookie |
Set-Cookie, through c.SetCookie |
A field tagged header without json:"-" stays in the body and sends no header, so an input type with header fields can be returned as it is.
Return an error
Section titled “Return an error”Return an error and it goes to the error handler, as it would from any handler. Zinc’s error constructors and your own errors with a StatusCode() method keep their status:
func getUser(c *zinc.Context, in UserID) (User, error) { user, err := users.Find(c.Context(), in.ID) // your data layer if errors.Is(err, sql.ErrNoRows) { return User{}, zinc.NotFound("user not found") } return user, err}
app.Get("/users/{id}", zinc.Typed(getUser))curl localhost:8080/users/8# {"error":{"status":404,"message":"user not found"}}Use the context
Section titled “Use the context”Your function still gets the *zinc.Context, so you have everything a plain handler has. Use c.Context() to stop work when the client goes away, zinc.Value to read what middleware stored, and c.SetHeader to add headers that depend on the request:
func createUser(c *zinc.Context, in CreateUser) (User, error) { user, err := users.Create(c.Context(), in) // your data layer if err != nil { return User{}, err } c.SetHeader(zinc.HeaderLocation, fmt.Sprintf("/orgs/%s/users/%d", in.OrgID, user.ID)) return user, nil}curl -i -X POST localhost:8080/orgs/acme/users \ -H 'Content-Type: application/json' -d '{"email":"ada@example.com"}'# HTTP/1.1 201 Created# Content-Type: application/json; charset=utf-8# Location: /orgs/acme/users/7## {"id":7,"org_id":"acme","email":"ada@example.com"}When to use a plain handler
Section titled “When to use a plain handler”A typed handler answers with JSON and headers, nothing, or one of the output types. Write a plain func(c *zinc.Context) error for routes that send events one at a time with c.SSE, render templates, or choose the format from the Accept header. Both kinds of handler work side by side in the same app.
Good to know
Section titled “Good to know”Which status wins
Section titled “Which status wins”A status you set inside the function with c.Status(...) wins over the one declared with .Status(...) on the route. If the function writes the response itself, for example with c.Data, Zinc ignores the returned value and sends what you wrote, but the spec still describes the output type. Return an output type instead, so the two agree.
Typed and plain handlers bind the same way
Section titled “Typed and plain handlers bind the same way”A typed handler binds exactly as c.Bind().All does: body, headers, cookies, query and path. A working plain handler converts to a typed one without changing what it reads.
Mistakes show up at startup
Section titled “Mistakes show up at startup”zinc.Typed panics if the input type isn’t a struct, or if the function is nil. Zinc also works out how to fill the struct at that point, so the first request doesn’t pay for it.
An empty body is allowed
Section titled “An empty body is allowed”A request with no body still binds path, query, header and cookie fields, then runs the validator. A required body field comes back as a 422, not a 400.
Next steps
Section titled “Next steps”- Typed CRUD API: a complete resource built with typed handlers.
- Binding: tags, supported types and validators.
- Errors: how errors become responses, and how to change them.