How to serialize a nest js response with class-transformer while getting data with Typegoose?

Viewed 7790

I have been trying to work through the NestJs example for the Serialization Section for Mongodb using Typegoose using the class-transformer library. The example given at https://docs.nestjs.com/techniques/serialization only shows how to use serialization in TypeORM. I followed the same process for Typegoose. Here is what I have tried so far.

// cat.domain.ts

import { prop } from '@typegoose/typegoose';

export class Cat {
  @prop()
  name: string;

  @prop()
  age: number;

  @prop()
  breed: string;
}


// cats.service.ts

@Injectable()
export class CatsService {
  constructor(
    @InjectModel(Cat) private readonly catModel: ReturnModelType<typeof Cat>,
  ) {}

  findAll(): Observable<Cat[]> {
    return from(this.catModel.find().exec());
  }

  findOne(id: string): Observable<Cat> {
    return from(this.catModel.findById(id).exec());
  }
  ...
}

// cat.response.ts

import { ObjectId } from 'mongodb';
import { Exclude, Transform } from 'class-transformer';

export class CatResponse {
  @Transform(value => value.toString(), { toPlainOnly: true })
  _id?: ObjectId;

  name: string;

  age: number;

  @Exclude()
  breed: string;

  constructor(partial: Partial<CatResponse>) {
    Object.assign(this, partial);
  }
}

// cats.controller.ts

@Controller('cats')
@UseInterceptors(ClassSerializerInterceptor)
export class CatsController {
  constructor(private readonly catsService: CatsService) {}

  @Get()
  findAll(): Observable<CatResponse[]> {
    return this.catsService.findAll();
  }

  @Get(':id')
  findOne(@Param() params: FindOneParamsDto): Observable<CatResponse> {
    return this.catsService.findOne(params.id);
  }
  ...
}

I tried running the API call on Get() with id but instead of the breed being excluded from the response I have been getting the following response.

{
    "$__": {
        "strictMode": true,
        "selected": {},
        "getters": {},
        "_id": {
            "_bsontype": "ObjectID",
            "id": {
                "type": "Buffer",
                "data": [
                    94,
                    93,
                    76,
                    66,
                    116,
                    204,
                    248,
                    112,
                    147,
                    216,
                    167,
                    205
                ]
            }
        },
        "wasPopulated": false,
        "activePaths": {
            "paths": {
                "_id": "init",
                "name": "init",
                "age": "init",
                "breed": "init",
                "__v": "init"
            },
            "states": {
                "ignore": {},
                "default": {},
                "init": {
                    "_id": true,
                    "name": true,
                    "age": true,
                    "breed": true,
                    "__v": true
                },
                "modify": {},
                "require": {}
            },
            "stateNames": [
                "require",
                "modify",
                "init",
                "default",
                "ignore"
            ]
        },
        "pathsToScopes": {},
        "cachedRequired": {},
        "$setCalled": [],
        "emitter": {
            "_events": {},
            "_eventsCount": 0,
            "_maxListeners": 0
        },
        "$options": {
            "skipId": true,
            "isNew": false,
            "willInit": true
        }
    },
    "isNew": false,
    "_doc": {
        "_id": {
            "_bsontype": "ObjectID",
            "id": {
                "type": "Buffer",
                "data": [
                    94,
                    93,
                    76,
                    66,
                    116,
                    204,
                    248,
                    112,
                    147,
                    216,
                    167,
                    205
                ]
            }
        },
        "name": "Sylver",
        "age": 14,
        "breed": "Persian Cat",
        "__v": 0
    },
    "$locals": {},
    "$op": null,
    "$init": true
}

Can anyone help me with how to serialize response properly?

3 Answers

Here is a workaround:

// cats.controller.ts
...
import { classToPlain } from "class-transformer";
...

@Controller('cats')
@UseInterceptors(ClassSerializerInterceptor)
export class CatsController {
  constructor(private readonly catsService: CatsService) {}

  @Get()
  findAll(): Observable<CatResponse[]> {
    const cats = this.catsService.findAll();
    // transforming the Model to CatResponse class...
    const catResponses = cats.map(cat => classToPlain(new CatResponse(cat.toJSON())))
    return catResponses;
  }

