🚨 ¡Nueva review! 🔇 Los mejores cascos con ANC del mercado: los Sony WH-1000XM4 . ¡Échale un ojo! 👀

Serialización y Transformación de datos en NestJS

Serie NestJS #15 : ClassSerializerInterceptor, @Exclude, @Expose, @Transform, @Type, groups y plainToInstance

Escrito por domin el 13 de abril de 2026

Vamos con el post número 15 de la serie NestJS y tercero del bloque de Funcionalidades Avanzadas. En el post 13 vimos Exception Filters y en el post 14 dominamos los Interceptors y hoy vamos a ver qué datos salen y como de nuestra preciosa API.

La entidad User tiene un campo password y también podría tener refreshTokenHash, internalNotes, deletedAt. ¿Queremos que eso llegue al cliente? Pues claro que no. Podrías hacer delete user.password en cada endpoint, pero eso no escala y es una castaña para mantenerlo. La serialización lo resuelve de forma declarativa y automática.

Por si quieres revisar algo de los anteriores post: Docker (post 1), controllers (post 2), DI (post 3), módulos (post 4), middleware y pipeline (post 5), validación (post 6), PostgreSQL con TypeORM (posts 7-9), seguridad (posts 10-12), Exception Filters (post 13) e Interceptors (post 14).

EA, al lío.

Entidad de NestJS pasando por un filtro que excluye campos sensibles antes de la respuesta.

1. ¿Qué datos salen de tu API?

Imagina esta petición:

// GET /users/uuid-123
const user = await this.usersRepository.findOne({ where: { id } });
return user;

Lo que devuelve la API:

{
    "id": "uuid-123",
    "name": "Domin",
    "email": "domin@domin.es",
    "password": "$2b$10$hasheado...",
    "refreshTokenHash": "$2b$10$otro-hash...",
    "role": "admin",
    "active": true,
    "createdAt": "2026-03-21T10:00:00.000Z",
    "updatedAt": "2026-03-21T10:00:00.000Z",
    "deletedAt": null
}

El password hasheado, el refresh token hash, la fecha de borrado… todo expuesto, una fokin locura masiva bro.

Soluciones que no escalan y por lo tanto, mal:

// ❌ Manual en cada endpoint: fácil olvidarlo
const { password, refreshTokenHash, ...safeUser } = user;
return safeUser;

// ❌ select: false en la entidad: se olvida en queries con select explícito
@Column({ select: false })
password: string;

La solución correcta es usar la .


2. class-transformer: la base de todo

La serialización en NestJS se apoya en , la misma librería que ya usamos con ValidationPipe en el post 6, así que ya está instalada.

class-transformer funciona en dos direcciones:

📥 Deserialización (entrada)

JSON -> instancia de clase. Lo hace el ValidationPipe con class-transformer. Convierte el body de la request en una instancia del DTO.

📤 Serialización (salida)

Instancia de clase -> JSON. Lo hace ClassSerializerInterceptor con class-transformer. Convierte la entidad en la respuesta JSON.


3. ClassSerializerInterceptor: activando la serialización

Es un Interceptor built-in de NestJS que aplica las transformaciones de class-transformer a la respuesta:

// src/app.module.ts
import { APP_INTERCEPTOR } from '@nestjs/core';
import { ClassSerializerInterceptor } from '@nestjs/common';

@Module({
    providers: [
        {
            provide: APP_INTERCEPTOR,
            useClass: ClassSerializerInterceptor,
        },
    ],
})
export class AppModule {}

Ahora todos los endpoints aplican serialización automáticamente. Los decoradores que pongas en las entidades y DTOs controlan qué sale.


4. @Exclude: ocultando campos sensibles

Este decorador muy usado, marca una propiedad para que NO se incluya en la respuesta JSON:

// src/users/entities/user.entity.ts
import { Entity, Column, PrimaryGeneratedColumn, CreateDateColumn, UpdateDateColumn, DeleteDateColumn } from 'typeorm';
import { Exclude } from 'class-transformer';
import { UserRole } from '../enums/user-role.enum';

@Entity('users')
export class User {
    @PrimaryGeneratedColumn('uuid')
    id: string;

    @Column({ type: 'varchar', length: 100 })
    name: string;

