Validación y transformación de datos

Por: Artiko
nestjsbuenas-practicasvalidacionpipeszodclass-validatorstandard-schema

Validación y transformación de datos

Todo dato que entra a tu API desde afuera es hostil hasta que se demuestre lo contrario. El body de un POST, los query params, los path params, los headers: nada de eso lo controlás vos. Un cliente puede mandar un age que es "veinte", un email sin arroba, un role: "admin" que jamás debió poder setear, o un objeto con 300 campos basura para intentar romperte. La validación es la frontera donde decidís qué entra al dominio y en qué forma.

NestJS resuelve esto con una capa dedicada: los pipes. Un pipe se ejecuta antes de que el handler reciba el argumento, y tiene dos trabajos que conviene no mezclar:

Este capítulo cubre cómo usar pipes bien, por qué el ValidationPipe global con whitelist es una decisión de seguridad y no de comodidad, la tensión entre el enfoque clásico (class-validator) y el schema-first (Zod/Valibot), y hacia dónde va Nest 12 con Standard Schema nativo.

Los pipes como capa

Un pipe implementa la interfaz PipeTransform. Su método transform recibe el valor y metadatos sobre de dónde viene:

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

export interface PipeTransform<T = any, R = any> {
  transform(value: T, metadata: ArgumentMetadata): R;
}

export interface ArgumentMetadata {
  type: 'body' | 'query' | 'param' | 'custom';
  metatype?: Type<unknown>; // el tipo declarado en el handler (el DTO)
  data?: string;            // el nombre del param, p.ej. 'id' en @Param('id')
}

Lo que devuelve transform es lo que recibe el handler. Si el pipe lanza, el request nunca llega al controller. Esa es la clave: la lógica de validación vive fuera del handler, así el controller asume que sus argumentos ya son válidos y tipados.

Built-in pipes

Nest trae pipes listos para los casos triviales de parseo y validación de escalares. No escribas a mano lo que ya existe:

import {
  ParseIntPipe, ParseFloatPipe, ParseBoolPipe, ParseArrayPipe,
  ParseUUIDPipe, ParseEnumPipe, DefaultValuePipe, ParseFilePipe,
} from '@nestjs/common';

@Get(':id')
findOne(@Param('id', ParseUUIDPipe) id: string) {
  // si 'id' no es un UUID válido → 400 antes de entrar acá
  return this.service.findOne(id);
}

@Get()
list(
  @Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number,
  @Query('active', new DefaultValuePipe(true), ParseBoolPipe) active: boolean,
) {
  return this.service.list(page, active);
}

Podés configurarlos cuando necesitás afinar el error o el status. Por ejemplo, devolver 404 en vez de 400 cuando un id mal formado debería tratarse como “no encontrado”:

@Param('id', new ParseUUIDPipe({ version: '4', errorHttpStatusCode: HttpStatus.NOT_FOUND }))

ParseEnumPipe valida contra un enum; ParseArrayPipe parsea listas separadas por coma con validación de items; ParseFilePipe valida uploads (tamaño, mime type). Para todo lo demás —objetos con estructura— usás DTOs y el ValidationPipe.

Niveles de binding

Un pipe se puede atar a tres niveles. La regla práctica: parseo puntual a nivel de parámetro, validación de DTOs a nivel global.

// 1. Parámetro: preciso, para un argumento puntual
findOne(@Param('id', ParseIntPipe) id: number) {}

// 2. Handler / controller: con @UsePipes (raramente necesario si tenés el global)
@UsePipes(new ValidationPipe({ groups: ['create'] }))
create(@Body() dto: CreateUserDto) {}

// 3. Global: aplica a toda la app
app.useGlobalPipes(new ValidationPipe({ whitelist: true }));

DTOs y el ValidationPipe global

Un DTO (Data Transfer Object) describe la forma de un payload de entrada. En el enfoque clásico es una clase con decoradores de class-validator:

import { IsEmail, IsString, MinLength, IsInt, Min, IsOptional } from 'class-validator';

export class CreateUserDto {
  @IsEmail()
  email: string;

  @IsString()
  @MinLength(8)
  password: string;

