Tu API de NestJS rechaza una petición PATCH o PUT con un 400 que nombra una propiedad concreta:
{
"message": ["property categoriaId should not exist"],
"error": "Bad Request",
"statusCode": 400
}
Pasa en aplicaciones que registran el ValidationPipe de forma global con estas opciones:
app.useGlobalPipes(
new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true }),
);
Aparece, por ejemplo, después de que el backend cambie un campo escalar por un arreglo, cuando el formulario del frontend reenvía campos deshabilitados o bloqueados, o cuando el servidor sigue ejecutando código antiguo. Este artículo explica cada caso y cómo distinguirlos por el nombre de la propiedad del mensaje.
Por qué pasa
La documentación de NestJS describe las dos opciones: con whitelist: true, el validador elimina del objeto las propiedades que no usan ningún decorador de validación; con forbidNonWhitelisted: true, en lugar de eliminarlas lanza una excepción. El resultado es que cualquier propiedad del cuerpo que el DTO no declare, o que declare sin ningún decorador de class-validator, produce un 400 con el mensaje property <nombre> should not exist.
El nombre de la propiedad indica la causa probable:
- Un nombre antiguo (
categoriaId): el backend lo renombró acategoriaIdsy el frontend sigue enviando el anterior. - Un campo de solo lectura (
id,createdAt, un campo bloqueado): el formulario envía el objeto completo y el DTO no los acepta. - Un campo que sí existe en tu DTO actual: el servidor que responde no ejecuta tu código nuevo. Lo más habitual es un proceso antiguo que quedó en marcha con un
dist/compilado antes del cambio. - Una propiedad declarada sin decorador: está en la clase, pero para el validador no existe.
Solución
-
Lee el nombre de la propiedad y decide de qué lado está el fallo. Si es un nombre antiguo o un campo de solo lectura, el error está en el payload del cliente. Si es un campo que acabas de añadir al DTO, revisa el decorador (paso 3) y que el servidor ejecute el código nuevo (paso 4).
-
Alinea el payload con el DTO. Envía solo los campos declarados, traduce los renombrados y omite los bloqueados. Para un PATCH, además, omite la clave cuando el valor no cambió:
function mismosIds(a: number[], b: number[]): boolean { const x = [...a].sort(); const y = [...b].sort(); return x.length === y.length && x.every((v, i) => v === y[i]); } // FormValues es el tipo de los valores de tu formulario function construirPayload(form: FormValues, original: FormValues) { const payload: { nombre?: string; categoriaIds?: number[] } = {}; if (form.nombre !== original.nombre) payload.nombre = form.nombre; if (!mismosIds(form.categoriaIds, original.categoriaIds)) { payload.categoriaIds = form.categoriaIds; } return payload; // las claves sin cambios no viajan } await api.patch(`/items/${id}`, construirPayload(form, original));Hay un detalle con los arreglos: en algunos servicios, el arreglo recibido se interpreta como el conjunto completo de relaciones. Un
[]enviado por descuido puede significar "quitar todo". Si no hubo cambios, no envíes la clave, y asegúrate de que el formulario conserva los valores cargados antes de enviarlos. -
Declara cada propiedad con al menos un decorador. Para el DTO de actualización, deriva del de creación con
PartialType(paquete@nestjs/mapped-types), que según la documentación deriva la variante sin repetir las propiedades ni sus decoradores de validación:import { PartialType } from '@nestjs/mapped-types'; import { IsArray, IsInt, IsNotEmpty, IsString } from 'class-validator'; export class CreateItemDto { @IsString() @IsNotEmpty() nombre: string; @IsArray() @IsInt({ each: true }) categoriaIds: number[]; } export class UpdateItemDto extends PartialType(CreateItemDto) {}Si una propiedad no necesita ninguna validación concreta, class-validator ofrece
@Allow()para que no se elimine ni se rechace. -
Si el cambio no se aplica, reinicia el proceso correcto. En un proyecto generado con Nest CLI,
start:prodejecutanode dist/main, es decir, el código ya compilado. No recoge cambios desrc/hasta que ejecutesnest build. Detén el proceso antiguo, compila y vuelve a iniciar. En Windows:netstat -ano | findstr :3000 taskkill /PID 12345 /T /F npm run build npm run start:prodEn Linux o macOS:
lsof -i :3000 kill 12345 npm run build npm run start:prodSustituye
3000por tu puerto y12345por el PID de la última columna denetstat(la opción-olo añade) o delsof. Entaskkill,/Ttermina también los procesos hijos y/Ffuerza el cierre. Si al iniciar la versión nueva ves un error comoError: listen EADDRINUSE: address already in use :::3000, otro proceso sigue ocupando el puerto: Node lo describe como un intento de enlazar un servidor a una dirección que otro servidor ya ocupa.
Cómo verificar que quedó resuelto
Prueba la petición con los campos correctos (en PowerShell usa curl.exe):
curl -i -X PATCH http://localhost:3000/items/1 \
-H "Content-Type: application/json" \
-d '{"nombre":"prueba","categoriaIds":[1,2]}'
Debe responder 200. Después consulta el recurso con un GET y comprueba que el selector queda con los valores marcados y que las relaciones previas se conservaron. Como control, envía un campo inventado:
curl -i -X PATCH http://localhost:3000/items/1 \
-H "Content-Type: application/json" \
-d '{"campoInventado":1}'
Debe responder 400 con property campoInventado should not exist: confirma que el pipe sigue activo y que el servidor carga el DTO nuevo. También puedes cubrirlo con una prueba unitaria del pipe, sin levantar la aplicación:
const pipe = new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true });
const meta = { type: 'body' as const, metatype: UpdateItemDto };
it('acepta los campos declarados', async () => {
await expect(pipe.transform({ nombre: 'x', categoriaIds: [1] }, meta)).resolves.toBeDefined();
});
it('rechaza un campo no declarado', async () => {
const error = await pipe.transform({ campoViejo: 1 }, meta).catch((e) => e);
expect(error.getResponse()).toMatchObject({ message: ['property campoViejo should not exist'] });
});
Variantes y errores relacionados
Solo whitelist: true
Sin forbidNonWhitelisted, las propiedades desconocidas se eliminan en silencio. El PATCH responde 200, pero el campo con el nombre antiguo no se guarda y nadie se entera. Si prefieres detectar estos desajustes pronto, mantén forbidNonWhitelisted activado.
No aparece el detalle del error
La opción disableErrorMessages: true del ValidationPipe hace que la respuesta no incluya los mensajes detallados. Si ves un 400 sin el nombre de la propiedad, revisa esa opción.
Las pruebas e2e pasan pero producción falla
La configuración de app.useGlobalPipes(...) suele estar en main.ts. Una prueba e2e que crea la aplicación desde el módulo de pruebas no la ejecuta, así que no reproducirá el 400 salvo que registres el mismo pipe en la prueba.
El DTO de actualización no deriva del de creación
Si UpdateItemDto es una clase independiente, cada campo nuevo que añadas a CreateItemDto será rechazado en el PATCH hasta que lo declares también ahí. Derivarlo con PartialType evita esa divergencia.
Resumen
- Con
whitelistyforbidNonWhitelisted, toda propiedad sin decorador de validación en el DTO produceproperty X should not exist. - El nombre de la propiedad orienta el diagnóstico: nombre antiguo o campo de solo lectura (frontend), o campo nuevo (decorador o proceso antiguo).
- Envía solo los campos declarados y omite la clave de un arreglo cuando no cambió: un
[]puede significar "borrar todo". - Si los cambios del backend no se aplican, detén el proceso antiguo, ejecuta
nest buildy vuelve a iniciar. - Verifica con un
PATCHválido (200) y uno con un campo inventado (400).
Comentarios
Publicar un comentario