Pipes: Validate and Transform Request Values in Go
Pipes transform or validate a value after it is extracted from the request and before it reaches your handler. They are NestGo’s type-safe, generics-based take on NestJS validation pipes — the Golang way to normalize and check path params, query strings, and other extracted values.
If guards decide whether a request proceeds and interceptors wrap how the handler runs, pipes shape what the handler receives.
The Pipe[T] interface
A pipe is generic over the value type it handles:
// Pipe transforms or validates an extracted value.
type Pipe[T any] interface {
Transform(value T, c Context) (T, error)
}
Transform receives the extracted value and the request context, and returns either a (possibly modified) value or an error. Returning an error aborts the request before the handler runs — return a *core.HTTPError (e.g. core.ErrBadRequest) to control the status code.
PipeFunc: write a pipe as a function
PipeFunc[T] adapts a plain function to the Pipe[T] interface:
// PipeFunc is a functional adapter for Pipe.
type PipeFunc[T any] func(value T, c Context) (T, error)
lowercase := core.PipeFunc[string](func(val string, c core.Context) (string, error) {
return strings.ToLower(val), nil
})
WithPipes: attach pipes to an extractor
In NestGo, values enter handlers through extractors (core.P, core.PInt, core.Q, core.B[T](), …) used with the Handle builders. WithPipes wraps an extractor so each pipe runs in order after extraction:
func WithPipes[T any](extractor Extractor[T], pipes ...Pipe[T]) Extractor[T]
// Extract path param "id" as int, then validate it's between 1 and 999999:
idExtractor := core.WithPipes(
core.PInt("id"),
core.IntRangePipe("id", 1, 999999),
)
r.GET("/:id", core.Handle1(idExtractor, ctrl.GetByID))
The flow for a request to GET /users/42:
core.PInt("id")extracts"42"and parses it tointIntRangePipe("id", 1, 999999)validates the boundsctrl.GetByID(42)runs with a clean, validated value
If extraction or any pipe fails, the handler never runs and the error goes to the error-handling chain (exception filters or the global ErrorHandler).
Because WithPipes returns a regular Extractor[T], it composes anywhere an extractor is accepted — including multi-argument builders:
r.POST("/:id/comments", core.Handle2(
core.WithPipes(core.PInt("id"), core.IntRangePipe("id", 1, 999999)),
core.B[CreateCommentDTO](),
ctrl.AddComment,
))
Built-in pipes in NestGo
| Pipe | Type | What it does |
|---|---|---|
core.TrimPipe | string | Trims leading/trailing whitespace |
core.NonEmptyPipe(paramName) | string | Rejects empty (after trim) with 400: '<paramName>' must not be empty |
core.IntRangePipe(paramName, min, max) | int | Rejects out-of-range with 400: '<paramName>' must be between <min> and <max> |
paramName is only used in the error message — pass the name your API users will recognize.
// Trim a search query, then require it to be non-empty:
searchExtractor := core.WithPipes(
core.Q("search"),
core.TrimPipe,
core.NonEmptyPipe("search"),
)
r.GET("/", core.Handle1(searchExtractor, ctrl.Search))
Pipes run in order, so TrimPipe before NonEmptyPipe means " " is rejected.
How to write a custom validation pipe in Go
Any PipeFunc[T] (or type implementing Pipe[T]) works. A reusable string-length pipe:
func MaxLenPipe(paramName string, max int) core.PipeFunc[string] {
return func(val string, c core.Context) (string, error) {
if len(val) > max {
return "", core.ErrBadRequest(
fmt.Sprintf("'%s' must be at most %d characters", paramName, max),
)
}
return val, nil
}
}
A transforming pipe that normalizes a slug:
var SlugPipe = core.PipeFunc[string](func(val string, c core.Context) (string, error) {
return strings.ToLower(strings.ReplaceAll(strings.TrimSpace(val), " ", "-")), nil
})
And since pipes receive the Context, they can validate against request state — for example, a pipe that caps limit differently for admins:
func LimitPipe() core.PipeFunc[int] {
return func(val int, c core.Context) (int, error) {
max := 100
if role, _ := c.Get("role").(string); role == "admin" {
max = 1000
}
if val > max {
return max, nil // clamp instead of reject
}
return val, nil
}
}
r.GET("/", core.Handle1(
core.WithPipes(core.QInt("limit", 20), LimitPipe()),
ctrl.List,
))
Pipes vs DTO validation
Pipes validate individual extracted values. For whole request bodies, use DTO validation instead: core.B[T]() automatically calls a DTO’s Validate() error method, and Config.ValidateFunc (or core.SetValidateFunc) plugs in struct-tag validation app-wide. The two compose — validate the body as a DTO, and pipe the path/query params around it.
Note: Config.GlobalPipes is a []core.MiddlewareFunc applied to all routes — it is route-level middleware slotted into the pipes position of the execution chain (Filters → Guards → Pipes → Interceptors → Handler), not a typed Pipe[T]. Typed pipes always attach to a specific extractor via WithPipes.
Source: github.com/ashrafAli23/nestgo (core/pipe.go, core/handle.go).
Next steps
- Guards — authentication and route authorization before the handler
- Exception Filters — customize how pipe errors are formatted
- Interceptors — before/after logic around handlers