Skip to content

Custom Pipes and Transformation

Aman Kumar Singh8 min read
Part 8 of 40From the NestJS Production Guide series
Custom Pipes and Transformation — article by Aman Kumar Singh

In Interceptors Explained, I covered how to wrap a handler on both sides to time requests, reshape responses, and cache results. Pipes sit right before that handler, and they do something narrower and, in my experience, more consequential day to day: they decide what shape the data arrives in when your controller method actually runs.

This is part of the NestJS Production Guide series. The DTOs and Validation Pipes article covered class-validator and the global ValidationPipe in depth, so I won't repeat that ground here. What I want to cover is the other half of what a PipeTransform does: transformation. Turning a route param string into a number, a query string into a typed enum, a raw upload into a normalized value, before any of that reaches your handler's parameters.

Validation and transformation get bundled together because ValidationPipe does both, but they're different jobs, and understanding where transformation earns its own pipe, separate from a DTO, is what this article is about.

Why transformation deserves its own pipe

Every value that comes off the wire, whether it's a route param, a query string, or a header, arrives as a string or, at best, undefined. HTTP doesn't know about your TypeScript types. A route like GET /orders/:id/items/:itemIndex gives you two strings, even though itemIndex is conceptually a number the moment it reaches your handler.

The naive fix is to coerce inline:

@Get(':id/items/:itemIndex')
getItem(@Param('id') id: string, @Param('itemIndex') itemIndex: string) {
  const index = parseInt(itemIndex, 10);
  if (Number.isNaN(index)) {
    throw new BadRequestException('itemIndex must be a number');
  }
  return this.orders.getItem(id, index);
}

That works for one handler. It stops working the moment you have the same pattern in ten handlers across three modules, each with a slightly different edge case someone remembered to handle or didn't. A pipe centralizes that coercion so the handler signature can just declare itemIndex: number and trust it.

NestJS ships several of these already: ParseIntPipe, ParseBoolPipe, ParseUUIDPipe, ParseArrayPipe, ParseEnumPipe, and DefaultValuePipe. Reach for these first. A custom pipe is for the transformation logic that's specific to your domain, not for logic the framework has already solved.

// src/modules/orders/orders.controller.ts
import { Controller, Get, Param, ParseIntPipe, ParseUUIDPipe } from '@nestjs/common';
import { OrdersService } from './orders.service';

@Controller('orders')
export class OrdersController {
  constructor(private readonly orders: OrdersService) {}

  @Get(':id/items/:itemIndex')
  getItem(
    @Param('id', ParseUUIDPipe) id: string,
    @Param('itemIndex', ParseIntPipe) itemIndex: number,
  ) {
    return this.orders.getItem(id, itemIndex);
  }
}

Both pipes throw a BadRequestException automatically if coercion fails, so getItem never runs with a malformed id or a non-numeric itemIndex. That's the whole value proposition: the handler's type signature becomes trustworthy instead of aspirational.

Writing a custom PipeTransform

A pipe is any class implementing PipeTransform, which requires one method: transform(value, metadata). The value is whatever came in for that argument; metadata tells you what kind of argument it was (body, query, param, or custom) and, when available, the declared TypeScript type.

Here's a pipe I'd actually reach for: normalizing free-text input before it hits validation. Trimming whitespace and collapsing it isn't something class-validator decorators do for you, and doing it inside every service method that touches user-entered text is exactly the kind of scattered logic a pipe exists to prevent.

// pipes/trim.pipe.ts
import { Injectable, PipeTransform } from '@nestjs/common';

@Injectable()
export class TrimPipe implements PipeTransform {
  transform(value: unknown) {
    if (typeof value === 'string') {
      return value.trim().replace(/\s+/g, ' ');
    }

    if (value && typeof value === 'object') {
      return Object.fromEntries(
        Object.entries(value as Record<string, unknown>).map(([key, val]) => [
          key,
          typeof val === 'string' ? val.trim().replace(/\s+/g, ' ') : val,
        ]),
      );
    }

    return value;
  }
}

