Curso de Backend con NestJS

Actualización de DTOs con mapped types en NestJS para perfil y usuario

Curso de Backend con NestJS

Contenido del curso

Fundamentos y Primer CRUD

Base de Datos y Persistencia con TypeORM

Actualización de DTOs con mapped types en NestJS para perfil y usuario

Resumen

Si trabajas con NestJS y manejas relaciones uno a uno entre entidades como usuario y perfil, seguramente te has topado con el dolor de duplicar DTOs para crear y actualizar. Aquí te muestro cómo resolverlo con Mapped Types, una utilidad oficial de NestJS que te permite reutilizar validaciones, mantener tu código limpio y propagar cambios en cascada sin escribir el doble.

¿Qué problema resuelven los Mapped Types en NestJS?

Cuando ya tienes un CreateUserDTO con validaciones estrictas (email, password, perfil anidado), crear un UpdateUserDTO desde cero implica copiar cada decorador y solo cambiarlos a opcionales. Eso es duplicación pura.

¿Qué es Mapped Types en NestJS? Es un paquete oficial que transforma DTOs existentes en variantes nuevas (parciales, omitidas, seleccionadas) reutilizando sus validaciones. Lo instalas con npm i @nestjs/mapped-types.

La documentación oficial lo ubica en techniques > validation > Mapped Types [01:05]. Tiene una advertencia sobre conflictos con Swagger y GraphQL, pero esos se resuelven después.

¿Cómo usar PartialType para crear un UpdateDTO sin duplicar código?

La idea es simple: en lugar de clonar manualmente, extiendes el DTO original. PartialType toma todos los campos requeridos del DTO base y los marca como opcionales conservando el resto de validaciones.

El flujo queda así:

  • Separas tus DTOs en una carpeta dtos/ con user.dto.ts y profile.dto.ts.
  • Defines CreateProfileDTO con sus validaciones (@IsNotEmpty, @IsURL para el avatar, etc.).
  • Exportas UpdateProfileDTO como PartialType(CreateProfileDTO).

Con eso, todos los campos del create se vuelven opcionales en el update sin reescribir un solo decorador. Si necesitas que algún campo siga siendo obligatorio en el update, lo sobrescribes y listo.

¿Cuándo conviene usar OmitType junto con PartialType?

Aquí aparece un caso interesante. Si tu CreateUserDTO tiene un perfil anidado validado contra CreateProfileDTO, al hacer PartialType(CreateUserDTO) heredas esa validación estricta del perfil. Resultado: cuando intentas actualizar solo el nombre dentro del perfil, NestJS te exige el lastName completo [12:30].

La solución elegante combina dos utilidades de Mapped Types:

  1. OmitType(CreateUserDTO, ['profile']) para sacar el perfil del DTO base.
  2. PartialType sobre ese resultado para volver opcionales email y password.
  3. Redefines profile con UpdateProfileDTO, que ya es parcial.

Así logras flexibilidad real: el cliente envía solo lo que quiere actualizar, sin inventarse campos ni cargar con validaciones que no aplican.

¿Por qué activar whitelist en el ValidationPipe?

Por defecto, NestJS ignora los campos que no están en el DTO. Eso es permisivo y puede esconder errores del cliente.

¿Qué hace whitelist en NestJS? Activa el modo estricto del ValidationPipe para que rechace cualquier propiedad que no esté declarada en el DTO. Se configura en el main.ts junto con forbidNonWhitelisted: true.

Con esa configuración, si alguien manda age en un update donde no existe ese campo, recibe un error claro: property age should not exist. Mejor feedback, menos bugs silenciosos.

¿Cómo manejar errores y relaciones en el update con TypeORM?

Aquí hay dos detalles que rompen muchas implementaciones reales.

¿Por qué tu try-catch no atrapa errores en save?

Si haces return this.userRepo.save(user) sin await, devuelves la promesa antes de que se resuelva. NestJS la ejecuta fuera del scope de tu try-catch, así que cualquier error termina como un 500.

La corrección es directa:

  • Usa const updated = await this.userRepo.save(user); dentro del try.
  • Devuelve updated al final.
  • En el catch, lanza una excepción controlada como bad request con un mensaje descriptivo [25:40].

¿Cómo evitar que el merge sobrescriba el perfil con undefined?

Cuando haces findOneBy({ id }) sin cargar la relación del perfil y luego haces merge, TypeORM completa el perfil con undefined. Al guardar, intenta poner lastName: null y choca con el constraint not null.

La fix es cambiar a findOne({ where: { id }, relations: ['profile'] }). Así el merge tiene los datos previos del perfil y solo reemplaza lo que viene en el payload.

¿Vale la pena cargar relaciones siempre o solo cuando se necesita?

Esta decisión es un trade-off clásico de ingeniería. Cargar la relación en findOne simplifica el código (lo reutilizas en update y en getById), pero también lo arrastra a operaciones donde no la necesitas, como un delete.

Dos enfoques válidos:

  • Pureza de código: dejas findOne con la relación y lo reutilizas en todos lados.
  • Performance: en delete haces un query directo con el id y evitas el join innecesario.

En bases de datos pequeñas o con tráfico moderado, optimizar ese join es overengineering. La diferencia se nota cuando hablas de millones de requests concurrentes en un data warehouse robusto. Para cientos o miles de peticiones, prioriza claridad.

¿Cómo exponer el perfil sin anidarlo siempre?

Si no quieres que getUser devuelva el perfil anidado por defecto, crea un endpoint dedicado. Por ejemplo, un método getProfileByUserId en el servicio que reutilice el findOne con relación y devuelva solo user.profile. Luego lo expones como GET /users/:id/profile en el controlador.

Tienes así dos estrategias conviviendo:

  • Endpoint anidado: una sola request trae usuario y perfil, ideal para tablas en frontend.
  • Endpoint específico: pides el perfil cuando lo necesitas, sin cargar relaciones innecesarias en otros lugares.

¿Cuál de las dos estrategias usarías tú en producción? Cuéntame en los comentarios cómo resolviste tu propio caso de DTOs anidados.