  @IsOptional()
  @IsInt()
  @Min(18)
  age?: number;
}

El ValidationPipe toma el metatype del argumento (acá CreateUserDto), instancia la clase con class-transformer, corre class-validator sobre ella y, si hay errores, lanza BadRequestException con 400. Se registra una vez, global:

// main.ts
app.useGlobalPipes(
  new ValidationPipe({
    whitelist: true,
    forbidNonWhitelisted: true,
    transform: true,
    transformOptions: { enableImplicitConversion: true },
  }),
);

O como provider (permite inyección de dependencias en el pipe):

// app.module.ts
import { APP_PIPE } from '@nestjs/core';

providers: [
  { provide: APP_PIPE, useClass: ValidationPipe },
],

whitelist + forbidNonWhitelisted: esto es seguridad, no cosmética

Estas dos opciones son la parte más importante del capítulo. Sin ellas, tu API tiene un agujero de mass assignment.

¿Por qué importa? Imaginá este DTO sin whitelist:

export class UpdateUserDto {
  @IsString() @IsOptional()
  displayName?: string;
}

Y este servicio que confía en el DTO:

// ANTIPATRÓN: pasar el DTO entero al ORM sin whitelist
async update(id: string, dto: UpdateUserDto) {
  return this.repo.update(id, dto); // ← peligro
}

Sin whitelist, un atacante manda:

{ "displayName": "Ana", "role": "admin", "isEmailVerified": true, "credits": 999999 }

class-validator valida displayName y deja pasar el resto tal cual, porque no los conoce. Si el service hace repo.update(id, dto), acabás de dejar que cualquiera se auto-promueva a admin. Eso es mass assignment (OWASP API3: Broken Object Property Level Authorization).

Con whitelist: true, role, isEmailVerified y credits se eliminan antes de llegar al service: el objeto queda { displayName: "Ana" }. Con forbidNonWhitelisted: true además, el request se rechaza con un 400 explícito, lo cual es preferible en desarrollo porque expone clientes mal comportados en vez de tragárselos en silencio.

flowchart LR
  A["Payload cliente<br/>{ displayName, role,<br/>credits, ... }"] --> B{ValidationPipe}
  B -->|whitelist| C["Descarta props<br/>sin decorador"]
  B -->|forbidNonWhitelisted| D["400 si hay<br/>props extra"]
  C --> E["DTO limpio<br/>{ displayName }"]
  E --> F[Service / ORM]
  D -.corta.-> X[["Request rechazado"]]
  style D fill:#7f1d1d,color:#fff
  style E fill:#14532d,color:#fff

La recomendación firme: activá whitelist y forbidNonWhitelisted globalmente desde el día uno. El costo es cero y cierra una clase entera de vulnerabilidades. Este tema se retoma en el capítulo 10 (Seguridad), donde el mass assignment aparece en el OWASP API Top 10.

transform y transformOptions

Sin conversión implícita, la alternativa explícita es @Type de class-transformer:

import { Type } from 'class-transformer';

export class PaginationDto {
  @Type(() => Number)
  @IsInt() @Min(1)
  page = 1;
}

Otras opciones útiles del ValidationPipe:

class-validator + class-transformer: el enfoque clásico

Es el default histórico de Nest y está profundamente integrado (Swagger, mapped types como PartialType/PickType, groups). Sus ventajas:

Sus fricciones son reales y conviene nombrarlas:

  1. Doble fuente de verdad de tipos. La clase declara tipos TypeScript y decoradores de runtime. El tipo age: number y el @IsInt() pueden desincronizarse: TypeScript cree una cosa, el validador chequea otra. No hay garantía de que coincidan.
  2. Depende de emitDecoratorMetadata. Necesita el flag de compilador emitDecoratorMetadata y reflect-metadata. Ese flag es cada vez más incómodo con bundlers modernos (esbuild, swc, Rspack) y es incompatible con varios tsconfig estándar. Es una de las razones por las que Nest 12 abre la puerta a alternativas.
  3. Acoplamiento a decoradores y metadatos. La validación vive pegada a la clase; reutilizar la forma en otra capa (un job, un mensaje de cola) implica arrastrar la clase con sus decoradores.
  4. Los tipos no se infieren, se declaran a mano. Escribís el tipo y las reglas por separado, dos veces.

