Skip to content

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"))
}
Terminal window
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.

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.

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))
Terminal window
curl localhost:8080/health
# {"ok":true}

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"}

A typed handler answers 200 by default. Declare another success status on the route:

app.Post("/users", zinc.Typed(createUser)).Status(zinc.StatusCreated) // 201

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

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 body
Terminal window
curl -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).

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

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)
/pets/7
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 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))
Terminal window
curl localhost:8080/users/8
# {"error":{"status":404,"message":"user not found"}}

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
}
Terminal window
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"}

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.

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.

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.

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.

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