    @Column({ type: 'varchar', length: 255, unique: true })
    email: string;

    @Column({ type: 'varchar', length: 255 })
    @Exclude() // ← Nunca sale en la respuesta
    password: string;

    @Column({ type: 'varchar', length: 255, nullable: true })
    @Exclude() // ← Nunca sale en la respuesta
    refreshTokenHash: string | null;

    @Column({ type: 'enum', enum: UserRole, default: UserRole.USER })
    role: UserRole;

    @Column({ type: 'boolean', default: true })
    active: boolean;

    @CreateDateColumn()
    createdAt: Date;

    @UpdateDateColumn()
    updatedAt: Date;

    @DeleteDateColumn()
    @Exclude() // ← No necesita saberlo el cliente
    deletedAt: Date | null;
}

Ahora GET /users/uuid-123 devuelve:

{
    "id": "uuid-123",
    "name": "Domin",
    "email": "domin@domin.es",
    "role": "admin",
    "active": true,
    "createdAt": "2026-03-21T10:00:00.000Z",
    "updatedAt": "2026-03-21T10:00:00.000Z"
}

Sin password, sin refreshTokenHash, sin deletedAt. Son excluídos automáticamente en todos los endpoints que devuelvan un User.

Importante: @Exclude() funciona con ClassSerializerInterceptor. Sin el interceptor, el decorador no hace nada y viceversa, el interceptor sin decoradores no modifica nada.


5. @Expose: whitelisting de propiedades

@Exclude excluye propiedades individuales (blacklist). @Expose hace lo contrario y solo incluye las propiedades marcadas (whitelist). Para esto, necesitas @Exclude() a nivel de clase:

import { Exclude, Expose } from 'class-transformer';

@Exclude() // Excluir TODO por defecto
@Entity('users')
export class UserPublicView {
    @Expose()
    id: string;

    @Expose()
    name: string;

    @Expose()
    email: string;

    // role, active, createdAt, updatedAt... nada de esto se incluye
    // porque @Exclude() a nivel de clase excluye todo lo no marcado con @Expose()
}
@Exclude en propiedades (blacklist)
  • Tienes pocas propiedades sensibles y muchas públicas
  • La mayoría de campos de la entidad son seguros para el cliente
  • No quieres repetir @Expose() en 15 propiedades
@Exclude en clase + @Expose (whitelist)
  • Tienes muchas propiedades internas y pocas públicas
  • Cada nueva columna que añadas debería ser privada por defecto
  • Máxima seguridad: nada sale a menos que lo marques explícitamente

Lo recomendable es usar @Exclude() en propiedades individuales (blacklist). Es más práctico para la mayoría de entidades, así si olvidas marcar un campo nuevo, sale en la respuesta pero no es un riesgo de seguridad (salvo que sea sensible). Con whitelist, si olvidas @Expose(), el campo no sale y puede romper el frontend.


6. @Transform: transformando valores

@Transform te permite modificar un valor antes de que salga en la respuesta:

import { Transform } from 'class-transformer';

@Entity('users')
export class User {
    // ...

    @Column({ type: 'varchar', length: 100 })
    @Transform(({ value }) => (value as string).toUpperCase())
    name: string;
    // "domin" → "DOMIN"

    @CreateDateColumn()
    @Transform(({ value }) => (value as Date).toISOString().split('T')[0])
    createdAt: Date;
    // "2026-03-21T10:00:00.000Z" → "2026-03-21"
}

Casos de uso comunes para @Transform

// Redondear a 2 decimales
@Column({ type: 'decimal', precision: 10, scale: 4 })
@Transform(({ value }) => parseFloat(Number(value).toFixed(2)))
price: number;
// 29.9999 → 30.00

// URL completa desde un path relativo
@Column({ type: 'varchar' })
@Transform(({ value }) => value ? `https://cdn.example.com/${value}` : null)
avatarPath: string | null;
// "avatars/domin.jpg" → "https://cdn.example.com/avatars/domin.jpg"

// Booleano a texto legible
@Column({ type: 'boolean', default: true })
@Transform(({ value }) => (value as boolean) ? 'activo' : 'inactivo')
active: boolean;
// true → "activo"