Zod (y Valibot) como alternativa schema-first

El enfoque schema-first invierte la relación: definís un schema y el tipo TypeScript se infiere de él. Una sola fuente de verdad.

import { z } from 'zod';

export const createUserSchema = z.object({
  email: z.string().email(),
  password: z.string().min(8),
  age: z.number().int().min(18).optional(),
});

// El tipo sale del schema; imposible que se desincronicen
export type CreateUser = z.infer<typeof createUserSchema>;

Para integrarlo con Nest necesitás un pipe que corra el schema. Es trivial de escribir:

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

@Injectable()
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.issues);
    }
    return result.data; // ya parseado, transformado y tipado
  }
}

Uso en el handler:

@Post()
create(@Body(new ZodValidationPipe(createUserSchema)) dto: CreateUser) {
  return this.service.create(dto); // dto está validado y tipado
}

Notá que Zod valida y transforma en un solo paso: z.coerce.number(), .trim(), .default(), .transform() viven en el schema. No necesitás class-transformer aparte. Y como el schema es un objeto plano, lo reutilizás en cualquier capa (un consumer de cola, un CLI, tests) sin arrastrar decoradores ni reflect-metadata.

La librería nestjs-zod empaqueta esto: createZodDto(schema) genera una clase-DTO que sirve para el pipe, para el tipo y para el schema de OpenAPI de Swagger, cubriendo el hueco de integración con el ecosistema:

import { createZodDto } from 'nestjs-zod';

class CreateUserDto extends createZodDto(createUserSchema) {}
// usable en @Body() dto: CreateUserDto, con Swagger y whitelisting implícito por el schema

Valibot es la misma idea con foco en tamaño de bundle: API modular y tree-shakeable, ideal si el peso importa (edge, serverless). El patrón del pipe es idéntico.

Comparación

Criterioclass-validator + class-transformerZod / Valibot
Fuente de verdad de tiposDoble (tipo TS + decoradores)Única (schema, tipo inferido)
emitDecoratorMetadataRequeridoNo necesita
Validación + transformaciónDos libreríasUna, en el mismo schema
Reutilización fuera de NestAcoplada a la clase/decoradoresSchema plano, portable
Integración nativa con NestTotal (default)Vía pipe custom o nestjs-zod
SwaggerNativoVía nestjs-zod
ComposiciónPartialType/PickType/Omit.partial()/.pick()/.omit()/.extend()
WhitelistingOpción del pipe (whitelist).strict() en el objeto (rechaza claves extra)

Un detalle de seguridad: el equivalente a forbidNonWhitelisted en Zod es z.object({...}).strict(), que rechaza claves no declaradas. Por default z.object descarta las claves extra (equivalente a whitelist). Es decir, Zod te da el mismo blindaje contra mass assignment, expresado en el schema.

La dirección de Nest 12: Standard Schema nativo

Hasta Nest 11, integrar Zod/Valibot requería el pipe manual de arriba. Nest 12 (major apuntado para Q3 2026) cambia esto de raíz: los decoradores de ruta @Body, @Query y @Param aceptan una opción schema compatible con Standard Schema, una micro-especificación que Zod, Valibot, ArkType, Effect Schema y otros ya implementan.

// Nest 12: sin pipe custom, sin adaptador
@Post()
create(@Body({ schema: createUserSchema }) dto: CreateUser) {
  return this.service.create(dto);
}

@Get(':id')
findOne(@Param('id', { schema: z.coerce.number().int().positive() }) id: number) {
  return this.service.findOne(id);
}

Qué cambia en la práctica:

La lectura estratégica: si arrancás un proyecto nuevo hoy sobre Nest 11 y querés minimizar deuda futura, el enfoque schema-first (Zod/Valibot con un ZodValidationPipe o nestjs-zod) te deja mejor posicionado para Nest 12, donde ese schema se conecta nativamente sin el pipe. Si ya tenés una base grande con class-validator, no hay urgencia: seguirá funcionando.

Validación consistente entre capas

