Serialization
Decide once, on the class, what leaves the server: exclude the microchip, expose a computed label, flatten the owner to a name, and apply the same rules to plain objects with SerializeOptions.
What you'll learn
- Register ClassSerializerInterceptor for the whole application and explain what instanceToPlain does to a handler's return value
- Shape a response with @Exclude, @Expose on a getter and @Transform, restricted to the way out with toPlainOnly
- Serialize plain objects by giving a route the class to treat them as, with @SerializeOptions({ type })
A cat in the shelter's records has a microchip number. Anyone may list the cats, and nobody outside the shelter should see that number. The obvious fix is to delete the field before returning, in every handler that returns a cat, forever. The better fix is to say once, on the class, what leaves the server and how, and let Nest apply it to every response. That is serialization: the step between "the value a handler returned" and "the JSON on the wire", and ClassSerializerInterceptor is where Nest puts it.
An interceptor that runs instanceToPlain
class-transformer, the library ValidationPipe uses to build DTO instances, also converts the other way. instanceToPlain(cat) turns a class instance into a plain object, obeying decorators on the class. ClassSerializerInterceptor calls it on whatever a handler returns:
@UseInterceptors(ClassSerializerInterceptor)
@Get()
findOne(): UserEntity {
return new UserEntity({ id: 1, firstName: 'John', lastName: 'Doe', password: 'password' });
}
The rules live on the class:
export class UserEntity {
id: number;
firstName: string;
lastName: string;
@Exclude()
password: string;
constructor(partial: Partial<UserEntity>) {
Object.assign(this, partial);
}
}
The response has no password. Three decorators cover most needs:
@Exclude()removes a property.@Expose()on a getter includes a computed property that would otherwise be invisible, because getters are not own properties:@Expose() get fullName() { return \${this.firstName} ${this.lastName}`; }`.@Transform(({ value }) => ...)replaces a value:@Transform(({ value }) => value.name) role: RoleEntityflattens a nested object to one field. A transform runs in both directions unless told otherwise, andplainToInstanceis the other direction;{ toPlainOnly: true }keeps it to the way out.
Register the interceptor once for the whole application and every route is covered:
@Module({
providers: [{ provide: APP_INTERCEPTOR, useClass: ClassSerializerInterceptor }],
})
export class AppModule {}
It only works on instances
instanceToPlain reads decorators from the object's class. A plain object literal has no class and passes through untouched, secrets and all. This is the trap in every serialization bug report: the handler returned { ...cat } or a row from a query builder, and @Exclude() did nothing. Two rules follow. Services return new Cat({ ... }), and a constructor that takes a partial keeps that painless. And where the data genuinely arrives plain, tell the interceptor what class to treat it as:
@SerializeOptions({ type: UserEntity })
@Get()
findOne(): UserEntity {
return { id: 1, firstName: 'John', lastName: 'Doe', password: 'password' };
}
@SerializeOptions() takes the same options as instanceToPlain: excludePrefixes: ['_'] drops every property starting with an underscore, groups: ['admin'] selects properties exposed for that group, strategy: 'excludeAll' flips the default so only @Expose()d properties leave.
Where it sits
An interceptor wraps the handler. Serialization is the part that runs after it, on the way back: middleware → guards → interceptors → pipes → handler → interceptors → filters. That is also why it does not touch exceptions: a thrown NotFoundException skips the handler's return path and goes to the filters as it always did.
Your task
The cats API records a microchip number and an owner with an email address; neither leaves the server.
- In
cats/cat.entity.ts, excludemicrochip, expose the computedlabel, and transformowner, on the way out only, so that only the owner's name is sent (nullwhen there is no owner). - Register
ClassSerializerInterceptorfor the whole application inAppModule. CatsService.exportAll()returns plain objects from the shelter's legacy export. GiveGET /cats/exportthe class those objects should be treated as, so the same rules apply.
Run after step 2 alone and read GET /cats: the interceptor is there, the class has no rules yet, and the microchip is still in the response. Rules and interceptor are both needed.
When it fails
microchipstill appears everywhere: the interceptor is not registered, or the service returns object literals rather thannew Cat(...).labelis missing: a getter is not an own property; it needs@Expose().owneris an object withemail:@Transformis missing, or its function returns the object instead ofvalue.name.GET /cats/exportshows microchips whileGET /catshides them: the export returns plain objects;@SerializeOptions({ type: Cat })on that route tells the interceptor which class they are.- With
typeset,ownervanishes from the export but not fromGET /cats: the interceptor first builds aCatfrom the plain object withplainToInstance, and the transform ran there too, turning the owner into a name; on the way out it ran again on that name and gotundefined. Restrict it with{ toPlainOnly: true }.
Remember
ClassSerializerInterceptorrunsinstanceToPlain()on every handler's return value; register it underAPP_INTERCEPTORto cover the application.@Exclude()hides,@Expose()on a getter shows a computed value,@Transform()rewrites a value on the way out.- Only instances are serialized; return
new Cat(...)or set@SerializeOptions({ type })for plain data. - Serialization happens after the handler and never touches exceptions.
Stuck? Show a hint
cat.entity.ts: @Exclude() on microchip; @Expose() on the label getter; @Transform(({ value }) => (value ? value.name : null), { toPlainOnly: true }) on owner. app.module.ts: providers: [{ provide: APP_INTERCEPTOR, useClass: ClassSerializerInterceptor }] with APP_INTERCEPTOR from @nestjs/core. Controller: @SerializeOptions({ type: Cat }) on the export route.