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:
- Global validator — if a validate function was registered via
core.SetValidateFunc(orConfig.ValidateFunc), it runs first. This is how nestgo-validator struct-tag validation plugs in. - Validatable — if the DTO implements the
Validatableinterface, itsValidate()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