NestJS – Pipes

July 28, 20268 min readUpdated 9/5/2026

A pipe sits between a request value and your handler's parameter. It gets the value, and it returns the value the handler will actually receive — which means it can transform, validate, or both.

transform(value: unknown, metadata: ArgumentMetadata): unknown

That is the whole interface. Return something and the handler gets it; throw and the request never reaches the handler. Everything else in this lesson follows from those two sentences.

The built-in pipes

Nest ships parsing pipes for the common cases — ParseIntPipe, ParseBoolPipe, ParseUUIDPipe, ParseArrayPipe, ParseEnumPipe, DefaultValuePipe — and you attach one to a single parameter as a second argument:

@Delete('me/portfolio/:imageId')
@HttpCode(HttpStatus.NO_CONTENT)
async removePortfolioImage(
  @CurrentUser() user: AuthenticatedUser,
  @Param('imageId', ParseUUIDPipe) imageId: string,
) {
  await this.contractorsService.removePortfolioImage(user, imageId)
}

Two things are happening. The obvious one is a 400 for a malformed id. The one worth understanding is what it prevents further down: without the pipe, /me/portfolio/banana reaches the database as a perfectly valid string comparison that matches nothing — a 404 identical to the one you get for another contractor's image.

That matters because those two 404s mean opposite things. One is "you sent nonsense", the other is "this exists and is not yours". Collapsing them makes a real bug much harder to diagnose, and the pipe keeps them apart for the cost of one identifier.

Pass the class when the defaults are fine and an instance when they are not — new ParseIntPipe({ errorHttpStatusCode: HttpStatus.NOT_ACCEPTABLE }). Nest constructs the class form through the DI container, so a pipe with injected dependencies works either way.

ValidationPipe is a pipe

The thing doing all the work in the previous lesson is not special machinery. It is a pipe that happens to be applied globally: it receives the parsed body, builds an instance of the DTO class, runs class-validator against it, and either returns the instance or throws a BadRequestException carrying the messages.

Knowing that explains a detail people find surprising — the error body's message is an array:

{
  "message": [
    "Enter a valid email address.",
    "Use a password of at least 8 characters."
  ],
  "error": "Bad Request",
  "statusCode": 400
}

One request can break several rules, so the pipe collects them all rather than stopping at the first. Clients have to handle both shapes, because every other exception in the application produces a plain string there.

Writing your own

The reason this application has a custom pipe is a real bug, and it is one every DTO in it had.

RegisterDto says @Length(1, 80) firstName. AuthService then stores dto.firstName.trim(). Send three spaces: class-validator counts three characters, passes it, and the service writes an empty string.

The validation was real and the trim was real. Between them they let through exactly the value both were meant to stop, because they disagreed about what the value was. The same applies to @Length(5, 160) on a project title and to every other length rule in the codebase.

@Injectable()
export class TrimPipe implements PipeTransform {
  transform(value: unknown, metadata: ArgumentMetadata): unknown {
    if (metadata.type !== 'body') return value
    return trim(value, 0)
  }
}

PipeTransform is the interface; @Injectable() is there so Nest can construct it and so it could take dependencies later.

The metadata argument

That metadata.type check is the part worth copying. A globally applied pipe is handed every parameter of every handler — body, query, param and custom — and ArgumentMetadata tells you which:

// metadata.type    'body' | 'query' | 'param' | 'custom'
// metadata.metatype the declared type, e.g. RegisterDto — how ValidationPipe
//                   knows which class to validate against
// metadata.data     the decorator's argument, e.g. 'projectId'

Trimming route params would not be harmless. A route param is part of a URL the client constructed, and quietly turning /projects/%20abc/status into /projects/abc/status hides a client bug rather than surfacing it as the 400 ParseUUIDPipe would otherwise give. Restricting to bodies is a decision, not a shortcut.

Validate or transform?

A pipe returns a value, so the distinction is about what it returns rather than about two different mechanisms.

A transformation pipe returns something different from what it received. ParseIntPipe takes "42" and returns 42; TrimPipe takes an object and returns a trimmed copy. Its job is to make the value correct.

A validation pipe returns exactly what it received, or throws. Its job is to decide whether the value is acceptable.

Most useful pipes are both, and that is fine — ValidationPipe transforms the body into a class instance and validates it. What is worth avoiding is a pipe that transforms in a way the caller would not expect. A pipe that silently substitutes a default for an invalid value hides a client bug forever; better to throw and let the client find out.

Order is the entire point

app.useGlobalPipes(
  new TrimPipe(),
  new ValidationPipe({
    whitelist: true,
    forbidNonWhitelisted: true,
    transform: true,
    transformOptions: {
      enableImplicitConversion: false,
    },
  }),
)

