🚨 ¡Nueva review! ¡Mi teclado ideal! ⌨️ Perfecto para programar, el Logitech MX Keys S . ¡Échale un ojo! 👀

DTOs, Pipes y Validación en NestJS: blindando las entradas

Serie NestJS #6 — class-validator, ValidationPipe, custom pipes y DTOs tipados tankeados

Escrito por domin el 25 de marzo de 2026

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.

Escudo protegiendo una API NestJS representando la validación de datos de entrada con Pipes y DTOs.

1. ¿Qué es un Pipe?

Un es una clase que implementa la interfaz PipeTransform y tiene dos trabajos posibles:

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:

PropiedadQué contieneEjemplo
typeDe dónde viene: ‘body’, ‘query’, ‘param’ o ‘custom’@Body()‘body’
metatypeEl tipo TypeScript del parámetro (la clase del DTO)CreateUserDto
dataEl 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.

ParseIntPipe en acción 0 / 2
$
Pulsa para ejecutar el siguiente comando

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

PipeQué haceEjemplo de uso
ParseIntPipeString → number (entero)@Param(‘id’, ParseIntPipe)
ParseFloatPipeString → number (decimal)@Query(‘price’, ParseFloatPipe)
ParseBoolPipeString → boolean@Query(‘active’, ParseBoolPipe)
ParseUUIDPipeValida que sea un UUID v3/v4/v5@Param(‘id’, ParseUUIDPipe)
ParseArrayPipeParsea y valida un array@Body(new ParseArrayPipe({ items: Number }))
ParseEnumPipeValida que el valor sea de un enum@Param(‘role’, new ParseEnumPipe(Role))
DefaultValuePipePone 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 .

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:

Instalación de dependencias 0 / 1
$
Pulsa para ejecutar el siguiente comando

4.1. class-validator — Los decoradores de validación

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

DecoradorQué 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.

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 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:

forbidNonWhitelisted en acción 0 / 1
$
Pulsa para ejecutar el siguiente comando

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:

  1. Convierte el objeto plano del body a una instancia real de tu clase DTO (usando plainToInstance() de class-transformer).
  2. 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!

Úsalo cuando...
  • 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
Evítalo cuando...
  • 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).

Mensajes de error personalizados 0 / 1
$
Pulsa para ejecutar el siguiente comando

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 para esto:

Instalación de @nestjs/mapped-types 0 / 1
$
Pulsa para ejecutar el siguiente comando

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:

Errores agrupados por campo 0 / 1
$
Pulsa para ejecutar el siguiente comando

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:

🎯 Nivel parámetro

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

📌 Nivel handler

Se aplica a todos los parámetros de un método. Útil para DTOs de body en un endpoint concreto.

@UsePipes(new ValidationPipe())

🏗️ Nivel controller

Se aplica a todos los handlers del controller. Útil cuando todo el controller requiere la misma validación.

@UsePipes(ValidationPipe) en la clase

🌍 Nivel global

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

🔧 Pipes

Transforman y validan datos justo antes del handler. PipeTransform con método transform(). Dos usos: conversión de tipos y validación.

📦 Built-in Pipes

ParseIntPipe, ParseUUIDPipe, ParseBoolPipe, ParseEnumPipe, DefaultValuePipe. Encadenables. Cubren parámetros individuales.

🛡️ ValidationPipe

Usa class-validator + class-transformer. Valida DTOs automáticamente. whitelist + forbidNonWhitelisted = seguridad.

📝 DTOs con decoradores

Clases (no interfaces) con @IsString, @IsEmail, @MinLength... Existen en runtime. Son el contrato de tu API.

🧬 Mapped Types

PartialType, PickType, OmitType, IntersectionType. Cero duplicación. Un cambio en el DTO base se propaga a todos.

⚡ Custom Pipes

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?