Vamos con el sexto post de la serie NestJS y primero del bloque de Validación y Base de Datos. Ya tenemos el entorno con Docker (post 1), controllers (post 2), inyección de dependencias (post 3), módulos (post 4) y middleware con el ciclo de vida de una request (post 5), vamos a tener que dejar de hacer este repaso porque cada vez hay más posts xd. Hoy toca hacer de la API un tanke.
Hasta ahora, nuestros controllers aceptan lo que les echen. Un @Body() recibe un objeto y nos fiamos de que el cliente manda lo correcto. Ahí fuera, en el mundo real Neo, eso es una sentencia de muerte porque pueden haber campos que faltan, tipos incorrectos, inyecciones SQL, strings de 10.000 caracteres donde esperabas un nombre… Todo lo que entra desde fuera es hostil hasta que demuestres lo contrario.
Los Pipes son la pieza del pipeline de NestJS diseñada para controlar esto. ¿Recuerdas el diagrama del post 5? Los Pipes se ejecutan justo antes del route handler, después de Guards e Interceptors y su trabajo es validar y transformar los datos de entrada.
EA, vamos al lío que aquí hay muchísima chicha.

1. ¿Qué es un Pipe?
Un 💡 Pipe Clase decorada con @Injectable() que implementa PipeTransform. Se ejecuta justo antes del route handler y tiene dos usos: transformar datos de entrada (parsear un string a número) o validarlos (lanzar excepción si el dato no cumple las reglas). Forma parte del request pipeline de NestJS.
Más info →
es una clase que implementa la interfaz PipeTransform y tiene dos trabajos posibles:
- Transformación — Convertir un dato de un tipo a otro. Ejemplo: el
idllega como string"42"desde la URL y lo conviertes al número42. - Validación — Comprobar que el dato cumple las reglas. Si no las cumple, lanza una excepción antes de que llegue al controller.
import { PipeTransform, Injectable, ArgumentMetadata } from '@nestjs/common';
@Injectable()
export class MiPipe implements PipeTransform {
transform(value: unknown, metadata: ArgumentMetadata): unknown {
// value: el dato de entrada
// metadata: info sobre el parámetro (type, metatype, data)
return value; // devuelve el valor transformado/validado
}
}
El método transform() recibe el valor y un objeto ArgumentMetadata que te dice de dónde viene ese valor. El objeto ArgumentMetadata tiene las siguientes propiedades:
| Propiedad | Qué contiene | Ejemplo |
|---|---|---|
type | De dónde viene: ‘body’, ‘query’, ‘param’ o ‘custom’ | @Body() → ‘body’ |
metatype | El tipo TypeScript del parámetro (la clase del DTO) | CreateUserDto |
data | El nombre del parámetro, si se especificó | @Body(‘email’) → ‘email’ |
2. Built-in Pipes: los que vienen de serie
NestJS trae varios pipes nativos para usar, son los que cubren el 80% de los casos de transformación de parámetros individuales:
ParseIntPipe
Convierte un string a number y si no puede, lanza BadRequestException:
@Get(':id')
findOne(@Param('id', ParseIntPipe) id: number): User {
// id ya es un number, no un string
return this.usersService.findOne(id);
}
Sin ParseIntPipe, id sería un string aunque TypeScript diga que es number. Recuerda que los parámetros de URL siempre llegan como strings. TypeScript no valida en runtime, solo en compilación pero el Pipe sí valida en runtime.
Personalizar el error
Puedes pasar opciones para customizar el status code y el mensaje:
@Get(':id')
findOne(
@Param('id', new ParseIntPipe({
errorHttpStatusCode: HttpStatus.NOT_ACCEPTABLE,
}))
id: number,
): User {
return this.usersService.findOne(id);
}
Otros pipes built-in
| Pipe | Qué hace | Ejemplo de uso |
|---|---|---|
ParseIntPipe | String → number (entero) | @Param(‘id’, ParseIntPipe) |
ParseFloatPipe | String → number (decimal) | @Query(‘price’, ParseFloatPipe) |
ParseBoolPipe | String → boolean | @Query(‘active’, ParseBoolPipe) |
ParseUUIDPipe | Valida que sea un UUID v3/v4/v5 | @Param(‘id’, ParseUUIDPipe) |
ParseArrayPipe | Parsea y valida un array | @Body(new ParseArrayPipe({ items: Number })) |
ParseEnumPipe | Valida que el valor sea de un enum | @Param(‘role’, new ParseEnumPipe(Role)) |
DefaultValuePipe | Pone un valor por defecto si es undefined o null | @Query(‘page’, new DefaultValuePipe(1), ParseIntPipe) |
Fíjate con el último ejemplo de que puedes encadenar pipes. Primero DefaultValuePipe pone el valor por defecto si no viene, y luego ParseIntPipe lo convierte a número. Los Pipes encadenados se ejecutan de izquierda a derecha.
Ejemplo de paginación tipada con pipes encadenados
@Get()
findAll(
@Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number,
@Query('limit', new DefaultValuePipe(10), ParseIntPipe) limit: number,
): User[] {
// page y limit son numbers garantizados con valores por defecto
return this.usersService.findAll(page, limit);
}
Sin estos pipes, page y limit serían string | undefined. Con ellos, son number garantizado, con valor por defecto si el cliente no los manda.
3. DTOs: la forma correcta de tipar las entradas
Los built-in pipes cubren parámetros individuales, pero cuando recibes un body con un objeto completo (crear un usuario, actualizar un producto…), necesitas algo más potente, necesitas un 💡 DTO Data Transfer Object. Clase TypeScript que define la forma exacta de los datos que espera un endpoint. Sirve como contrato tipado entre el cliente y el servidor, y como base para la validación automática con class-validator. Más info → .
En los posts anteriores ya usamos DTOs como interfaces planas, pero para validación real necesitamos clases, no interfaces. ¿Por qué? Porque las interfaces de TypeScript desaparecen en compilación y en runtime no existen. Las clases sí existen en runtime y los decoradores de validación se asocian a ellas.
// ❌ Interface: desaparece en runtime, no se puede validar
interface CreateUserDto {
name: string;
email: string;
}
// ✅ Clase: existe en runtime, los decoradores se mantienen
class CreateUserDto {
name: string;
email: string;
}
4. class-validator y class-transformer
Las dos librerías que hacen la magia:
4.1. class-validator — Los decoradores de validación
💡 class-validator Librería que permite validar objetos usando decoradores en propiedades de clase. Cada decorador define una regla de validación (@IsString, @IsEmail, @MinLength, etc.) que se verifica en runtime con la función validate(). Más info → proporciona decoradores que defines sobre las propiedades de tus DTOs:
// src/users/dto/create-user.dto.ts
import {
IsString,
IsEmail,
IsNotEmpty,
MinLength,
MaxLength,
IsOptional,
IsBoolean,
Matches,
} from 'class-validator';
export class CreateUserDto {
@IsString()
@IsNotEmpty()
@MinLength(2)
@MaxLength(100)
readonly name: string;
@IsEmail()
@IsNotEmpty()
readonly email: string;
@IsString()
@MinLength(8)
@MaxLength(128)
@Matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/, {
message: 'La contraseña debe contener al menos una mayúscula, una minúscula y un número',
})
readonly password: string;
@IsBoolean()
@IsOptional()
readonly active?: boolean;
}
Cada propiedad tiene sus reglas. Si alguna falla, la validación lanza un error antes de que tu controller se entere. Las propiedades marcadas con @IsOptional() solo se validan si están presentes.
Decoradores más usados
| Decorador | Qué valida |
|---|---|
@IsString() | Es un string |
@IsNumber() | Es un número |
@IsBoolean() | Es un boolean |
@IsEmail() | Es un email válido |
@IsNotEmpty() | No es string vacío, null ni undefined |
@IsOptional() | El campo puede no existir. Si existe, se validan las demás reglas |
@MinLength(n) / @MaxLength(n) | Longitud mínima/máxima de un string |
@Min(n) / @Max(n) | Valor mínimo/máximo de un número |
@Matches(regex) | Cumple una expresión regular |
@IsEnum(enum) | Es un valor del enum dado |
@IsArray() | Es un array |
@IsDate() | Es una instancia de Date |
@IsUUID() | Es un UUID válido |
@ValidateNested() | Valida un objeto anidado (combinado con @Type()) |
4.2. class-transformer — Convertir objetos planos a instancias
Cuando Express parsea el JSON del body, crea un objeto plano ({}) — no una instancia de tu clase DTO, y los decoradores de class-validator solo funcionan sobre instancias de clase.
💡 class-transformer Librería que transforma objetos planos ({}) en instancias de clase y viceversa. La función clave es plainToInstance(), que convierte un objeto plano en una instancia de la clase indicada, permitiendo que los decoradores de class-validator funcionen.
Más info →
hace esa conversión porque el ValidationPipe de NestJS usa class-transformer internamente, así que no tienes que llamar a plainToInstance() a mano.
Pero class-transformer también te da decoradores útiles, especialmente para objetos anidados y transformación de tipos:
import { Type } from 'class-transformer';
import { ValidateNested, IsArray } from 'class-validator';
export class CreateOrderDto {
@IsArray()
@ValidateNested({ each: true })
@Type(() => OrderItemDto)
readonly items: OrderItemDto[];
}
export class OrderItemDto {
@IsString()
@IsNotEmpty()
readonly productId: string;
@IsNumber()
@Min(1)
readonly quantity: number;
}
El @Type(() => OrderItemDto) le dice a class-transformer: “cada elemento del array items es de tipo OrderItemDto”. Sin esto, los items serían objetos planos y @ValidateNested() no podría validar sus propiedades.
5. ValidationPipe: la pieza que lo conecta todo
El 💡 ValidationPipe Pipe built-in de NestJS que usa internamente class-validator y class-transformer para validar y transformar automáticamente los datos de entrada. Se puede aplicar a nivel de parámetro, handler, controller o globalmente.
Más info →
es el pipe que usa class-validator y class-transformer por debajo para validar tus DTOs automáticamente. Es el pegamento entre tus DTOs decorados y el pipeline de NestJS.
5.1. Registro global (la forma correcta)
La forma profesional es registrarlo globalmente en main.ts para que aplique a todos los endpoints:
// src/main.ts
import { NestFactory } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common';
import { AppModule } from './app.module';
async function bootstrap(): Promise<void> {
const app = await NestFactory.create(AppModule);
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
transformOptions: {
enableImplicitConversion: true,
},
}),
);
await app.listen(3000);
console.warn('Server running on http://localhost:3000');
}
bootstrap();
Vamos a destripar cada opción porque son críticas:
5.2. whitelist: true: Filtrar propiedades no decoradas
Con whitelist: true, el ValidationPipe elimina automáticamente cualquier propiedad que no tenga un decorador de class-validator. Si tu DTO solo define name, email y password, y el cliente manda un campo extra isAdmin: true… se borra y no llega al controller.
// DTO solo tiene name y email
export class CreateUserDto {
@IsString()
readonly name: string;
@IsEmail()
readonly email: string;
}
// El cliente manda:
// { "name": "Domin", "email": "domin@domin.es", "isAdmin": true, "role": "superadmin" }
// Lo que llega al controller:
// { "name": "Domin", "email": "domin@domin.es" }
// isAdmin y role se eliminan silenciosamente
Esto es seguridad básica porque sin whitelist, un atacante podría intentar inyectar campos extra que no esperabas.
5.3. forbidNonWhitelisted: true: Rechazar campos no esperados
En vez de eliminar silenciosamente los campos extra como whitelist, lanza un error. Así, el cliente sabe que mandó algo que no debía:
La combinación whitelist: true + forbidNonWhitelisted: true es lo que recomiendo siempre. Primero porque es más seguro, y segundo porque ayuda al frontend a detectar errores en sus payloads.
5.4. transform: true: Transformación automática de tipos
Con transform: true, el ValidationPipe hace dos cosas:
- Convierte el objeto plano del body a una instancia real de tu clase DTO (usando
plainToInstance()declass-transformer). - Transforma automáticamente los tipos de los parámetros según el tipo declarado en TypeScript.
@Get(':id')
findOne(@Param('id') id: number): User {
// Sin transform: id es string "42"
// Con transform: id es number 42
console.warn(typeof id); // "number" ✅
return this.usersService.findOne(id);
}
Con enableImplicitConversion: true en transformOptions, no necesitas ParseIntPipe para parámetros simples porque El tipo de TypeScript es suficiente para que class-transformer haga la conversión. Pero ojito!
- enableImplicitConversion para query params y params simples donde el tipo TypeScript basta
- ParseIntPipe/ParseUUIDPipe explícitos cuando quieres un error específico y descriptivo
- Ambos enfoques a la vez: enableImplicitConversion como base + pipes explícitos donde necesites control fino
- Confiar solo en enableImplicitConversion para UUIDs — no valida el formato, solo convierte strings
- Quitar transform:true para "ganar rendimiento". La diferencia es despreciable y pierdes las ventajas
5.5. Registro global con DI (alternativa)
Si necesitas inyectar dependencias en la configuración del pipe (por ejemplo, leer opciones de un ConfigService), puedes registrarlo como provider en un módulo en vez de en main.ts:
// src/app.module.ts
import { Module, ValidationPipe } from '@nestjs/common';
import { APP_PIPE } from '@nestjs/core';
@Module({
providers: [
{
provide: APP_PIPE,
useValue: new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
transformOptions: { enableImplicitConversion: true },
}),
},
],
})
export class AppModule {}
El token APP_PIPE es un token especial de NestJS para registrar pipes globales desde un módulo. El resultado es el mismo que app.useGlobalPipes(), pero con acceso al contenedor de DI.
6. Mensajes de error personalizados
Por defecto, class-validator genera mensajes en inglés como "email must be an email". En una API en producción querrás customizarlos:
export class CreateUserDto {
@IsString({ message: 'El nombre debe ser un string' })
@IsNotEmpty({ message: 'El nombre es obligatorio' })
@MinLength(2, { message: 'El nombre debe tener al menos $constraint1 caracteres' })
@MaxLength(100, { message: 'El nombre no puede superar los $constraint1 caracteres' })
readonly name: string;
@IsEmail({}, { message: 'El email no tiene un formato válido' })
@IsNotEmpty({ message: 'El email es obligatorio' })
readonly email: string;
@IsString({ message: 'La contraseña debe ser un string' })
@MinLength(8, { message: 'La contraseña debe tener al menos $constraint1 caracteres' })
@Matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/, {
message: 'La contraseña debe contener al menos una mayúscula, una minúscula y un número',
})
readonly password: string;
}
Fíjate en $constraint1 que es una variable de interpolación que class-validator reemplaza por el valor del constraint. Para @MinLength(8), $constraint1 vale 8. También tienes $value (el valor recibido), $property (el nombre de la propiedad) y $target (el nombre de la clase).
7. Mapped Types: DTOs sin duplicar código
Cuando tienes un CreateUserDto y necesitas un UpdateUserDto que es básicamente lo mismo pero con todos los campos opcionales, no dupliques el DTO. NestJS proporciona utilidades de Mapped Types Mapped Types Funciones de utilidad de @nestjs/mapped-types que crean nuevos DTOs a partir de uno existente: PartialType (todo opcional), PickType (solo algunos campos), OmitType (sin ciertos campos), IntersectionType (combinar dos DTOs). para esto:
7.1. PartialType — Todos los campos opcionales
// src/users/dto/update-user.dto.ts
import { PartialType } from '@nestjs/mapped-types';
import { CreateUserDto } from './create-user.dto';
export class UpdateUserDto extends PartialType(CreateUserDto) {}
PartialType(CreateUserDto) genera una clase con las mismas propiedades y decoradores de CreateUserDto, pero todas marcadas como @IsOptional(). Una sola línea y tu UpdateUserDto ya valida correctamente para operaciones PATCH donde el cliente solo manda los campos que quiere cambiar.
7.2. PickType — Solo ciertos campos
import { PickType } from '@nestjs/mapped-types';
// Solo name y email, sin password
export class UpdateProfileDto extends PickType(CreateUserDto, ['name', 'email']) {}
7.3. OmitType — Todo menos ciertos campos
import { OmitType } from '@nestjs/mapped-types';
// Todo excepto password
export class UserResponseDto extends OmitType(CreateUserDto, ['password']) {}
7.4. IntersectionType — Combinar dos DTOs
import { IntersectionType } from '@nestjs/mapped-types';
export class CreateUserWithAddressDto extends IntersectionType(
CreateUserDto,
CreateAddressDto,
) {}
7.5. Composición: la verdadera potencia
Puedes combinar estas utilidades:
// Todos los campos de CreateUserDto opcionales EXCEPTO email
export class UpdateEmailRequiredDto extends IntersectionType(
PickType(CreateUserDto, ['email']), // email obligatorio
PartialType(OmitType(CreateUserDto, ['email'])), // el resto opcional
) {}
Aquí ya nos podemos reventar la cabesita, expresas qué quieres, no cómo construirlo. Y si mañana añades un campo a CreateUserDto, todos los DTOs derivados lo heredan automáticamente. Así tendremos cero duplicación con total escalabilidad.
8. Validación de objetos anidados
Cuando tu DTO tiene objetos o arrays de objetos dentro, necesitas dos piezas extra: @ValidateNested() y @Type():
// src/orders/dto/create-order.dto.ts
import { Type } from 'class-transformer';
import {
IsString,
IsNotEmpty,
IsNumber,
IsArray,
ValidateNested,
ArrayMinSize,
Min,
} from 'class-validator';
export class OrderItemDto {
@IsString()
@IsNotEmpty()
readonly productId: string;
@IsNumber()
@Min(1, { message: 'La cantidad debe ser al menos 1' })
readonly quantity: number;
@IsNumber()
@Min(0, { message: 'El precio no puede ser negativo' })
readonly unitPrice: number;
}
export class CreateOrderDto {
@IsString()
@IsNotEmpty()
readonly customerId: string;
@IsArray()
@ArrayMinSize(1, { message: 'El pedido debe tener al menos un item' })
@ValidateNested({ each: true })
@Type(() => OrderItemDto)
readonly items: OrderItemDto[];
@IsString()
@IsOptional()
readonly notes?: string;
}
Sin @Type(() => OrderItemDto), class-transformer no sabe a qué clase transformar los elementos del array y @ValidateNested() no puede validar sus propiedades internas.
El { each: true } en @ValidateNested() le dice que valide cada elemento del array individualmente.
9. Custom Pipes: cuando los built-in no bastan
A veces necesitas lógica de validación/transformación que ningún pipe built-in cubre. Para estos casos creas tu propio Pipe:
9.1. ParseSlugPipe — Validar y normalizar slugs
// src/common/pipes/parse-slug.pipe.ts
import { PipeTransform, Injectable, BadRequestException } from '@nestjs/common';
@Injectable()
export class ParseSlugPipe implements PipeTransform<string, string> {
transform(value: string): string {
if (typeof value !== 'string') {
throw new BadRequestException('El slug debe ser un string');
}
const normalized = value.toLowerCase().trim();
if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(normalized)) {
throw new BadRequestException(
`El slug "${value}" no es válido. Solo letras minúsculas, números y guiones`,
);
}
return normalized;
}
}
Y se usa como cualquier otro pipe:
@Get(':slug')
findBySlug(@Param('slug', ParseSlugPipe) slug: string): Product {
return this.productsService.findBySlug(slug);
}
9.2. ParseSortPipe — Validar y parsear ordenación
Un ejemplo más realista, un pipe que parsea y valida parámetros de ordenación de queries:
// src/common/pipes/parse-sort.pipe.ts
import { PipeTransform, Injectable, BadRequestException } from '@nestjs/common';
export interface SortOption {
field: string;
order: 'ASC' | 'DESC';
}
@Injectable()
export class ParseSortPipe implements PipeTransform<string | undefined, SortOption[]> {
constructor(private readonly allowedFields: string[]) {}
transform(value: string | undefined): SortOption[] {
if (!value) {
return [];
}
return value.split(',').map((part) => {
const trimmed = part.trim();
const order: 'ASC' | 'DESC' = trimmed.startsWith('-') ? 'DESC' : 'ASC';
const field = trimmed.replace(/^-/, '');
if (!this.allowedFields.includes(field)) {
throw new BadRequestException(
`Campo de ordenación "${field}" no permitido. Campos válidos: ${this.allowedFields.join(', ')}`,
);
}
return { field, order };
});
}
}
@Get()
findAll(
@Query('sort', new ParseSortPipe(['name', 'email', 'createdAt']))
sort: SortOption[],
): User[] {
// GET /users?sort=-name,createdAt
// sort = [{ field: 'name', order: 'DESC' }, { field: 'createdAt', order: 'ASC' }]
return this.usersService.findAll({ sort });
}
Este pipe acepta un formato como -name,createdAt (el guión indica DESC) y lo parsea a un array tipado de SortOption[]. Si alguien manda un campo que no está en la whitelist, lanza un error.
10. Validación condicional
A veces la validación de un campo depende del valor de otro. class-validator tiene @ValidateIf() para esto:
import { ValidateIf, IsString, IsNotEmpty, IsUrl } from 'class-validator';
export class CreateNotificationDto {
@IsString()
@IsNotEmpty()
readonly type: 'email' | 'webhook';
@ValidateIf((dto) => dto.type === 'email')
@IsEmail()
readonly email?: string;
@ValidateIf((dto) => dto.type === 'webhook')
@IsUrl()
readonly webhookUrl?: string;
}
Si type es 'email', se valida email pero no webhookUrl y viceversa. El callback recibe el DTO completo como argumento, así que puedes hacer cualquier lógica condicional.
11. Custom validators: tus propios decoradores
Cuando necesitas validación reutilizable que class-validator no trae de serie, puedes crear tu propio decorador:
// src/common/validators/is-strong-password.validator.ts
import {
registerDecorator,
type ValidationOptions,
type ValidationArguments,
} from 'class-validator';
export function IsStrongPassword(validationOptions?: ValidationOptions) {
return function (object: object, propertyName: string): void {
registerDecorator({
name: 'isStrongPassword',
target: object.constructor,
propertyName,
options: validationOptions,
validator: {
validate(value: unknown, _args: ValidationArguments): boolean {
if (typeof value !== 'string') return false;
const hasUpperCase = /[A-Z]/.test(value);
const hasLowerCase = /[a-z]/.test(value);
const hasNumber = /\d/.test(value);
const hasSpecialChar = /[!@#$%^&*(),.?":{}|<>]/.test(value);
const isLongEnough = value.length >= 8;
return hasUpperCase && hasLowerCase && hasNumber && hasSpecialChar && isLongEnough;
},
defaultMessage(args: ValidationArguments): string {
return `${args.property} debe tener al menos 8 caracteres, una mayúscula, una minúscula, un número y un carácter especial`;
},
},
});
};
}
Ahora lo usas como cualquier decorador de class-validator:
export class CreateUserDto {
@IsString()
@IsNotEmpty()
readonly name: string;
@IsEmail()
readonly email: string;
@IsStrongPassword()
readonly password: string;
}
Ahora tienes un decorator limpio, reutilizable y auto-documentado. Si mañana las reglas de contraseña cambian, las cambias en un sitio y aplica a todos los DTOs que lo usen.
12. El truco de exceptionFactory: errores a tu medida
El formato de error por defecto del ValidationPipe está bien, pero en APIs reales querrás un formato consistente. Puedes customizar cómo se construye la excepción:
// src/main.ts
import { ValidationPipe, BadRequestException, type ValidationError } from '@nestjs/common';
function formatValidationErrors(errors: ValidationError[]): Record<string, string[]> {
const formatted: Record<string, string[]> = {};
for (const error of errors) {
if (error.constraints) {
formatted[error.property] = Object.values(error.constraints);
}
if (error.children?.length) {
const childErrors = formatValidationErrors(error.children);
for (const [key, messages] of Object.entries(childErrors)) {
formatted[`${error.property}.${key}`] = messages;
}
}
}
return formatted;
}
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
transformOptions: { enableImplicitConversion: true },
exceptionFactory: (errors: ValidationError[]) =>
new BadRequestException({
statusCode: 400,
error: 'Validation Error',
messages: formatValidationErrors(errors),
}),
}),
);
Ahora en vez del array plano de strings, obtienes errores agrupados por campo:
Esto es mucho más útil para el frontend porque puede mapear cada error al campo correspondiente del formulario.
13. Dónde aplicar pipes: los cuatro niveles
Los pipes se pueden aplicar a cuatro niveles de granularidad:
Se aplica a un solo parámetro. El más granular. Útil para ParseIntPipe, ParseUUIDPipe y pipes custom sobre un valor concreto.
@Param("id", ParseIntPipe) id: number
Se aplica a todos los parámetros de un método. Útil para DTOs de body en un endpoint concreto.
@UsePipes(new ValidationPipe())
Se aplica a todos los handlers del controller. Útil cuando todo el controller requiere la misma validación.
@UsePipes(ValidationPipe) en la clase
Se aplica a toda la app. app.useGlobalPipes() o APP_PIPE. Es donde va el ValidationPipe normalmente.
app.useGlobalPipes(new ValidationPipe())
Con ValidationPipe global, no necesitas @UsePipes() en cada controller. Los pipes de parámetro (como ParseIntPipe) van además del pipe global. Ambos se ejecutan, no se sustituyen.
14. Ejemplo práctico completo: CRUD validado
Vamos a juntar todo en el módulo de usuarios del proyecto del curso:
// src/users/dto/create-user.dto.ts
import {
IsString,
IsEmail,
IsNotEmpty,
MinLength,
MaxLength,
IsOptional,
IsBoolean,
Matches,
} from 'class-validator';
export class CreateUserDto {
@IsString({ message: 'El nombre debe ser un string' })
@IsNotEmpty({ message: 'El nombre es obligatorio' })
@MinLength(2, { message: 'El nombre debe tener al menos $constraint1 caracteres' })
@MaxLength(100, { message: 'El nombre no puede superar los $constraint1 caracteres' })
readonly name: string;
@IsEmail({}, { message: 'El email no tiene un formato válido' })
@IsNotEmpty({ message: 'El email es obligatorio' })
readonly email: string;
@IsString({ message: 'La contraseña debe ser un string' })
@MinLength(8, { message: 'La contraseña debe tener al menos $constraint1 caracteres' })
@MaxLength(128, { message: 'La contraseña no puede superar los $constraint1 caracteres' })
@Matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/, {
message: 'La contraseña debe contener al menos una mayúscula, una minúscula y un número',
})
readonly password: string;
@IsBoolean({ message: 'active debe ser un boolean' })
@IsOptional()
readonly active?: boolean;
}
// src/users/dto/update-user.dto.ts
import { PartialType } from '@nestjs/mapped-types';
import { CreateUserDto } from './create-user.dto';
export class UpdateUserDto extends PartialType(CreateUserDto) {}
// src/users/dto/query-users.dto.ts
import { IsOptional, IsString, IsBoolean, IsNumber, Min, Max } from 'class-validator';
export class QueryUsersDto {
@IsOptional()
@IsNumber()
@Min(1)
readonly page?: number = 1;
@IsOptional()
@IsNumber()
@Min(1)
@Max(100)
readonly limit?: number = 10;
@IsOptional()
@IsString()
readonly search?: string;
@IsOptional()
@IsBoolean()
readonly active?: boolean;
}
// src/users/users.controller.ts
import {
Controller,
Get,
Post,
Put,
Delete,
Body,
Param,
Query,
ParseIntPipe,
HttpCode,
HttpStatus,
} from '@nestjs/common';
import { UsersService } from './users.service';
import { CreateUserDto } from './dto/create-user.dto';
import { UpdateUserDto } from './dto/update-user.dto';
import { QueryUsersDto } from './dto/query-users.dto';
import { type User } from './entities/user.entity';
@Controller('users')
export class UsersController {
constructor(private readonly usersService: UsersService) {}
@Post()
@HttpCode(HttpStatus.CREATED)
create(@Body() createUserDto: CreateUserDto): User {
// createUserDto ya está validado y tipado ✅
// Campos extra eliminados por whitelist ✅
// Tipos transformados automáticamente ✅
return this.usersService.create(createUserDto);
}
@Get()
findAll(@Query() query: QueryUsersDto): User[] {
// query.page y query.limit son numbers con defaults ✅
// query.active es boolean si viene, undefined si no ✅
return this.usersService.findAll(query);
}
@Get(':id')
findOne(@Param('id', ParseIntPipe) id: number): User {
// id es number garantizado, error 400 si no es numérico ✅
return this.usersService.findOne(id);
}
@Put(':id')
update(
@Param('id', ParseIntPipe) id: number,
@Body() updateUserDto: UpdateUserDto,
): User {
// Todos los campos son opcionales gracias a PartialType ✅
return this.usersService.update(id, updateUserDto);
}
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
remove(@Param('id', ParseIntPipe) id: number): void {
this.usersService.remove(id);
}
}
Fíjate que el controller no tiene una sola línea de validación. Toda la validación la hacen los DTOs y los pipes. El controller solo orquesta y no hace nada más, separación de responsabilidades lo llaman!
15. Recapitulando
Transforman y validan datos justo antes del handler. PipeTransform con método transform(). Dos usos: conversión de tipos y validación.
ParseIntPipe, ParseUUIDPipe, ParseBoolPipe, ParseEnumPipe, DefaultValuePipe. Encadenables. Cubren parámetros individuales.
Usa class-validator + class-transformer. Valida DTOs automáticamente. whitelist + forbidNonWhitelisted = seguridad.
Clases (no interfaces) con @IsString, @IsEmail, @MinLength... Existen en runtime. Son el contrato de tu API.
PartialType, PickType, OmitType, IntersectionType. Cero duplicación. Un cambio en el DTO base se propaga a todos.
implements PipeTransform para lógica que los built-in no cubren. Reutilizables, testeables, inyectables.
En el próximo post conectamos con una base de datos real: TypeORM + PostgreSQL + Docker Compose. Vamos a montar el docker-compose con PostgreSQL y pgAdmin, configurar TypeOrmModule.forRootAsync() (que ya entiendes desde el post 4), y crear nuestras primeras entidades con decoradores.
EA, nos vemos en los bares!! 🍺
Pon a prueba lo aprendido
1. ¿Por qué los DTOs deben ser clases y no interfaces para usar validación?
2. ¿Qué hace la opción whitelist: true del ValidationPipe?
3. ¿Qué utilidad de Mapped Types crea un DTO con todos los campos opcionales?
4. ¿Qué combinación de decoradores necesitas para validar un array de objetos anidados?
5. ¿En qué punto del pipeline de NestJS se ejecutan los Pipes?