Applied at the body level, this runs before ValidationPipe if you order it that way, so a field like " Acme Corp " becomes "Acme Corp" before any @IsString() or @MinLength() check ever sees it. That ordering matters: transformation should generally happen before validation, because validation rules are usually written assuming clean input, and running them against raw, untrimmed values invites subtle bugs like a @MinLength(3) check passing on a string that's mostly whitespace.

// src/modules/customers/customers.controller.ts
import { Body, Controller, Post, UsePipes } from '@nestjs/common';
import { TrimPipe } from '../../pipes/trim.pipe';
import { CreateCustomerDto } from './dto/create-customer.dto';
import { CustomersService } from './customers.service';

@Controller('customers')
export class CustomersController {
  constructor(private readonly customers: CustomersService) {}

  @Post()
  @UsePipes(TrimPipe)
  create(@Body() dto: CreateCustomerDto) {
    return this.customers.create(dto);
  }
}

Pipe order here matters as much as it does for middleware. Multiple pipes on the same parameter run in the order you list them in @UsePipes(), and parameter-scoped pipes run before a globally registered ValidationPipe. If you need TrimPipe to run before validation, put it at the parameter or controller level rather than assuming the global pipe will somehow defer to it.

Async pipes and dependency injection

Pipes can be async, and they can inject providers through the constructor like any other Nest class, since @Injectable() pipes participate in the DI container the same way services do. This is useful for transformation that needs a lookup, not just string manipulation.

A common one: converting a slug in the URL into the underlying entity, so the handler receives a resolved object instead of a string it has to look up itself.

// pipes/resolve-workspace.pipe.ts
import { Injectable, PipeTransform, NotFoundException } from '@nestjs/common';
import { WorkspacesService } from '../modules/workspaces/workspaces.service';
import { Workspace } from '../modules/workspaces/workspace.entity';

@Injectable()
export class ResolveWorkspacePipe implements PipeTransform<string, Promise<Workspace>> {
  constructor(private readonly workspaces: WorkspacesService) {}

  async transform(slug: string): Promise<Workspace> {
    const workspace = await this.workspaces.findBySlug(slug);
    if (!workspace) {
      throw new NotFoundException(`Workspace "${slug}" not found`);
    }
    return workspace;
  }
}
@Get(':workspaceSlug/projects')
listProjects(@Param('workspaceSlug', ResolveWorkspacePipe) workspace: Workspace) {
  return this.projects.listForWorkspace(workspace.id);
}

This pattern reads nicely, and it's tempting to lean on it for every entity lookup in every route. I'd be cautious about that. Every pipe like this is a database round trip added before the handler even starts, and it's a round trip the handler or a guard might duplicate for its own authorization check anyway (an authorization check like whether this user can see this workspace, which is a different question from whether the workspace exists at all). Use resolver pipes for the common case of turning an identifier into an entity when nothing else in the pipeline needs that same lookup. If a guard already fetches the workspace to check permissions, don't fetch it again in a pipe; pass it forward instead.

Where transformation logic belongs: pipe, DTO, or service

This is the question I see teams get wrong most often. The mechanics aren't the hard part. What's hard is that three reasonable-looking places exist for the same piece of logic, with no obvious signal for which one is correct.

My rule of thumb: pipes own type coercion and identifier resolution at the argument boundary, class-transformer decorators on the DTO own shape normalization within a structured payload, and services own anything that depends on business state.

A currency amount arriving as "49.99" and needing to become an integer number of cents is transformation, and it belongs on the DTO with @Transform(), because it's part of the shape of that field within a larger payload class validation already owns:

import { Transform } from 'class-transformer';
import { IsInt, Min } from 'class-validator';

export class CreateInvoiceDto {
  @Transform(({ value }) => Math.round(parseFloat(value) * 100))
  @IsInt()
  @Min(0)
  amountCents: number;
}

A route param that needs to become an entity, like the workspace slug example above, is a standalone argument rather than a field inside a structured body, so a pipe is the natural fit. And "is this discount code still valid for this customer's plan" is neither: it depends on business state that can change between requests, so it belongs in the service layer, not in a pipe or a DTO decorator that runs once, upstream, disconnected from the transaction that will use the result.

Production pitfalls worth naming directly

