Validación en NestJS: DTOs, class-validator y sus trampas
El bug tardó once días en aparecer y cuatro minutos en explicarse.
Una API NestJS, endpoint de registro. El controlador recibía el body, lo pasaba al servicio, el servicio lo pasaba al ORM. Limpio, corto, elegante. Alguien mandó un role: "admin" de más en el JSON y se creó una cuenta con permisos de administrador.
Lo que más duele: el proyecto tenía validación en NestJS. Tenía DTOs con decoradores. Tenía el ValidationPipe registrado globalmente. Y aun así el campo pasó.
Porque el pipe estaba puesto sin opciones. new ValidationPipe(), tal cual. Eso valida que los campos declarados cumplan sus reglas, pero no elimina los que no declaraste. Y en una API, lo que no declaras es exactamente lo que te van a mandar.
La validación en NestJS bien montada no es una capa de seguridad más. Es el contrato entre el mundo exterior y tu aplicación. Cuando lo defines bien, un montón de código defensivo que hoy vive en tus servicios simplemente desaparece.
El DTO en NestJS es un contrato, no un tipo
Un DTO (Data Transfer Object) en NestJS es una clase que define la forma exacta de los datos que un endpoint acepta. No la forma que esperas: la que aceptas.
// src/users/dto/create-user.dto.ts
import {
IsEmail,
IsInt,
IsOptional,
IsString,
MaxLength,
Min,
MinLength,
} from 39;class-validator39;;
export class CreateUserDto {
@IsEmail({}, { message: 39;El email no tiene un formato válido39; })
email: string;
@IsString()
@MinLength(12, { message: 39;La contraseña necesita al menos 12 caracteres39; })
password: string;
@IsString()
@MinLength(2)
@MaxLength(60)
fullName: string;
@IsOptional()
@IsInt()
@Min(18)
age?: number;
}
En el controlador no haces nada especial:
@Post()
create(@Body() dto: CreateUserDto) {
return this.usersService.create(dto);
}
Y aquí viene la primera regla que no se negocia: CreateUserDto tiene que ser una class, nunca una interface.
Las interfaces de TypeScript desaparecen al compilar. Nest lee el tipo del parámetro en runtime con reflect-metadata; si es una interface, el metatype que recibe es Object, no hay decoradores que leer, y el pipe deja pasar el payload entero sin decir nada. Es un fallo silencioso, que son los peores — y es el mismo problema de fondo que trato en cómo tipar correctamente una API REST en TypeScript: el tipo estático no te protege de lo que llega por el cable.
ValidationPipe en NestJS: las opciones que sí importan
El ValidationPipe de NestJS es el pipe integrado que intercepta el payload de una request, lo compara contra los decoradores del DTO usando class-validator y lanza un 400 Bad Request con la lista de errores si algo no cumple. Viene en @nestjs/common y no valida nada por sí solo: todo depende de las opciones con las que lo construyas.
Registra el pipe globalmente y configúralo. Este es el bloque que uso en producción:
// src/main.ts
import { ValidationPipe } from 39;@nestjs/common39;;
import { NestFactory } from 39;@nestjs/core39;;
import { AppModule } from 39;./app.module39;;
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
forbidUnknownValues: true,
transform: true,
transformOptions: { enableImplicitConversion: false },
stopAtFirstError: false,
}),
);
await app.listen(process.env.PORT ?? 3000);
}
bootstrap();
Qué hace cada una, sin adornos.
| Opción | Por defecto en Nest | Recomendado | Qué te juegas |
|---|---|---|---|
whitelist |
false |
true |
Sin ella, las propiedades que no declaras en el DTO llegan intactas al servicio |
forbidNonWhitelisted |
false |
true |
Sin ella los campos de más se borran en silencio en vez de devolver un 400 |
forbidUnknownValues |
false (Nest lo fuerza) |
true |
Con false, un objeto sin metadatos de class-validator pasa la validación entera |
transform |
false |
true |
Sin ella recibes un objeto literal, no una instancia del DTO: sin métodos ni getters |
transformOptions.enableImplicitConversion |
false |
false |
Si lo pones a true, ?onlyActive=false activa el filtro, porque Boolean('false') es true |
stopAtFirstError |
false |
false |
Con true devuelves un error por request en vez de todos los del formulario de una vez |
whitelist: true elimina del objeto toda propiedad que no tenga al menos un decorador de validación. Este flag por sí solo habría evitado el bug de la historia. role no estaba en el DTO, así que se habría borrado antes de llegar al servicio.
forbidNonWhitelisted: true sube la apuesta: en lugar de borrar en silencio, devuelve un 400 diciendo qué propiedad sobra. Lo prefiero, porque el silencio de whitelist a secas te oculta que el frontend lleva tres sprints mandando un campo muerto.
transform: true convierte el JSON plano en una instancia real de la clase. Sin esto recibes un objeto literal con la forma correcta, no un CreateUserDto: si tu DTO tiene métodos o getters, no existen.
stopAtFirstError viene en false por defecto en class-validator, y así lo dejo: quiero devolver todos los errores del formulario de una vez, no obligar al cliente a hacer seis viajes.
Y luego está forbidUnknownValues, que merece su propia sección porque es la trampa gorda.
La trampa: forbidUnknownValues no vale lo que crees
La documentación de class-validator es tajante: forbidUnknownValues vale true por defecto y recomienda no tocarlo, porque desactivarlo hace que objetos desconocidos pasen la validación.
Ahora mira el constructor del ValidationPipe de Nest:
// packages/common/pipes/validation.pipe.ts
this.validatorOptions = { forbidUnknownValues: false, ...validatorOptions };
Nest lo pone a false salvo que tú lo pidas explícitamente. Es una decisión deliberada de compatibilidad hacia atrás (viene del issue 10683), no un despiste. Pero el efecto práctico es que la opción que class-validator considera crítica está desactivada por defecto en tu API de Nest.
Muerde cuando el pipe valida un objeto del que class-validator no tiene metadatos: un DTO sin decoradores, uno que olvidaste importar bien, una clase generada dinámicamente. Con false eso pasa limpiamente. Con true falla y te enteras.
Ponlo a true explícitamente y pasa tu suite después. Si algo se rompe, es que algo no se estaba validando.
Objetos anidados: @ValidateNested sin @Type no valida nada
@ValidateNested() solo valida un objeto anidado si va acompañado de @Type(() => Clase). Sin @Type, class-validator no sabe en qué clase instanciar el valor, no encuentra metadatos y deja pasar el objeto entero. Es la segunda trampa, y la he visto en más proyectos que la anterior.
// src/orders/dto/create-order.dto.ts
import { Type } from 39;class-transformer39;;
import {
ArrayMinSize,
IsArray,
IsInt,
IsNotEmpty,
IsString,
Matches,
Min,
ValidateNested,
} from 39;class-validator39;;
export class AddressDto {
@IsString()
@IsNotEmpty()
street: string;
@IsString()
@IsNotEmpty()
city: string;
@Matches(/^\d{5}$/, { message: 39;El código postal debe tener 5 dígitos39; })
zipCode: string;
}
export class OrderItemDto {
@IsString()
@IsNotEmpty()
sku: string;
@IsInt()
@Min(1)
quantity: number;
}
export class CreateOrderDto {
@IsString()
@IsNotEmpty()
customerId: string;
@ValidateNested()
@Type(() => AddressDto)
shippingAddress: AddressDto;
@IsArray()
@ArrayMinSize(1)
@ValidateNested({ each: true })
@Type(() => OrderItemDto)
items: OrderItemDto[];
}
@Type() viene de class-transformer, no de class-validator, y es la que le dice en qué clase instanciar el objeto anidado.
Si la quitas, el anidado se queda como objeto plano, @ValidateNested() no encuentra metadatos asociados a ese valor y no comprueba nada. La request pasa y shippingAddress llega a tu servicio con lo que sea que mandaran.
@ValidateNested sin @Type es decoración. Van siempre en pareja. Y en arrays, { each: true } es obligatorio o solo validas el array como un todo.
Update sin duplicar el DTO
No copies y pegues el DTO de create para poner todo opcional.
// src/users/dto/update-user.dto.ts
import { OmitType, PartialType } from 39;@nestjs/mapped-types39;;
import { CreateUserDto } from 39;./create-user.dto39;;
export class UpdateUserDto extends PartialType(
OmitType(CreateUserDto, [39;email39;] as const),
) {}
PartialType hace todas las propiedades opcionales manteniendo sus reglas. OmitType quita las que no deben poder cambiarse. Tienes también PickType e IntersectionType.
Aviso de la documentación oficial que cuesta caro ignorar: si usas @nestjs/swagger o @nestjs/graphql, importa los mapped types desde esos paquetes, no desde @nestjs/mapped-types. La doc oficial lo deja en "efectos secundarios varios y no documentados", sin concretar. En mi experiencia se manifiesta casi siempre como esquemas OpenAPI vacíos que nadie sabe explicar.
Query params y la conversión implícita
Los query params llegan siempre como string. Aquí es donde mucha gente activa enableImplicitConversion: true y se olvida del tema. Mala idea.
// src/users/dto/find-users-query.dto.ts
import { Transform, Type } from 39;class-transformer39;;
import { IsBoolean, IsInt, IsOptional, IsString, Max, Min } from 39;class-validator39;;
export class FindUsersQueryDto {
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page: number = 1;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(100)
limit: number = 20;
@IsOptional()
@Transform(({ value }) => value === 39;true39; || value === true)
@IsBoolean()
onlyActive: boolean = false;
@IsOptional()
@IsString()
search?: string;
}
onlyActive lo transformo a mano por una razón muy concreta.
Cuando activas enableImplicitConversion, class-transformer usa el tipo reflejado por TypeScript y aplica el constructor correspondiente. Para booleanos, el código es literalmente return Boolean(value).
Y Boolean('false') en JavaScript es true. Cualquier string no vacío lo es.
Es decir: ?onlyActive=false te activa el filtro. Tu API hace lo contrario de lo que pide el cliente, devuelve un 200 y no aparece un solo error en los logs. Lo he depurado dos veces y las dos me llevó más de una hora.
Deja enableImplicitConversion en false y sé explícito propiedad a propiedad con @Type() y @Transform(). Más verboso, y correcto.
Validadores custom: cuando el decorador no existe
Los decoradores integrados cubren tipo y formato. Las reglas de negocio no. Para eso escribes una clase que implementa ValidatorConstraintInterface.
// src/users/validators/is-email-available.validator.ts
import { Injectable } from 39;@nestjs/common39;;
import {
ValidationArguments,
ValidatorConstraint,
ValidatorConstraintInterface,
} from 39;class-validator39;;
import { UsersRepository } from 39;../users.repository39;;
@ValidatorConstraint({ name: 39;isEmailAvailable39;, async: true })
@Injectable()
export class IsEmailAvailableConstraint implements ValidatorConstraintInterface {
constructor(private readonly users: UsersRepository) {}
async validate(email: unknown): Promise<boolean> {
if (typeof email !== 39;string39;) return false;
const existing = await this.users.findByEmail(email.toLowerCase());
return existing === null;
}
defaultMessage(args: ValidationArguments): string {
return `El email ${args.value} ya está registrado`;
}
}
Lo enganchas al DTO con @Validate:
import { IsEmail, Validate } from 39;class-validator39;;
import { IsEmailAvailableConstraint } from 39;../validators/is-email-available.validator39;;
export class CreateUserDto {
@IsEmail()
@Validate(IsEmailAvailableConstraint)
email: string;
// ...resto de propiedades
}
Para que la inyección de dependencias funcione necesitas dos cosas. Primero, declarar el constraint como provider en su módulo. Segundo, decirle a class-validator que use el contenedor de Nest:
// src/main.ts
import { useContainer } from 39;class-validator39;;
async function bootstrap() {
const app = await NestFactory.create(AppModule);
useContainer(app.select(AppModule), { fallbackOnErrors: true });
// ...el resto del bootstrap: useGlobalPipes, listen
}
fallbackOnErrors: true no es opcional: sin él, Nest lanza una excepción en cuanto class-validator le pide al contenedor una clase que no está registrada como provider.
Una advertencia: consultar la base de datos durante la validación es best effort, no una garantía. Entre que el validador pregunta y el servicio inserta hay una ventana de carrera. El índice único de la tabla sigue siendo la fuente de verdad; el validador solo sirve para devolver un 400 legible en vez de un 500 con un error del driver.
A favor juega el orden del ciclo de vida de Nest: los pipes se ejecutan después de los guards. Cuando ese validador toca la base de datos, la request ya está autenticada.
Testea el validador como lógica pura
Un validador custom es una clase con una dependencia. No necesitas levantar un TestingModule.
// src/users/validators/is-email-available.validator.spec.ts
import { IsEmailAvailableConstraint } from 39;./is-email-available.validator39;;
describe(39;IsEmailAvailableConstraint39;, () => {
const usersRepository = { findByEmail: jest.fn() };
const constraint = new IsEmailAvailableConstraint(usersRepository as never);
beforeEach(() => jest.resetAllMocks());
it(39;acepta un email que no existe39;, async () => {
usersRepository.findByEmail.mockResolvedValue(null);
await expect(constraint.validate(39;nuevo@dominicode.com39;)).resolves.toBe(true);
});
it(39;rechaza un email ya registrado39;, async () => {
usersRepository.findByEmail.mockResolvedValue({ id: 39;139; });
await expect(constraint.validate(39;bezael@dominicode.com39;)).resolves.toBe(false);
});
it(39;normaliza a minúsculas antes de consultar39;, async () => {
usersRepository.findByEmail.mockResolvedValue(null);
await constraint.validate(39;Bezael@Dominicode.com39;);
expect(usersRepository.findByEmail).toHaveBeenCalledWith(39;bezael@dominicode.com39;);
});
});
Tres tests, cero infraestructura. Si tu validador necesita un módulo entero para poder testearse, tiene demasiada responsabilidad.
Cuándo NO usar class-validator
class-validator es la librería de decoradores (@IsEmail, @MinLength, @ValidateNested) sobre la que NestJS construye toda su validación de entrada. No es un paquete de Nest: es un proyecto independiente, y ahí está la parte incómoda.
class-validator va por la 0.15.1, publicada el 26 de febrero de 2026. El parón fuerte fue entre la 0.14.1 (enero de 2024) y la 0.14.2 (mayo de 2025): dieciséis meses sin release. Desde entonces ha recuperado ritmo — 0.14.3 en noviembre de 2025, 0.14.4 y 0.15.1 en febrero de 2026. Está vivo.
class-transformer es otra historia. Su última versión publicada es la 0.5.1, de noviembre de 2021. Casi cinco años sin release, y es la pieza de la que dependen @Type, @Transform, transform: true y toda la conversión implícita que acabamos de ver. No está roto, pero tampoco se está arreglando.
En paralelo, NestJS 12 salió el 27 de agosto de 2026 con soporte nativo de Standard Schema. Los decoradores de parámetro aceptan una opción schema, y hay un pipe nuevo para validarla:
// src/users/schemas/create-user.schema.ts
import { z } from 39;zod39;;
export const createUserSchema = z.strictObject({
email: z.email(),
password: z.string().min(12),
fullName: z.string().min(2).max(60),
age: z.number().int().min(18).optional(),
});
export type CreateUserDto = z.infer<typeof createUserSchema>;
// src/main.ts
import { StandardSchemaValidationPipe } from 39;@nestjs/common39;;
app.useGlobalPipes(new StandardSchemaValidationPipe());
// src/users/users.controller.ts
@Post()
create(@Body({ schema: createUserSchema }) body: CreateUserDto) {
return this.usersService.create(body);
}
Aquí CreateUserDto es un tipo inferido, no una clase. El esquema y el tipo son la misma cosa, así que no puedes desincronizarlos. Con decoradores son dos verdades separadas que mantienes a mano, y ahí es donde entran los bugs.
La comparación honesta:
| class-validator | Zod / Standard Schema | |
|---|---|---|
| Fuente de verdad | Tipo + decoradores (dos) | Esquema (una) |
| DI en validadores | Sí, vía useContainer |
No de serie |
Mapped types (PartialType) |
Sí | .partial(), .omit(), .pick() |
| OpenAPI | @nestjs/swagger maduro |
Nativo en v12, o nestjs-zod |
| Mantenimiento | Lento (class-transformer congelado) | Activo |
| Reutilizar en el frontend | No | Sí, mismo esquema |
Mi criterio, sin vender humo: si tu proyecto ya es class-based de arriba abajo (entidades TypeORM, Swagger, validadores con DI), class-validator sigue siendo el camino de menor fricción y no hay que migrarlo por moda.
Si empiezas hoy en Nest 12, si compartes contratos con un frontend TypeScript, o si validas salidas de un LLM —donde necesitas parsear, transformar y reintentar en el mismo sitio—, Zod gana con claridad. Si sigues en v11 y quieres esa ruta, nestjs-zod (5.5.0, julio de 2026) te da createZodDto, el pipe y la serialización de respuestas. Ojo: sus peer dependencies todavía declaran @nestjs/common ^10 || ^11, así que para v12 aún no es opción.
Sobre este tema escribí a fondo en validación en runtime con Zod y TypeScript, y si quieres dominar la librería entera —transformaciones, refinamientos, esquemas compuestos— la trabajo paso a paso en el curso de Zod para TypeScript.
Y si aún estás eligiendo framework, esta capa de validación es uno de los argumentos de más peso a favor de Nest frente a opciones más ligeras, como analicé en Hono vs NestJS vs Express y al mirar la alternativa más directa a Nest, ExpressoTS 4.0.
Checklist: revisa hoy tu validación en NestJS
Abre tu main.ts. Si ves new ValidationPipe() sin opciones, ya tienes trabajo para los próximos veinte minutos:
- Añade
whitelist: trueyforbidNonWhitelisted: true. - Pon
forbidUnknownValues: trueexplícitamente y ejecuta tu suite de tests. - Busca en el proyecto
@ValidateNestedy comprueba que cada uno tiene su@Type()al lado. - Si tienes
enableImplicitConversion: true, quítalo y haz explícitas las conversiones.
Después vete a tus servicios y borra los if (!dto.email) throw .... Esos guardias existen porque en algún momento nadie confió en la entrada. Cuando el contrato vive en el DTO, sobran: es el principio que desarrollo en programación defensiva en TypeScript, donde la mejor defensa es la que se aplica una vez, en el borde, y no en cada función.
Esa es la fortaleza silenciosa de NestJS. No es que valide. Es que, bien montado, te deja escribir servicios que asumen datos correctos porque lo son.
Si quieres verlo aplicado sobre un proyecto real, con la capa de validación, los tests y las decisiones de arquitectura completas, lo trabajamos en Dominicode Labs. Y si lo que te interesa es NestJS llevado al terreno de la IA, monté el streaming de respuestas en tiempo real con el Vercel AI SDK sobre esta misma base. En vídeo, subo NestJS y arquitectura backend cada semana en el canal de YouTube.
Preguntas frecuentes
¿Qué es un DTO en NestJS?
Un DTO (Data Transfer Object) en NestJS es una clase que describe la forma exacta del payload que un endpoint acepta: qué propiedades existen, de qué tipo son y qué reglas cumplen. Se declara con decoradores de class-validator y se usa como tipo del parámetro @Body(), @Query() o @Param() en el controlador.
Tiene que ser una class y no una interface: las interfaces desaparecen al compilar y en runtime no queda nada a lo que asociar los decoradores. Y no es solo documentación — con el ValidationPipe configurado, el DTO es lo que decide qué request entra y cuál se rechaza con un 400.
¿Puedo usar una interface en lugar de una clase para el DTO?
No, si quieres que se valide. Las interfaces desaparecen en la transpilación, así que en runtime no hay nada a lo que asociar los decoradores: Nest recibe Object como metatype y el ValidationPipe deja pasar el payload entero.
Si te molesta escribir clases, la alternativa real es la ruta de esquemas: con Zod y el StandardSchemaValidationPipe de Nest 12 el DTO sí puede ser un tipo inferido, porque la validación no depende de metadatos de runtime sino del esquema que pasas al decorador.
Tengo el ValidationPipe puesto y la validación en NestJS no salta. ¿Qué reviso?
Por orden. Que el DTO sea una clase y que el tipo del parámetro en el controlador sea exactamente esa clase, no any ni un union. Que emitDecoratorMetadata y experimentalDecorators estén a true en tu tsconfig.json, porque sin ellos no hay metadatos de tipo que leer.
Después, que el pipe esté registrado donde crees: si lo pusiste con APP_PIPE en un módulo de feature en lugar del root, solo aplica a ese ámbito. Y si lo que pasa es que un objeto entero se cuela sin validarse, mira forbidUnknownValues, que Nest fuerza a false cuando no lo declaras tú.
¿class-validator sigue mantenido en 2026?
Sí, aunque a ritmo irregular. La versión actual es la 0.15.1, del 26 de febrero de 2026, publicada justo un día después de la 0.14.4. El bache serio fueron los dieciséis meses entre la 0.14.1 (enero de 2024) y la 0.14.2 (mayo de 2025); desde ahí ha vuelto a publicar con regularidad. Sigue en 0.x diez años después de su primera versión, lo cual dice bastante sobre su compromiso de estabilidad de API.
El problema mayor es class-transformer, su dependencia inseparable: última versión 0.5.1, noviembre de 2021. Toda la lógica de @Type, @Transform y transform: true corre sobre un paquete que lleva casi cinco años sin release. No es motivo para migrar mañana, sí para tenerlo en cuenta al empezar un proyecto nuevo.
¿Dónde valido las reglas de negocio: en el DTO o en el servicio?
En el DTO va todo lo que es forma: tipos, formatos, longitudes, rangos, campos requeridos, estructura de los objetos anidados. Son reglas que se responden mirando solo el payload.
En el servicio va todo lo que necesita contexto: si este usuario puede hacer esta operación, si el stock alcanza, si el pedido está en un estado que admite ese cambio. La prueba rápida es preguntarte si la regla depende de quién hace la petición o del estado actual del sistema. Si depende, no es validación de entrada, es lógica de dominio, y meterla en un decorador te va a complicar los tests.
¿Merece la pena migrar un proyecto grande de class-validator a Zod?
Rara vez de golpe, y casi nunca por el argumento de que "está más moderno". Rehacer DTOs, mapped types, validadores con inyección de dependencias y la integración con Swagger son semanas de trabajo sin una sola feature nueva para el usuario.
Lo que sí funciona es la convivencia. Nest 12 mantiene el flujo de class-validator plenamente soportado junto al de Standard Schema, así que escribes con Zod lo nuevo y dejas lo existente como está. Se migra por presión real —un bug de sincronía entre tipo y validación, un esquema que necesitas compartir con el frontend— y no por calendario.
Por Bezael Pérez — Developer senior con más de 15 años de experiencia y fundador de Dominicode.
¿Te resultó útil este artículo?
Compártelo con tu comunidad y ayuda a otros desarrolladores.
