open atlas
↑ Back to track
NestJS, zero to senior NEST · 02 · 03

Custom pipes and the transformation stage

A pipe runs per-argument right before the handler and does two jobs: coerce and validate. The transform stage is where "42" becomes 42 and a plain body becomes a real DTO — and where enableImplicitConversion silently turns ?active=false into true.

NEST Senior ◷ 17 min
Level
FoundationsJuniorMiddleSenior

The support ticket said “the deactivated-users report shows active users.” The query was GET /users?active=false. The handler read query.active — a real boolean, the DTO promised — and filtered where: { isActive: query.active }. Reading the code, everything was right. The filter said false, the report should show deactivated accounts. It showed the active ones. Someone had turned on enableImplicitConversion: true six months earlier to “stop writing @Type everywhere,” and that one flag made class-transformer coerce the string "false" to a boolean the JavaScript way: Boolean("false") — a non-empty string — is true. The DTO field was typed boolean, the validator passed, the value was a lie. This lesson is the transformation stage: where the framework quietly changes your values before the handler ever sees them, and where that one convenience flag flips a filter inside out.

What a pipe actually is

A pipe is a class implementing PipeTransform<T, R>: one method, transform(value: T, metadata: ArgumentMetadata): R. Nest runs it on a single handler argument, right before the handler executes — after guards and the interceptor pre-phase, but it is the last thing to touch the argument on the way in. A pipe has exactly two jobs: transformation (return a coerced value, e.g. "42"42) and validation (throw to reject the request, which Nest turns into a 400). It either returns the (possibly transformed) value or throws; there is no third outcome.

import { PipeTransform, Injectable, ArgumentMetadata, BadRequestException } from '@nestjs/common';

// A hand-written equivalent of ParseIntPipe: this is all a built-in pipe is.
@Injectable()
export class ParseIntPipeManual implements PipeTransform<string, number> {
  transform(value: string, _metadata: ArgumentMetadata): number {
    const n = Number(value);
    if (!Number.isInteger(n)) {
      throw new BadRequestException(`Validation failed: "${value}" is not an integer`);
    }
    return n; // the handler now receives a number, not a string
  }
}

The second argument, metadata: ArgumentMetadata, is what makes pipes more than dumb converters. It carries { type, metatype, data }:

  • type — where this argument came from: 'body', 'query', 'param', or 'custom'.
  • metatype — the argument’s declared class (CreateUserDto, Number, String…). This is how ValidationPipe knows which DTO to validate against — it reads the metatype off the parameter’s type annotation via reflect-metadata.
  • data — the string passed to the decorator, e.g. @Param('id')data === 'id'.

The built-ins — ParseIntPipe, ParseBoolPipe, ParseUUIDPipe, ParseArrayPipe, DefaultValuePipe — are nothing more than PipeTransform implementations that parse and throw BadRequestException on failure. You bind them right at the parameter:

@Get(':id')
findOne(@Param('id', ParseIntPipe) id: number) {
  // id is a number here, or the request already 400'd before we got called
  return this.users.findOne(id);
}

The transform stage: transform:true makes a real DTO

By default, ValidationPipe validates the incoming object and leaves it as a plain object. The parameter is typed CreateUserDto, but at runtime it’s just {} with string fields — body instanceof CreateUserDto is false, any getters or methods on the DTO don’t exist, and a field declared Date is still a string. The type annotation is a compile-time fiction the runtime never honored.

Setting { transform: true } changes that. The pipe runs class-transformer’s plainToInstance(metatype, value) and hands the handler an actual instance of the DTO class:

import { ValidationPipe } from '@nestjs/common';

app.useGlobalPipes(
  new ValidationPipe({
    transform: true, // plainToInstance(CreateUserDto, body) -> a real DTO instance
    whitelist: true,
  }),
);
export class CreateOrderDto {
  @Type(() => Number) // tells class-transformer to coerce this field to a number
  quantity: number;

  @Type(() => ShippingDto) // nested conversion: the child is built as a ShippingDto too
  shipping: ShippingDto;

  get total(): number {
    return this.quantity * UNIT_PRICE; // only callable because it's a REAL instance
  }
}

Two things only work under transform: true: instanceof is now true (so the value is a genuine CreateOrderDto, and its getters/methods like total are callable), and the @Type() decorators run — which is what coerces quantity to a number and rebuilds the nested shipping as a real ShippingDto. Without transform: true, @Type() is inert and your nested objects stay plain.

The enableImplicitConversion footgun

Writing @Type(() => Number) on every numeric field is tedious, so class-transformer offers a shortcut: transformOptions: { enableImplicitConversion: true }. With it on, class-transformer coerces primitives based on the declared TypeScript type of each field — no @Type() needed. A field typed number gets Number(value); a field typed boolean gets the value run through boolean coercion; and so on, app-wide, from one flag.

new ValidationPipe({
  transform: true,
  transformOptions: { enableImplicitConversion: true }, // one flag, changes coercion everywhere
});

export class UserFilterDto {
  active: boolean; // no @Type needed now... but watch what "false" becomes
  page: number;
}

