Type-Safe HTTP Handlers in Go with Generics

NestGo replaces manual request parsing with extractors — generic, composable equivalents of NestJS parameter decorators like @Body() and @Param(). Your Go handler receives fully typed, validated values, and the framework handles extraction, error responses, and JSON serialization.

Extractors: NestJS decorators, the Go way

In NestJS you annotate handler parameters with decorators. Go has no decorators, so NestGo expresses the same idea with generics: an Extractor[T] is a value that knows how to pull a typed T out of the request context.

type Extractor[T any] struct {
    Extract func(Context) (T, error)
}

Without extractors, a handler parses everything itself:

func (ctrl *Controller) Create(c core.Context) error {
    dto, err := core.Body[CreateUserDTO](c)
    if err != nil {
        return err
    }
    id, err := core.ParamInt(c, "id")
    if err != nil {
        return err
    }
    return ctrl.service.Create(id, dto)
}

With extractors and a Handle builder, the same handler becomes a plain typed function:

func (ctrl *Controller) Create(id int, dto *CreateUserDTO) (any, error) {
    return ctrl.service.Create(id, dto)
}
r.POST("/:id", core.Handle2(
    core.PInt("id"),          // extract path param "id" as int
    core.B[CreateUserDTO](),  // extract & validate body as *CreateUserDTO
    ctrl.Create,
))

If any extractor fails, the builder returns its error (already a *core.HTTPError, e.g. a 400) and your function is never called.

Extractor reference table

Extractor NestJS equivalent Returns Description
B[T]() @Body() *T Bind & validate the request body into a DTO
P(key) @Param('key') string Path parameter as string (400 if empty)
PInt(key) @Param('key', ParseIntPipe) int Path parameter as int
PInt64(key) @Param('key', ParseIntPipe) int64 Path parameter as int64 — for database primary keys
Q(key, default...) @Query('key') string Query parameter with optional default
QInt(key, default...) @Query('key', ParseIntPipe) int Query parameter as int with optional default
QDto[T]() @Query() with a DTO class *T Bind & validate all query params into a struct
H(key, required) @Headers('key') string Header value (400 if required and missing)
Ctx() — core.Context Pass the raw NestGo context through
RCtx() — context.Context The request’s standard-library context — pass to DB, gRPC, HTTP calls

Extractors can be wrapped with pipes for extra transform/validate steps, e.g. core.WithPipes(core.PInt("id"), core.IntRangePipe("id", 1, 999999)).

Handle1–Handle4: automatic JSON responses

Handle1 through Handle4 combine 1–4 extractors with a typed function of the form func(...) (any, error) and build a standard core.HandlerFunc. The response behavior is automatic:

  • Extractor error → returned as-is (typically a 400 HTTPError)
  • Your function returns an error → returned to the error-handling chain
  • Your function returns a non-nil result → JSON with status 200
  • Your function returns nil, nil → 204 No Content
// One extractor:
r.GET("/:id", core.Handle1(core.PInt("id"), ctrl.GetByID))
// func (ctrl *Ctrl) GetByID(id int) (any, error)

// Two extractors:
r.POST("/:id", core.Handle2(core.PInt("id"), core.B[CreateDTO](), ctrl.Create))
// func (ctrl *Ctrl) Create(id int, dto *CreateDTO) (any, error)

// Three extractors:
r.PUT("/:id", core.Handle3(
    core.PInt("id"),
    core.B[UpdateDTO](),
    core.H("Authorization", true),
    ctrl.Update,
))
// func (ctrl *Ctrl) Update(id int, dto *UpdateDTO, auth string) (any, error)

// Four extractors follow the same pattern with core.Handle4.

A common production pattern pairs RCtx() with PInt64 so services receive a cancellable context.Context alongside a typed ID:

r.GET("/:id", core.Handle2(core.RCtx(), core.PInt64("id"), ctrl.GetByID))

