Core

Handlers

Write typed handlers with ezz.H and ezz.HFunc, and control how responses are written.

A handler is a function with this shape:

func(ctx *ezz.Context, req Req) (Resp, error)

ezz.H wraps it into a *Handler[Req, Resp], which implements http.Handler and reports its own request and response types so the OpenAPI generator can document it.

Write a handler with a request type

type GetUserRequest struct {
    ID     string `path:"id" validate:"required"`
    Expand bool   `query:"expand"`
}

app.GET("/users/{id}", ezz.H(func(ctx *ezz.Context, req GetUserRequest) (*User, error) {
    user, ok := store[req.ID]
    if !ok {
        return nil, ezz.NotFound("user not found")
    }
    if req.Expand {
        user.Orders = loadOrders(ctx, user.ID)
    }
    return user, nil
}))

Before your function runs, ezz binds req from the request and validates it. A binding or validation failure returns 400 and your function is never called.

Write a handler without a request type

When there is nothing to bind, ezz.HFunc drops the second parameter:

app.GET("/health", ezz.HFunc(func(ctx *ezz.Context) (*HealthResponse, error) {
    return &HealthResponse{Status: "ok"}, nil
}))

ezz.H with a struct{} request type does the same thing, which is why ezz.HFunc(fn) has the type *Handler[struct{}, Resp].

Understand when the response is written

After your function returns, ezz decides what to send:

  1. If the function returned an error, the error is written and nothing else happens. See Errors.
  2. If you already wrote to the response yourself (ctx.JSON, ctx.Status, ctx.Redirect, ctx.ServeFile, ctx.Stream, or a direct write), ezz leaves it alone.
  3. Otherwise, if the returned value is not the zero value for its type, it is encoded as JSON with status 200.
  4. If the returned value is the zero value, nothing is written, and the server sends 200 with an empty body.

That last rule is worth internalising:

// writes {"status":"ok"}
return &HealthResponse{Status: "ok"}, nil

// writes {} because the pointer is non-nil
return &HealthResponse{}, nil

// writes nothing: a nil pointer is the zero value
var h *HealthResponse
return h, nil

// writes nothing: an empty struct value is the zero value
return HealthResponse{}, nil

Prefer pointer response types (*User) or types that are never meaningfully empty. If you genuinely want an empty success, be explicit:

app.DELETE("/users/{id}", ezz.H(func(ctx *ezz.Context, req DeleteUserRequest) (any, error) {
    store.Delete(req.ID)
    return nil, ctx.NoContent()   // 204, no body
}))

Take over the response

The context gives you the raw http.ResponseWriter, so a handler can write anything. Return nil afterwards and ezz will not touch the response.

app.GET("/reports/{id}.csv", ezz.H(func(ctx *ezz.Context, req ReportRequest) (any, error) {
    ctx.SetHeader("Content-Type", "text/csv")
    ctx.SetHeader("Content-Disposition", `attachment; filename="report.csv"`)
    ctx.Status(http.StatusOK)

    w := csv.NewWriter(ctx.Response)
    defer w.Flush()
    for _, row := range rows(req.ID) {
        if err := w.Write(row); err != nil {
            return nil, err
        }
    }
    return nil, nil
}))

Choose a response status other than 200

Call ctx.JSON with the status you want, then return nil:

app.POST("/users", ezz.H(func(ctx *ezz.Context, req CreateUserRequest) (any, error) {
    user := store.Create(req)
    ctx.SetHeader("Location", "/users/"+user.ID)
    return nil, ctx.JSON(http.StatusCreated, user)
}))

Build handlers once, at startup

ezz.H inspects the request type and compiles a binder and a validator when you call it. Do that at registration time, which is what the examples above do, and the request path stays reflection-free.

// good: compiled once at startup
app.POST("/users", ezz.H(createUser))

// bad: builds a new binder and validator on every request
app.POST("/users", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
    ezz.H(createUser).ServeHTTP(w, r)
}))

Reuse handler functions

Handlers are ordinary functions, so the usual Go patterns apply. Closures are the simplest way to inject dependencies:

func getUser(store *Store) func(*ezz.Context, GetUserRequest) (*User, error) {
    return func(ctx *ezz.Context, req GetUserRequest) (*User, error) {
        user, err := store.Find(ctx, req.ID)
        if errors.Is(err, ErrNotFound) {
            return nil, ezz.NotFound("user not found")
        }
        return user, err
    }
}

app.GET("/users/{id}", ezz.H(getUser(store)))

Methods on a service struct work equally well:

type UserAPI struct{ store *Store }

func (a *UserAPI) Get(ctx *ezz.Context, req GetUserRequest) (*User, error) { ... }

api := &UserAPI{store: store}
app.GET("/users/{id}", ezz.H(api.Get))

Inspect a handler's types

*Handler[Req, Resp] satisfies ezz.TypedHandler:

type TypedHandler interface {
    http.Handler
    RequestType() reflect.Type
    ResponseType() reflect.Type
}

The app calls this at registration and stores the result on the route, which is how /openapi.json learns your schemas without any code generation.

Next steps

Copyright © 2026