  @Get(':id')
  findOne(@Param() params: FindOneParamsDto): Observable<CatResponse> {
    const cat = this.catsService.findOne(params.id);
    const catResponse = classToPlain(new CatResponse(cat.toJSON()));
    return 
  }
  ...
}

Hope it could help.

For people trying to follow nestjs documentation & using mongoose but ClassSerializerInterceptor not working.

Posting a solution for using class-transformer withe mongoose below which can be helpful for others, it uses custom interceptor that you can see in the nest documentation. https://docs.nestjs.com/interceptors

How the folder structure would be:  
  
src
└── cats
    ├── dto
    │   └── cats-response.dto.ts
    ├── interceptor
    │   └── cats.interceptor.ts
    ├── schemas
    │   └── cat.schema.ts
    ├── cats.controller.ts
    └── cats.service.ts
  1. We will create a dto for cat response called CatsResponseDto in cats-response.dto.ts & in it exclude the breed property from response using Exclude() decorator. By using Dto for response we will create the instance of CatsResponseDto

  2. We will create custom interceptor for cats response called CatsInterceptor in cats.interceptor.ts. You can generate it using nest cli, the command is nest g interceptor cats

cats-response.dto.ts
Create CatsResponseDto to be used in our custom interceptor.

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

export class CatsResponseDto {

  @Expose()
  name: string;

  @Expose()
  age: number;

  // Exclude decorator to exclude it from our response.
  @Exclude()
  breed: string;

}
  

cats.interceptor.ts
Create out custom CatsInterceptor
Note: plainToClass has been deprecated & now called plainToInstance, I only got to know when used it on my Ide and it prompted. The official documentation yet not updated. https://github.com/typestack/class-transformer#plaintoclass

The changelog does mention about it.
https://github.com/typestack/class-transformer/blob/develop/CHANGELOG.md#041-breaking-change---2021-11-20

import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
import { plainToInstance } from 'class-transformer'
import { map, Observable } from 'rxjs';
import { CatsResponseDto } from '../dto/cats-response.dto'

@Injectable()
export class CatsInterceptor implements NestInterceptor {
  intercept(context: ExecutionContext, handler: CallHandler): Observable<any> {
    
    return handler.handle().pipe(
      map((data: any) => {
        
        // run something before the response is sent out.
        // Please note that plainToClass is deprecated & is now called plainToInstance
        
        return plainToInstance(CatsResponseDto, data, {
        
        // By using excludeExtraneousValues we are ensuring that only properties decorated with Expose() decorator are included in response.
          
        excludeExtraneousValues: true,

        })
      })
    );

  }
}

Our cat schema must have been defined like below (for reference) in cat.schema.ts or similar as per mongoose documentation.

cat.schema.ts

import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
import { Document } from 'mongoose';

export type CatDocument = Cat & Document;

@Schema({ timestamps: true })
export class Cat {

  // no Id defined here as its automatically added by mongoose unless we explicitly provide option to turn it OFF in schema options.  
    
  @Prop({ required: true })
  name: string;

  @Prop({ required: true })
  age: number;

  @Prop({ required: true })
  breed: string;

}

export const CatSchema = SchemaFactory.createForClass(Cat);

Now bind our custom interceptor CatsInterceptor in cats.controller.ts

cats.controller.ts

import { Cat } from './schemas/cat.schema';
import { CatsInterceptor } from './interceptor/cats.interceptor';
import { CatsService } from './cats.service.ts'

@Controller('cats')
export class CatsController {
  constructor(private readonly catsService: CatsService) {}

  @Get()
  findAll(): Promise<Cat[]> {
    return this.catsService.findAll();
  }

  @UseInterceptors(CatsInterceptor)
  @Get(':id')
  findOne(@Param() params: FindOneParamsDto): Promise<Cat> {
    return this.catsService.findOne(params.id);
  }
  ...
}

RESULT: when calling /cats/{id} response would exclude breed.

related issue: class serialization not working in nestjs

Related