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:

  1. core.PInt("id") extracts "42" and parses it to int
  2. IntRangePipe("id", 1, 999999) validates the bounds
  3. ctrl.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