Strip secrets, rename fields, and expose role-specific views of the same entity. NestJS hands the response to
class-transformerviaClassSerializerInterceptor, which callsinstanceToPlainon whatever the controller returned.
When to reach for it
You hit this the first time a User entity leaks password or passwordHash in an API response. Other common cases:
- Rename internal column names (
emailAddressin DB,emailover the wire). - Hide admin-only fields (
internalNotes,auditLog) from non-admin callers. - Compute derived fields (
fullNamefromfirstName+lastName) without polluting the entity. - Format dates / numbers consistently across every endpoint.
Setup
npm i class-transformer reflect-metadataclass-transformer is a peer dep of @nestjs/common’s serializer. reflect-metadata is already required by Nest itself.
Wire up the interceptor
Bind it globally so every endpoint runs through the serializer. No more @UseInterceptors(ClassSerializerInterceptor) per controller.
import { ClassSerializerInterceptor, Module } from '@nestjs/common';
import { APP_INTERCEPTOR, Reflector } from '@nestjs/core';
@Module({
providers: [
{
provide: APP_INTERCEPTOR,
useFactory: (reflector: Reflector) =>
new ClassSerializerInterceptor(reflector),
inject: [Reflector],
},
],
})
export class AppModule {}Why the factory: ClassSerializerInterceptor needs Reflector to read @SerializeOptions() metadata. The shorthand { provide: APP_INTERCEPTOR, useClass: ClassSerializerInterceptor } works too because Nest resolves the constructor deps automatically; the factory form is explicit and survives if you ever wrap or extend the interceptor.
The decorators
Decorate the entity / DTO class with class-transformer decorators. The interceptor reads them and rewrites the response.
All examples below assume the global
ClassSerializerInterceptorfrom the previous section is wired up. Without it, the decorators are inert.
@Exclude()
Drops the field from the response. Apply per-property or at class level.
import { Controller, Get, Param } from '@nestjs/common';
import { Exclude } from 'class-transformer';
export class UserEntity {
id: number;
email: string;
@Exclude() password: string;
@Exclude() passwordResetToken: string;
constructor(partial: Partial<UserEntity>) {
Object.assign(this, partial);
}
}
@Controller('users')
export class UsersController {
@Get(':id')
findOne(@Param('id') id: string): UserEntity {
// The handler still sees password/passwordResetToken on the instance.
return new UserEntity({
id: Number(id),
email: 'a@b.c',
password: 'hunter2',
passwordResetToken: 'abc123',
});
}
}GET /users/1 returns:
{ "id": 1, "email": "a@b.c" }password and passwordResetToken are stripped on the way out by the globally-bound ClassSerializerInterceptor. The handler still has access to them inside the controller — the stripping happens after return.
@Expose() and excludeAll strategy
Flip the default: hide everything, then opt fields in. Safer for accidental leaks when you add a new column to the entity.
import { Controller, Get, Param } from '@nestjs/common';
import { Exclude, Expose, plainToInstance } from 'class-transformer';
@Exclude()
export class UserDto {
@Expose() id: number;
@Expose() email: string;
passwordHash: string; // not @Expose()'d, so excluded
internalNote: string; // same
}
@Controller('users')
export class UsersController {
@Get(':id')
findOne(@Param('id') id: string): UserDto {
// Pretend this came from the database.
const row = {
id: Number(id),
email: 'a@b.c',
passwordHash: '$2b$...',
internalNote: 'flagged for review',
};
return plainToInstance(UserDto, row);
}
}GET /users/1 returns:
{ "id": 1, "email": "a@b.c" }Adding a new column to the entity now defaults to hidden until someone explicitly @Expose()s it. That’s the strategy you want for any DTO that wraps a sensitive entity.
@Transform()
Reshape a value on the way out: format dates, mask digits, derive fields.
import { Controller, Get, Param } from '@nestjs/common';
import { plainToInstance, Transform } from 'class-transformer';
export class UserDto {
id: number;
@Transform(({ value }) => value.toISOString())
createdAt: Date;
@Transform(({ value }) => `${value.slice(0, 2)}***${value.slice(-2)}`)
apiKey: string;
}
@Controller('users')
export class UsersController {
@Get(':id')
findOne(@Param('id') id: string): UserDto {
return plainToInstance(UserDto, {
id: Number(id),
createdAt: new Date('2025-01-15T09:30:00Z'),
apiKey: 'sk_live_abcdef1234567890',
});
}
}GET /users/1 returns:
{
"id": 1,
"createdAt": "2025-01-15T09:30:00.000Z",
"apiKey": "sk***90"
}The value argument is the raw property; the function returns whatever should appear in the JSON.
The class-instance gotcha
Those decorators only fire when the controller returns a class instance. Return a plain object and the interceptor silently skips it — every field leaks.
import { Controller, Get } from '@nestjs/common';
import { Exclude } from 'class-transformer';
export class UserEntity {
id: number;
email: string;
@Exclude() password: string;
constructor(partial: Partial<UserEntity>) {
Object.assign(this, partial);
}
}
@Controller('users')
export class UsersController {
@Get('plain')
leaks() {
return { id: 1, email: 'a@b.c', password: 'secret' };
}
@Get('instance')
safe(): UserEntity {
return new UserEntity({ id: 1, email: 'a@b.c', password: 'secret' });
}
}GET /users/plain returns:
{ "id": 1, "email": "a@b.c", "password": "secret" }GET /users/instance returns:
{ "id": 1, "email": "a@b.c" }Same data, different type, very different blast radius.
How your ORM affects this
The handler is free to use every field of a class instance — user.password, hash comparisons, audit logs all work. The stripping happens after return, when the interceptor calls instanceToPlain(user). The trap: not every ORM gives you class instances.
| Source | What you get back | @Exclude() works? | Fix |
|---|---|---|---|
TypeORM repository.findOne(...) with @Entity() | Real UserEntity instance | ✅ | None needed |
Prisma prisma.user.findUnique(...) | Plain object (generated TS type) | ❌ | return plainToInstance(UserEntity, user) |
Mongoose .lean() | Plain object | ❌ | return plainToInstance(UserEntity, doc) |
Raw SQL via dataSource.query(...) | Plain object | ❌ | return plainToInstance(UserEntity, row) |
fetch() / external HTTP call | Plain object | ❌ | return plainToInstance(UserEntity, body) |
Mental check: if console.log(returned instanceof UserEntity) would print false right before return, serialization is silently skipped. Wrap with plainToInstance(UserEntity, raw) (or new UserEntity(raw) if your constructor copies fields).
For arrays, map: return rows.map((r) => plainToInstance(UserEntity, r)).
Role-based views with groups
@Expose({ groups: [...] }) plus @SerializeOptions({ groups: [...] }) on the route gives you per-role response shapes from a single entity.
import { ClassSerializerInterceptor, Controller, Get, SerializeOptions, UseInterceptors } from '@nestjs/common';
import { Exclude, Expose } from 'class-transformer';
export class UserEntity {
@Expose() id: number;
@Expose() email: string;
@Expose({ groups: ['admin'] }) role: string;
@Expose({ groups: ['admin'] }) lastLoginIp: string;
@Exclude() password: string;
}
@Controller('users')
@UseInterceptors(ClassSerializerInterceptor)
export class UsersController {
@Get('me')
@SerializeOptions({ groups: ['user'] })
me(): UserEntity {
return Object.assign(new UserEntity(), { id: 1, email: 'a@b.c', role: 'admin', lastLoginIp: '1.2.3.4', password: 'secret' });
}
@Get('admin')
@SerializeOptions({ groups: ['admin'] })
asAdmin(): UserEntity {
return Object.assign(new UserEntity(), { id: 1, email: 'a@b.c', role: 'admin', lastLoginIp: '1.2.3.4', password: 'secret' });
}
}/users/me returns:
{ "id": 1, "email": "a@b.c" }/users/admin returns:
{
"id": 1,
"email": "a@b.c",
"role": "admin",
"lastLoginIp": "1.2.3.4"
}Same entity, two payloads, zero conditional code in the controller.
Gotchas
- Plain objects skip the interceptor. The most common bug. Always return
new Entity(...). - Nested objects need
@Type(). If a field is an array of another class (@Type(() => OrderItem) items: OrderItem[]),class-transformerneeds the explicit type to apply the right decorators recursively. reflect-metadataimport order. It must be imported once at the top ofmain.tsbefore any decorator runs. Nest’s CLI scaffolds this for you.@SerializeOptions()only works when the interceptor is bound. Setting it withoutClassSerializerInterceptorregistered does nothing.- DTOs vs entities. Mixing serialization decorators into a TypeORM/Prisma entity couples DB shape to API shape. For non-trivial APIs, map entities to dedicated DTOs and decorate the DTO instead.
See also
- Request validation with class-validator — the inbound twin: same
class-transformer/class-validatorpair, with the parallelgroupsmechanism for per-route rules. - Interceptors for the interceptor lifecycle and how
ClassSerializerInterceptorplugs into it. - Official serialization docs
class-transformerREADME