Here is the trap, in three concrete cases:

  • ?active=false on a boolean field → class-transformer coerces the string "false", and a non-empty string is truthy, so you get true. Your “show deactivated users” filter now shows active ones. This is the production incident verbatim.
  • ?page= (empty string) on a number field → Number("") is 0, and ?page= with junk like 12abcNumber("12abc") is NaN, passed silently into your query.
  • ?ids= on an array field → coercion guesses, and “guess” is exactly what you don’t want at a validation boundary.
Why this works

Why does ?active=false become true? Implicit conversion doesn’t parse the value of the string — it coerces by the field’s declared type using JavaScript’s own rules. For a boolean target on a non-empty string, that’s truthiness, and "false" is a non-empty string, so Boolean("false") is true. The same flag that helpfully turns "42" into 42 (because Number("42") happens to be right) turns "false" into true (because boolean coercion has nothing to do with the word “false”). The convenience and the bug are the same mechanism: coerce-by-type, no value-aware parsing. That’s why the senior move is @Transform(({ value }) => value === 'true') or @IsBoolean() after the coercion — something that actually looks at the value.

The senior stance: prefer explicit, per-field conversion — @Type(() => Number) for numerics, @Transform(...) for the value-aware cases like booleans — over a blanket enableImplicitConversion. If you do turn implicit conversion on, pair every coerced field with a validator (@IsBoolean(), @IsInt()) so the coercion result is checked, not trusted. The flag trades a few decorators for an app-wide silent-coercion surface; that’s a bad trade at a validation boundary.

A custom pipe: the Zod alternative

A custom pipe lets you replace the whole class-validator machinery. Here, a Zod schema is the single source of truth — it infers the TypeScript type and validates and transforms, in one safeParse:

import { PipeTransform, ArgumentMetadata, BadRequestException } from '@nestjs/common';
import { ZodSchema } from 'zod';

export class ZodValidationPipe implements PipeTransform {
  constructor(private readonly schema: ZodSchema) {}

  transform(value: unknown, _metadata: ArgumentMetadata) {
    const result = this.schema.safeParse(value);
    if (!result.success) {
      throw new BadRequestException(result.error.format()); // reject -> 400
    }
    return result.data; // parsed AND coerced: one source of truth, no @Type, no reflect-metadata
  }
}

// usage: @Body(new ZodValidationPipe(createUserSchema)) — the schema infers the body type

The tradeoff is real and goes both ways. Class-validator is DI-native, idiomatic Nest, and its decorators double as the source for @nestjs/swagger API docs — one decorator set drives validation and the OpenAPI schema. Zod gives you inferred types (no decorator/reflect-metadata duplication) and far better composability (.refine, .transform, unions), but you wire the pipe yourself and lose the decorator-driven Swagger integration. Pick by constraint: Swagger-first and DI-heavy → class-validator; type-inference-first and composable → a Zod pipe.

Pick the best fit

You must validate AND coerce a complex nested request body (numbers, booleans, a nested address object) at a single endpoint. Which approach holds up?

Quiz

Where in the request lifecycle does a pipe run, and what does its ArgumentMetadata carry?

Quiz

With ValidationPipe({ transform: true, transformOptions: { enableImplicitConversion: true } }) and a DTO field `active: boolean`, what does the request `?active=false` produce?

Recall before you leave
  1. 01
    Explain what a pipe is, where it runs, its two jobs, and what ArgumentMetadata gives you.
  2. 02
    Contrast transform:true with enableImplicitConversion, and say why ?active=false becomes true — then give the senior fix.
Recap

A pipe is a class implementing PipeTransform<T, R> — one transform(value, metadata) method that Nest runs on a single handler argument right before the handler (after guards), with two jobs: coerce the value or throw to reject (a 400). The built-in Parse* pipes are exactly that. ArgumentMetadata carries type (‘body’|‘query’|‘param’|‘custom’), metatype (the argument’s declared class, which is how ValidationPipe picks the DTO), and data (the decorator string). By default ValidationPipe leaves the body a plain object whose declared class is a fiction — instanceof is false and Date/number fields stay strings; setting transform: true runs plainToInstance so the handler gets a real DTO instance with working getters and @Type() coercion, including nested objects. enableImplicitConversion is the dangerous shortcut: one flag makes class-transformer coerce primitives by declared type using JS rules with no @Type, so ?active=false on a boolean becomes Boolean(‘false’) === true and inverts a filter, ?page= becomes 0/NaN, all silently and app-wide. The senior stance is explicit per-field conversion (@Type for numbers, @Transform for value-aware booleans), or validate the coercion result with @IsBoolean()/@IsInt() if implicit is on. A custom pipe like ZodValidationPipe is the alternative to the whole class-validator stack: a Zod schema infers the TS type and parses+coerces in one safeParse, trading away decorator-driven Swagger and DI-native repos for inferred types and composability — pick by constraint. Now when you see a boolean query param that behaves the opposite of what you expect, your first question should be: is enableImplicitConversion on, and is there a @Transform catching the literal word?

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 5 done

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources2
expand
  1. 01
  2. 02

Trademarks belong to their respective owners. Editorial reference only.