Ten en cuenta que @Transform modifica la salida serializada, no el valor en la entidad. La entidad sigue teniendo el valor original en la base de datos.


7. @Type: relaciones y objetos anidados

Cuando tienes relaciones entre entidades, class-transformer necesita saber el tipo de la propiedad anidada para aplicar sus decoradores. Para eso existe @Type:

// src/posts/entities/post.entity.ts
import { Entity, Column, ManyToOne, PrimaryGeneratedColumn } from 'typeorm';
import { Type } from 'class-transformer';
import { User } from '../../users/entities/user.entity';

@Entity('posts')
export class Post {
    @PrimaryGeneratedColumn('uuid')
    id: string;

    @Column({ type: 'varchar', length: 255 })
    title: string;

    @Column({ type: 'text' })
    content: string;

    @ManyToOne(() => User, (user) => user.posts)
    @Type(() => User) // ← class-transformer aplica los decoradores de User
    author: User;
}

Sin @Type(() => User), cuando serialices un Post con su author, las propiedades @Exclude() del User no se aplican y el password sale en la respuesta. Con @Type, class-transformer sabe que author es un User y aplica sus @Exclude().

Respuesta sin @Type:

{
    "id": "post-uuid",
    "title": "Mi post",
    "author": {
        "id": "user-uuid",
        "name": "Domin",
        "password": "$2b$10$EXPUESTO!!"
    }
}

Respuesta con @Type(() => User):

{
    "id": "post-uuid",
    "title": "Mi post",
    "author": {
        "id": "user-uuid",
        "name": "Domin",
        "email": "domin@domin.es",
        "role": "user"
    }
}

El password se excluye correctamente porque @Type le dice a class-transformer que aplique las reglas del User.

Arrays de relaciones

// src/users/entities/user.entity.ts
@OneToMany(() => Post, (post) => post.author)
@Type(() => Post) // También necesario para arrays
posts: Post[];

@Type funciona igual para relaciones @OneToMany, @ManyToMany, o cualquier propiedad que sea un objeto o array de objetos.


8. Groups: diferentes vistas según el contexto

A veces necesitas devolver más o menos campos según quién pida los datos. Un admin ve más campos que un usuario normal. Los grupos de serialización resuelven esto:

// src/users/entities/user.entity.ts
import { Exclude, Expose } from 'class-transformer';

@Entity('users')
export class User {
    @PrimaryGeneratedColumn('uuid')
    id: string;

    @Column({ type: 'varchar', length: 100 })
    name: string;

    @Column({ type: 'varchar', length: 255, unique: true })
    email: string;

    @Column({ type: 'varchar', length: 255 })
    @Exclude()
    password: string;

    @Column({ type: 'enum', enum: UserRole, default: UserRole.USER })
    role: UserRole;

    @Column({ type: 'boolean', default: true })
    @Expose({ groups: ['admin'] }) // Solo visible para el grupo 'admin'
    active: boolean;

    @CreateDateColumn()
    createdAt: Date;

    @UpdateDateColumn()
    @Expose({ groups: ['admin'] }) // Solo visible para el grupo 'admin'
    updatedAt: Date;

    @Column({ type: 'varchar', length: 255, nullable: true })
    @Exclude()
    refreshTokenHash: string | null;

    @DeleteDateColumn()
    @Exclude()
    deletedAt: Date | null;

    @Column({ type: 'varchar', nullable: true })
    @Expose({ groups: ['admin', 'owner'] }) // Solo para admin o el propio usuario
    phoneNumber: string | null;
}

Aplicando los groups en el controller

import { Controller, Get, SerializeOptions, Param, ParseUUIDPipe } from '@nestjs/common';
import { Roles } from '../auth/decorators/roles.decorator';
import { UserRole } from './enums/user-role.enum';

@Controller('users')
export class UsersController {
    constructor(private readonly usersService: UsersService) {}

    // Vista pública: solo campos sin grupo
    @Get()
    findAll(): Promise<User[]> {
        return this.usersService.findAll();
    }

    // Vista admin: campos sin grupo + grupo 'admin'
    @Get('admin/list')
    @Roles(UserRole.ADMIN)
    @SerializeOptions({ groups: ['admin'] })
    findAllAdmin(): Promise<User[]> {
        return this.usersService.findAll();
    }