Treating a pipe as a place for real validation of business rules. A pipe runs once, early, and its result isn't re-checked downstream. If the "customer exists" check in a pipe passes but the customer is deleted by a concurrent request, your service still needs its own guard, typically a foreign key constraint or a check inside the same transaction. The pipe is a fast-fail convenience for obviously bad input, not the source of truth for an invariant your database already protects.

Pipes with side effects. A transform() method that writes to the database, publishes an event, or mutates shared state outside of the value it returns is a debugging problem waiting to happen, since pipes are easy to compose and easy to forget are running. Keep transform() a pure function of its input, never something that changes state as a side effect of formatting a value.

Forgetting that pipe order is explicit, not inferred. When you stack @UsePipes(TrimPipe, ValidationPipe), Nest runs them left to right. Getting that order backwards means your validation rules see raw input and your normalized value never gets checked against anything.

Async pipes that silently swallow errors. If a custom pipe awaits a lookup and that lookup throws a raw database error instead of a Nest HTTP exception, the client sees a generic 500 with no indication of what went wrong. Translate unexpected failures into a clear BadRequestException or NotFoundException rather than letting an ORM error leak through.

Key takeaways

  • Pipes transform and coerce individual arguments (params, query values, single fields); DTOs with `class-transformer` normalize shape within a structured payload; services own logic that depends on business state.
  • Use the built-in pipes (`ParseIntPipe`, `ParseUUIDPipe`, `ParseEnumPipe`, `DefaultValuePipe`) before writing a custom one; a custom pipe should express domain-specific logic the framework doesn't already cover.
  • Pipe order is explicit and matters: normalization pipes should generally run before validation, and `@UsePipes()` runs its arguments left to right.
  • Async pipes can inject providers and resolve identifiers into entities, but every lookup inside a pipe is a database round trip on the hot path; don't duplicate a fetch a guard or the handler already needs to make.
  • Keep `transform()` free of side effects. A pipe should be a pure mapping from input to output, not a place to write data or trigger events.
  • Real business invariants (uniqueness, ownership, state transitions) belong in the service or database layer, not in a pipe, even if a pipe can give a faster, friendlier error for the obvious cases.

Frequently asked questions

What's the difference between a pipe and a DTO with class-transformer decorators?

A pipe operates on a single argument as it enters the pipeline, a route param, a query value, or occasionally a whole body before validation. `class-transformer` decorators like `@Transform()` operate on individual fields within a DTO's shape, alongside `class-validator` rules on the same class. Use a pipe when the value in question isn't part of a larger structured payload; use a DTO transform when it is.

Can a pipe throw a custom exception instead of the default BadRequestException?

Yes. Throw any Nest HTTP exception directly from `transform()`, such as `NotFoundException` or `UnprocessableEntityException`, and it propagates the same way an exception thrown from a handler would. This is often clearer than letting a generic parse failure surface as a 400 with no context about what specifically was wrong.

Do pipes run before or after guards in the request lifecycle?

After. Guards decide whether a request is authorized to proceed at all; pipes then validate and transform the arguments of a request that's already been allowed through. If you find yourself doing authorization logic inside a pipe, that logic almost certainly belongs in a guard instead.

Should every route parameter go through a pipe, even simple ones?

Not necessarily. A string route param that stays a string all the way through your handler and service doesn't need a pipe. Reach for one when there's real coercion or lookup work to centralize, an integer conversion, a UUID format check, an entity resolution. Wrapping every parameter in a pipe purely out of habit adds indirection without solving a real problem.

Is it worth writing a pipe that hits Redis or an external service during transform?

Only if that lookup is unavoidable at the argument level and cheap enough not to matter on every request, like resolving a slug to an ID through a cache. If the lookup is expensive, rate-limited, or duplicates work a guard or the handler already performs, it's usually a sign the logic belongs later in the pipeline, not earlier in a pipe that runs regardless of whether the rest of the request would have needed it.

Related articles

Pagination and Filtering — Aman Kumar Singh
DTOs and Validation Pipes — Aman Kumar Singh
API Versioning — Aman Kumar Singh
Aman Kumar Singh

About the author

I'm Aman Kumar Singh, a software engineer in Noida, India building scalable full-stack products with React, Next.js, Node.js, NestJS, PostgreSQL, Redis, and AWS. I write about backend engineering, distributed systems, and system design.