func (ctrl *Ctrl) GetByID(ctx context.Context, id int64) (any, error) {
    return ctrl.repo.FindByID(ctx, id) // DB query respects request cancellation
}

HandleC1–HandleC3: custom status codes and response control

When 200/204 is not what you want — a 201 on create, a redirect, a file download — use the HandleC variants. They run the same extractors but pass core.Context as the first argument, and your function writes the response itself, returning only error:

r.POST("/", core.HandleC1(core.B[CreateDTO](), ctrl.Create))

func (ctrl *Ctrl) Create(c core.Context, dto *CreateDTO) error {
    user, err := ctrl.service.Create(dto)
    if err != nil {
        return err
    }
    return c.JSON(201, user)
}
Builder Extractors Handler signature
HandleC1 1 func(core.Context, A) error
HandleC2 2 func(core.Context, A, B) error
HandleC3 3 func(core.Context, A, B, C) error

Rule of thumb: use Handle* for standard JSON APIs, and HandleC* whenever you need c.JSON(201, ...), c.Redirect, c.SendFile, headers, or cookies.

Binding helpers: the functions behind the extractors

Every extractor delegates to a plain helper in nestgo/core that you can also call directly inside a classic func(core.Context) error handler:

Helper Signature Behavior
Body[T] Body[T](c) (*T, error) c.Bind into a new *T, then validates (see below). 400 on bind failure
QueryDTO[T] QueryDTO[T](c) (*T, error) Binds query params into a struct via reflection using the query: tag (falling back to json:, then the lowercased field name), then validates
Param Param(c, key) (string, error) Path param; 400 if empty
ParamInt ParamInt(c, key) (int, error) Path param as int; 400 if missing or not an integer
ParamInt64 ParamInt64(c, key) (int64, error) Path param as int64; 400 if missing or not an integer
Query Query(c, key, default...) string Query param, falling back to the default when empty
QueryInt QueryInt(c, key, default...) (int, error) Query param as int; default when absent, 400 when not an integer
Header Header(c, key, required) (string, error) Header value; 400 if required and missing

QueryDTO works identically on both adapters (it never delegates to adapter binding): a field tagged - is skipped, embedded structs are bound recursively, missing parameters leave fields at their zero value, and supported field types are string, bool, all int/uint sizes, float32/float64, and []string (comma-separated).

func (ctrl *Controller) List(c core.Context) error {
    page, err := core.QueryInt(c, "page", 1)
    if err != nil {
        return err
    }
    sort := core.Query(c, "sort", "created_at")
    users, err := ctrl.service.List(page, sort)
    if err != nil {
        return err
    }
    return c.JSON(200, users)
}

The Validatable interface: automatic DTO validation

Body[T] and QueryDTO[T] (and therefore B[T]() and QDto[T]()) validate DTOs automatically in two steps:

  1. Global validator — if a validate function was registered via core.SetValidateFunc (or Config.ValidateFunc), it runs first. This is how nestgo-validator struct-tag validation plugs in.
  2. Validatable — if the DTO implements the Validatable interface, its Validate() method runs next for custom business rules.
type Validatable interface {
    Validate() error
}
type CreateUserDTO struct {
    Name  string `json:"name"`
    Email string `json:"email"`
}

func (d *CreateUserDTO) Validate() error {
    if d.Name == "" {
        return core.ErrBadRequest("name is required")
    }
    if !strings.Contains(d.Email, "@") {
        return core.ErrBadRequest("invalid email format")
    }
    return nil
}
// Validation runs automatically on extraction — a failing DTO never
// reaches your handler:
r.POST("/users", core.Handle1(core.B[CreateUserDTO](), ctrl.Create))

Core also ships small standalone checks you can use inside Validate(): Required, MinLength, MaxLength, and InRange.

Next steps

  • Controllers & Routing — where these handlers get registered, plus groups and prefixes
  • Guards — run auth checks before your typed handlers execute
  • Getting Started — wire handlers, controllers, and an adapter into a running app