    // Vista del propio usuario: campos sin grupo + grupo 'owner'
    @Get('me')
    @SerializeOptions({ groups: ['owner'] })
    getMe(@GetUser('sub') userId: string): Promise<User> {
        return this.usersService.findOne(userId);
    }
}

Resultado para cada vista:

GET /users (público):
 id, name, email, role, createdAt

GET /users/admin/list (admin):
 id, name, email, role, active, createdAt, updatedAt, phoneNumber

GET /users/me (owner):
 id, name, email, role, createdAt, phoneNumber

El mismo endpoint, el mismo service, la misma entidad, solo cambia el decorador @SerializeOptions.

¿Cómo funciona @SerializeOptions? Es un decorador de NestJS que se comunica con ClassSerializerInterceptor. Le pasa las opciones de serialización (groups, strategy, etc.) al interceptor, que las aplica cuando transforma la respuesta.


9. Response DTOs: la alternativa a decorar entidades

Decorar las entidades con @Exclude, @Expose y @Transform funciona, pero tiene un problema que es mezclar lógica de la base de datos (TypeORM) con lógica de presentación (class-transformer) en la misma clase.

La alternativa es crear Response DTOs separados:

// src/users/dto/user-response.dto.ts
import { Exclude, Expose, Transform, Type } from 'class-transformer';
import { type UserRole } from '../enums/user-role.enum';

export class UserResponseDto {
    @Expose()
    id: string;

    @Expose()
    name: string;

    @Expose()
    email: string;

    @Expose()
    role: UserRole;

    @Expose()
    @Transform(({ value }) => (value as Date).toISOString().split('T')[0])
    createdAt: string;
}
// src/users/dto/user-admin-response.dto.ts
import { Expose, Transform } from 'class-transformer';
import { type UserRole } from '../enums/user-role.enum';

export class UserAdminResponseDto {
    @Expose()
    id: string;

    @Expose()
    name: string;

    @Expose()
    email: string;

    @Expose()
    role: UserRole;

    @Expose()
    active: boolean;

    @Expose()
    phoneNumber: string | null;

    @Expose()
    createdAt: Date;

    @Expose()
    updatedAt: Date;
}

Usando plainToInstance para transformar

// src/users/users.service.ts
import { plainToInstance } from 'class-transformer';
import { UserResponseDto } from './dto/user-response.dto';
import { UserAdminResponseDto } from './dto/user-admin-response.dto';

@Injectable()
export class UsersService {
    async findAllPublic(): Promise<UserResponseDto[]> {
        const users = await this.usersRepository.find();
        return plainToInstance(UserResponseDto, users, {
            excludeExtraneousValues: true,
        });
    }

    async findAllAdmin(): Promise<UserAdminResponseDto[]> {
        const users = await this.usersRepository.find();
        return plainToInstance(UserAdminResponseDto, users, {
            excludeExtraneousValues: true,
        });
    }
}

convierte los objetos planos de TypeORM en instancias del DTO, aplicando todos los decoradores.

La opción excludeExtraneousValues: true es importante porque solo incluye las propiedades con @Expose(). Todo lo demás se descarta sin necesidad de @Exclude() explícito.

¿Entidad decorada o Response DTO separado?

EnfoqueVentajasDesventajas
Decorar la entidadMenos archivos, menos código, automático con ClassSerializerInterceptorMezcla concerns (DB + presentación), difícil tener múltiples vistas
Response DTOsSeparación de concerns, fácil tener múltiples vistas, explícitoMás archivos, necesitas plainToInstance, más código

Lo suyo es comenzar decorando las entidades con @Exclude() en los campos sensibles. Es lo más práctico para el 80% de los casos. Cuando necesites múltiples vistas de la misma entidad, crea Response DTOs y no te vengas arriba desde el principio, mejor ir corrigiendo.


10. Serialización de arrays y relaciones

Arrays

ClassSerializerInterceptor maneja arrays automáticamente. Si tu handler devuelve User[], cada elemento del array se serializa con las reglas del User:

@Get()
findAll(): Promise<User[]> {
    return this.usersService.findAll();
    // Cada User del array se serializa: sin password, sin refreshTokenHash
}

Relaciones anidadas

