Validación y transformación de datos
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:
- Validación: comprobar que el valor cumple una forma esperada; si no, lanzar una excepción y cortar el request.
- Transformación: convertir el valor a la forma que el handler necesita (string
"42"→ number42, plain object → instancia de clase, string ISO →Date).
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.
whitelist: true: elimina del objeto validado toda propiedad que no tenga al menos un decorador de validación. Los campos “de más” se descartan silenciosamente.forbidNonWhitelisted: true: en vez de descartarlos, rechaza el request con400si aparece una propiedad no declarada.
¿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
transform: true: hace que el pipe devuelva una instancia de la clase DTO en vez de un objeto plano. Sin esto, tu argumento es unObjectcualquiera aunque el tipo digaCreateUserDto; con esto, es una instancia real (importa si el DTO tiene getters o métodos, y para que@Transformcorra). También convierte tipos primitivos de path/query params:@Param('id') id: numberrecibe unnumberreal.enableImplicitConversion: true: convierte tipos basándose en el tipo TypeScript declarado, sin necesidad de decoradores@Type. Un query?age=30llega comonumberporque el DTO diceage: number. Es cómodo, pero tiene una fricción: coerciona de forma agresiva (un"true"puede volversetrue, un"1"puede volverse1), así que revisá que no cambie el significado de tus campos string-que-parecen-número (IDs, códigos postales).
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:
forbidUnknownValues: true(default en la práctica): rechaza objetos que no matchean ningún tipo conocido; protege contra payloads que evaden la validación.disableErrorMessages: true: oculta los mensajes de error detallados en producción (evita filtrar la forma interna de tus DTOs).skipMissingProperties/stopAtFirstError: control fino sobre cuándo y cuánto validar.
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:
- Cero fricción con el framework: el
ValidationPipelo usa nativamente; no configurás nada. - Ecosistema maduro:
@nestjs/swagger,@nestjs/mapped-types, decoradores para casi todo. - Composición de DTOs con
PartialType,PickType,OmitType,IntersectionType.
Sus fricciones son reales y conviene nombrarlas:
- Doble fuente de verdad de tipos. La clase declara tipos TypeScript y decoradores de runtime. El tipo
age: numbery el@IsInt()pueden desincronizarse: TypeScript cree una cosa, el validador chequea otra. No hay garantía de que coincidan. - Depende de
emitDecoratorMetadata. Necesita el flag de compiladoremitDecoratorMetadatayreflect-metadata. Ese flag es cada vez más incómodo con bundlers modernos (esbuild, swc, Rspack) y es incompatible con variostsconfigestándar. Es una de las razones por las que Nest 12 abre la puerta a alternativas. - 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.
- 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
| Criterio | class-validator + class-transformer | Zod / Valibot |
|---|---|---|
| Fuente de verdad de tipos | Doble (tipo TS + decoradores) | Única (schema, tipo inferido) |
emitDecoratorMetadata | Requerido | No necesita |
| Validación + transformación | Dos librerías | Una, en el mismo schema |
| Reutilización fuera de Nest | Acoplada a la clase/decoradores | Schema plano, portable |
| Integración nativa con Nest | Total (default) | Vía pipe custom o nestjs-zod |
| Swagger | Nativo | Vía nestjs-zod |
| Composición | PartialType/PickType/Omit | .partial()/.pick()/.omit()/.extend() |
| Whitelisting | Opció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:
- Fin del monopolio de
class-validator. Deja de ser la única opción de primera clase. Cualquier librería Standard Schema-compatible se enchufa sin adaptadores. - Adiós al requisito de
emitDecoratorMetadatapara validar, lo que destraba la migración a ESM, esbuild/Rspack ytsconfigmodernos. No es casualidad que Nest 12 también migre a ESM, Vitest y oxlint: el schema-first encaja con ese toolchain. - No es un reemplazo forzado. El equipo de Nest fue explícito:
class-validatorsigue soportado y la documentación lo seguirá sugiriendo como default para la mayoría de los casos. La opciónschemaes aditiva. La misma idea se extiende al interceptor de serialización, para tener validación de request y de response gobernadas por el mismo schema.
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
-
ValidationPiperegistrado globalmente (useGlobalPipesoAPP_PIPE). -
whitelist: trueyforbidNonWhitelisted: trueactivados (blindaje contra mass assignment). -
transform: truepara recibir instancias/tipos reales en los handlers. - Revisaste que
enableImplicitConversionno coercione mal tus campos string-que-parecen-número. - Ningún handler valida a mano; todo argumento externo pasa por un pipe o DTO.
- Built-in pipes (
ParseUUIDPipe,ParseIntPipe, etc.) para escalares en path/query. - Los services reciben tipos ya validados; no reparsean strings de transporte.
- Una sola fuente de verdad por shape: componés con
PartialType/.partial(), no duplicás. - Si es proyecto nuevo, evaluaste schema-first (Zod/Valibot) pensando en la migración a Standard Schema de Nest 12.
- Toda regla de integridad/seguridad vive en el backend, no dependés de la validación del frontend.
- En producción, considerás
disableErrorMessages: truepara no filtrar la forma interna de los DTOs.
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 →