Un error conceptual frecuente: creer que validar en el frontend “alcanza”. Nunca confíes en la validación del cliente. El frontend valida por experiencia de usuario (feedback inmediato); el backend valida por integridad y seguridad. Un atacante no usa tu formulario: pega directo al endpoint con curl. Toda regla que importe para la integridad de los datos o la seguridad debe vivir en el pipe del backend, la valide o no el frontend. Esto se profundiza en el capítulo 10 (Seguridad).

La consistencia también aplica entre capas del backend. La forma validada en el borde (el DTO) no debería re-validarse a mano en el service ni transformarse de nuevo en el repositorio. Un solo lugar de verdad por dirección de datos: el pipe valida la entrada, el interceptor de serialización controla la salida. Si tenés la misma regla escrita en el DTO y otra vez en el service, tenés un esquema duplicado esperando a desincronizarse.

Antipatrones y sus refactors

1. Validar a mano en el controller

// ANTIPATRÓN: el handler hace de validador
@Post()
create(@Body() body: any) {
  if (!body.email || !body.email.includes('@')) {
    throw new BadRequestException('email inválido');
  }
  if (typeof body.age !== 'number' || body.age < 18) {
    throw new BadRequestException('edad inválida');
  }
  return this.service.create(body); // body sigue siendo 'any', sin whitelist
}

Problemas: el controller mezcla validación con orquestación, body es any (sin tipos), no hay whitelist (mass assignment abierto) y la lógica no se reutiliza. Refactor:

// MEJOR: el pipe valida, el handler orquesta
@Post()
create(@Body() dto: CreateUserDto) {
  return this.service.create(dto); // dto validado, tipado, con whitelist global
}

2. DTOs sin whitelist

Ya visto: un DTO validado con whitelist: false deja pasar campos no declarados al service. Refactor: whitelist: true + forbidNonWhitelisted: true global. No hay excusa para tenerlo apagado.

3. Transformar en el service lo que debería validar el pipe

// ANTIPATRÓN: coerción de tipos dentro de la lógica de negocio
async list(query: Record<string, string>) {
  const page = parseInt(query.page ?? '1', 10);
  const size = Math.min(parseInt(query.size ?? '20', 10), 100);
  // ... el service ahora sabe de parseo de strings HTTP
}

El service quedó acoplado al formato de transporte (strings de query). Refactor: que el pipe entregue tipos ya correctos.

// MEJOR: el pipe transforma; el service recibe tipos limpios
export class ListQueryDto {
  @Type(() => Number) @IsInt() @Min(1)
  page = 1;

  @Type(() => Number) @IsInt() @Min(1) @Max(100)
  size = 20;
}

// service:
async list(query: ListQueryDto) { /* page y size ya son numbers válidos */ }

Con Zod: z.object({ page: z.coerce.number().int().min(1).default(1), size: z.coerce.number().int().min(1).max(100).default(20) }).

4. Esquemas duplicados

Tener el mismo shape escrito como clase class-validator para la API y como interface suelta para el service, o dos schemas Zod casi iguales para create y update. Refactor: componé, no dupliques.

// class-validator
export class UpdateUserDto extends PartialType(CreateUserDto) {}

// Zod
export const updateUserSchema = createUserSchema.partial();

Flujo completo del request

sequenceDiagram
  participant C as Cliente
  participant R as Router Nest
  participant P as ValidationPipe
  participant T as class-transformer / Zod
  participant H as Handler (controller)
  participant S as Service

  C->>R: POST /users { email, role, ... }
  R->>P: value + ArgumentMetadata (metatype = DTO)
  P->>T: instanciar / parsear
  T-->>P: objeto transformado
  P->>P: whitelist (descarta extras)
  alt props no declaradas + forbidNonWhitelisted
    P-->>C: 400 Bad Request
  else válido
    P->>H: DTO limpio y tipado
    H->>S: lógica de negocio
    S-->>C: 201 Created
  end

Checklist

Con la entrada ya validada y tipada, el request avanza por el resto de la maquinaria de Nest —middleware, guards, interceptores, pipes y exception filters— cada uno con su rol y su orden. Eso es lo que desarma el próximo capítulo: Ciclo de vida del request →