Con @Type() en la entidad (sección 7), las relaciones se serializan recursivamente:

@Get(':id')
findOne(@Param('id', ParseUUIDPipe) id: string): Promise<Post> {
    return this.postsService.findOneWithAuthor(id);
    // Post se serializa, y el author anidado también se serializa
    // con las reglas de User (@Exclude en password, etc.)
}

Relaciones circulares

Si User tiene posts: Post[] y Post tiene author: User, la serialización puede entrar en un loop infinito. Solución:

@Entity('users')
export class User {
    // ...

    @OneToMany(() => Post, (post) => post.author)
    @Type(() => Post)
    @Exclude() // Excluir por defecto, incluir solo cuando sea necesario
    posts: Post[];
}

O usa @Transform para serializar solo los IDs:

@OneToMany(() => Post, (post) => post.author)
@Transform(({ value }) =>
    (value as Post[])?.map((post) => ({ id: post.id, title: post.title })) ?? [],
)
posts: Post[];

11. @Transform avanzado: acceso al objeto completo

El callback de @Transform recibe un objeto con varias propiedades útiles:

@Transform(({ value, obj, key, type }) => {
    // value → el valor de esta propiedad
    // obj   → el objeto completo que se está serializando
    // key   → el nombre de la propiedad ('fullName')
    // type  → TransformationType (0 = plain→class, 1 = class→plain)
    return value;
})

Caso real con un campo virtual calculado a partir de otros campos:

@Entity('users')
export class User {
    @Column({ type: 'varchar', length: 50 })
    firstName: string;

    @Column({ type: 'varchar', length: 50 })
    lastName: string;

    // Propiedad virtual que no existe en la DB
    @Expose()
    @Transform(({ obj }) => `${obj.firstName} ${obj.lastName}`)
    get fullName(): string {
        return `${this.firstName} ${this.lastName}`;
    }
}

Otro caso con formateo condicional:

@Column({ type: 'decimal', precision: 10, scale: 2 })
@Transform(({ value, obj }) => {
    const amount = parseFloat(value as string);
    const currency = (obj as { currency: string }).currency;
    return `${amount.toFixed(2)} ${currency}`;
})
price: number;
// 29.99 → "29.99 EUR"

12. Combinando serialización con el TransformResponseInterceptor

En el post 14 creamos un TransformResponseInterceptor que envuelve la respuesta en { data, statusCode, timestamp }. Funciona perfectamente con ClassSerializerInterceptor:

Handler devuelve User ClassSerializer excluye password TransformResponse envuelve en { data }

El orden de los interceptors importa, así que ClassSerializerInterceptor debe ejecutarse antes (en el after) que TransformResponseInterceptor:

// src/app.module.ts
@Module({
    providers: [
        // ClassSerializer PRIMERO en el registro = se aplica en el after DESPUÉS
        // (los interceptors after van en orden inverso)
        {
            provide: APP_INTERCEPTOR,
            useClass: TransformResponseInterceptor,
        },
        {
            provide: APP_INTERCEPTOR,
            useClass: ClassSerializerInterceptor,
        },
    ],
})
export class AppModule {}

Resultado:

{
    "data": {
        "id": "uuid-123",
        "name": "Domin",
        "email": "domin@domin.es",
        "role": "admin",
        "createdAt": "2026-03-21"
    },
    "statusCode": 200,
    "timestamp": "2026-03-21T10:30:00.000Z"
}

El password no está, la fecha está formateada, y todo envuelto en { data }.


13. Opciones globales de serialización

Puedes configurar opciones por defecto para toda la aplicación:

// src/main.ts
import { ClassSerializerInterceptor, ValidationPipe } from '@nestjs/common';
import { Reflector } from '@nestjs/core';

async function bootstrap(): Promise<void> {
    const app = await NestFactory.create<NestExpressApplication>(AppModule);
    const reflector = app.get(Reflector);

    app.useGlobalInterceptors(
        new ClassSerializerInterceptor(reflector, {
            strategy: 'excludeAll', // Excluir todo por defecto
            excludeExtraneousValues: true, // Solo incluir @Expose()
            enableImplicitConversion: true, // Conversión automática de tipos
        })
    );

    await app.listen(3000);
}