Global pipes run left to right, each receiving what the previous one returned. TrimPipe hands a trimmed body to ValidationPipe, so @Length(1, 80) now sees the empty string it was always meant to reject:

{
  "message": ["firstName must be longer than or equal to 1 characters"],
  "error": "Bad Request",
  "statusCode": 400
}

Reversed, the DTO is validated untrimmed and the pipe trims a value that has already been judged — which is the original bug with an extra step. Whenever two pipes both touch a value, their order is part of the behaviour.

Transforming without wrecking things

The implementation has one detail that is easy to get wrong and hard to notice:

function trim(value: unknown, depth: number): unknown {
  if (typeof value === 'string') return value.trim()
  if (depth >= MAX_DEPTH || value === null || typeof value !== 'object') return value

  if (Array.isArray(value)) return value.map((item) => trim(item, depth + 1))

  const prototype: unknown = Object.getPrototypeOf(value)
  if (prototype !== Object.prototype && prototype !== null) return value

  const result: Record<string, unknown> = {}
  for (const [key, item] of Object.entries(value)) {
    result[key] = trim(item, depth + 1)
  }
  return result
}

The prototype check is the load-bearing line. A Date, a Buffer and a class instance are all typeof 'object', and rebuilding one of them field by field turns a Date into a plain object and a Buffer into { 0: 12, 1: 80, ... } — quietly, and only for requests that happen to carry one. An earlier draft did exactly that. The depth cap is the other half: a pipe runs on attacker-supplied input, so unbounded recursion over a hostile body is a denial of service.

Both are cheap to test, because a pipe is a class with one method and no dependencies:

it('turns a whitespace-only field into the empty string, so @Length can reject it', () => {
  expect(pipe.transform({ firstName: '   ' }, body)).toEqual({ firstName: '' })
})

Where pipes sit in the request

Pipes run late: after middleware, after guards, after an interceptor's inbound half, and immediately before the handler. That position has two practical consequences worth knowing before you debug anything.

First, a guard never sees validated input. By the time ValidationPipe runs, JwtAuthGuard has already decided the request may proceed. A guard that wants to inspect the body gets the raw parsed object, unvalidated and untransformed — which is one good reason for guards to base their decisions on the token rather than on the payload.

Second, an interceptor sees the pipe's failure. A validation error is thrown inside the handler's execution path, so an interceptor wrapping it observes the error like any other. That is why this application's logging interceptor produces a line for a 400 that never reached a controller method:

WARN [HTTP] POST /api/v1/auth/register 400 2ms AuthController.register req=ba10c2bc user=anon

The handler named there never ran. The interceptor knows which handler would have run, because Nest resolved the route before any of this started. Lesson 13 puts the whole order together.

The four places a pipe can go

Narrowest to widest: on one parameter, which is where the parsing pipes belong; on a method with @UsePipes(); on a controller class, applying to all its routes; or globally.

Global has two forms and the difference matters. app.useGlobalPipes() in main.ts constructs the pipe outside the DI container, so it cannot inject anything — and, less obviously, a test that builds the application from AppModule does not get it. That is why this project's end-to-end suite repeats the pipe configuration by hand, with a comment saying so: without it, every DTO decorator is inert and every validation assertion in the suite passes vacuously.

The alternative is registering it as a provider under the APP_PIPE token, which puts it in the container and makes it part of the module — so tests importing that module pick it up. This application binds its exception filter and interceptor that way and its pipes the other, which is a real inconsistency with a real reason: useGlobalPipes guarantees the left-to-right ordering that TrimPipe depends on, and the relative order of APP_PIPE providers is not something to rely on.

One more built-in worth remembering: DefaultValuePipe, which supplies a value when a parameter is absent. It is most useful in front of a parsing pipe, as @Query('page', new DefaultValuePipe(1), ParseIntPipe) — the default applies first, so the parser never sees undefined. That ordering is the same left-to-right rule as everywhere else.

When not to write one

The temptation with a custom pipe is to put business logic in it, because it runs before the handler and has access to the value. Two rules keep that in check.

A pipe should not need the database. "Is this a UUID" is a pipe. "Does this project exist" is not — it is a query, it belongs in the service that is about to run one anyway, and doing it in a pipe means two round trips for one fact.

A pipe should not need to know who is asking. A pipe sees a value and its metadata, not the caller. A rule like "a contractor may only quote in their own trades" involves both the request and the actor, which makes it authorization — and that is either a guard or, when it depends on stored data, a service check.

Applied consistently, those two leave pipes doing what they are good at: making sure the value handed to your handler is the shape and type it claims to be, so nothing further in has to re-check it.

Next: guards — the stage that runs before pipes and answers a different question entirely.