SerializationTechniques · NestJS

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 will learn

Read the theory for Serialization

All Techniques lessons

All NestJS courses

loading types…

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 })

Serialization

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: RoleEntity flattens a nested object to one field. A transform runs in both directions unless told otherwise, and plainToInstance is 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.

  1. In cats/cat.entity.ts, exclude microchip, expose the computed label, and transform owner, on the way out only, so that only the owner's name is sent (null when there is no owner).
  2. Register ClassSerializerInterceptor for the whole application in AppModule.
  3. CatsService.exportAll() returns plain objects from the shelter's legacy export. Give GET /cats/export the 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

  • microchip still appears everywhere: the interceptor is not registered, or the service returns object literals rather than new Cat(...).
  • label is missing: a getter is not an own property; it needs @Expose().
  • owner is an object with email: @Transform is missing, or its function returns the object instead of value.name.
  • GET /cats/export shows microchips while GET /cats hides them: the export returns plain objects; @SerializeOptions({ type: Cat }) on that route tells the interceptor which class they are.
  • With type set, owner vanishes from the export but not from GET /cats: the interceptor first builds a Cat from the plain object with plainToInstance, and the transform ran there too, turning the owner into a name; on the way out it ran again on that name and got undefined. Restrict it with { toPlainOnly: true }.

Remember

  • ClassSerializerInterceptor runs instanceToPlain() on every handler's return value; register it under APP_INTERCEPTOR to 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.