strategy: 'excludeAll' es agresivo: todas las propiedades sin @Expose() se excluyen. Útil si prefieres whitelist, pero requiere @Expose() en cada campo que quieras mostrar. Para la mayoría de proyectos, es mejor la estrategia por defecto (incluir todo) con @Exclude() en los campos sensibles.


14. Errores comunes

Error 1: Olvidar @Type() en relaciones

// ❌ El author se serializa como objeto plano, los @Exclude() de User no se aplican
@ManyToOne(() => User)
author: User;
// password del author sale en la respuesta

// ✅ @Type le dice a class-transformer qué clase es
@ManyToOne(() => User)
@Type(() => User)
author: User;
// password del author se excluye correctamente

Error 2: Usar @Exclude() sin ClassSerializerInterceptor

// ❌ @Exclude() en la entidad pero sin ClassSerializerInterceptor registrado
@Column()
@Exclude()
password: string;
// El decorador está ahí pero nadie lo lee → password sale en la respuesta

ClassSerializerInterceptor debe estar registrado globalmente como APP_INTERCEPTOR o con useGlobalInterceptors.

Error 3: plainToInstance sin excludeExtraneousValues

// ❌ Sin la opción, plainToInstance incluye TODAS las propiedades del objeto plano
const dto = plainToInstance(UserResponseDto, user);
// password sigue ahí aunque UserResponseDto no lo declare

// ✅ Con la opción, solo incluye las propiedades con @Expose()
const dto = plainToInstance(UserResponseDto, user, {
    excludeExtraneousValues: true,
});

Error 4: @Transform en la entidad que rompe TypeORM

// ❌ @Transform cambia el tipo del valor, TypeORM no puede guardar un string en una columna Date
@Column({ type: 'timestamp' })
@Transform(({ value }) => (value as Date).toISOString().split('T')[0])
createdAt: Date;
// Cuando TypeORM intenta guardar, createdAt es "2026-03-21" (string) en vez de Date

// ✅ Solución: @Transform solo aplica en serialización (class→plain, toPlainOnly)
@Column({ type: 'timestamp' })
@Transform(({ value }) => (value as Date).toISOString().split('T')[0], {
    toPlainOnly: true,  // Solo al serializar, no al deserializar
})
createdAt: Date;

La opción toPlainOnly: true hace que el @Transform solo se aplique cuando se convierte de clase a JSON (serialización), no cuando se convierte de JSON a clase (cuando TypeORM carga la entidad).


15. Recapitulando

🔄 ClassSerializerInterceptor

Interceptor global que aplica class-transformer a las respuestas. Una línea en AppModule y funciona automáticamente.

🚫 @Exclude / @Expose

@Exclude() en propiedades sensibles (blacklist). @Exclude() en clase + @Expose() en públicas (whitelist). Dos estrategias.

🔧 @Transform

Modifica valores: formatear fechas, redondear precios, construir URLs, campos calculados. toPlainOnly para no romper TypeORM.

🏷️ @Type

Indica el tipo de relaciones anidadas. Sin @Type, los @Exclude de la relación no se aplican. Crítico para seguridad.

👥 Groups

@Expose({ groups: ["admin"] }) + @SerializeOptions({ groups: ["admin"] }). Diferentes vistas de la misma entidad.

📋 plainToInstance

Transforma objetos planos en instancias con decoradores aplicados. Con excludeExtraneousValues: true para Response DTOs.

En el próximo post nos metemos con File Upload y Streaming: Multer con NestJS, FileInterceptor, @UploadedFile, validación de archivos, almacenamiento y StreamableFile. Esto ya será lo último del bloque de funcionalidades avanzadas.

EA, nos vemos en los bares!! 🍺


Pon a prueba lo aprendido

1. ¿Qué pasa si pones @Exclude() en una propiedad de la entidad pero no registras ClassSerializerInterceptor?

2. ¿Para qué sirve @Type(() => User) en una relación?

3. ¿Cómo consigues que un @Transform solo se aplique al serializar (salida) y no al deserializar (entrada)?

4. ¿Qué hace la opción excludeExtraneousValues: true en plainToInstance?

5. ¿Cuál es la diferencia principal entre @SerializeOptions({ groups: ['admin'] }) y crear un Response DTO separado?