NestJS/Mongoose: serialization does not exclude properties in plain output

I've started to play with NestJS, migrating from my old express/mongoose project and immediately crashed into a fence, just following MongoDB/serializations chapters from NestJS docs. I've prepared following schema

/////// schema
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
import * as mongoose from 'mongoose';
import { Exclude, Expose } from 'class-transformer';

export type UserDocument = User & mongoose.Document;

@Schema()
export class User {
    @Prop()
    @Exclude()
    _id: String

    @Expose()
    get id(): String { return this._id ? `${this._id}` : undefined }

    @Prop()
    name: string

    @Prop({ unique: true })
    login: string

    @Exclude()
    @Prop()
    password: string        
}

export const UserSchema = SchemaFactory.createForClass(User);

registered it in app.module

MongooseModule.forRoot('mongodb://localhost/old_project'), 
MongooseModule.forFeature([ { name: User.name, schema: UserSchema } ]),

and tried following calls, expecting no password property revealed in results

/////// controller
  @UseInterceptors(ClassSerializerInterceptor)
  @Get('default')
  async default(): Promise<User> {
    let u = new User();
    u.name = 'Kos';
    u.password = "secret";
    u.login = '[email protected]'

    return u;
  }
  
  // returns
  // {"name":"Kos","login":"[email protected]"}

  @Get('first_raw')
  async firstRaw(): Promise<User> {
    return this.userModel.findOne()
  }
  
  @Get('first_lean')
  async firstLean(): Promise<User> {
    return this.userModel.findOne().lean()
  }
  
  //both return
  // {"_id":"5f8731a36fc003421db08921","name":"Kos","login":"kos","password":"secret","__v":0}

  @UseInterceptors(ClassSerializerInterceptor)
  @Get('first_raw_stripped')
  async firstRawStripped(): Promise<User> {
    return this.userModel.findOne()
  }
  
  //returns
  // {"$__":{"strictMode":true,"selected":{},"getters":{},"_id":"5f8731a36fc003421db08921","wasPopulated":false,"activePaths":{"paths":{"_id":"init","name":"init","login":"init","password":"init","__v":"init"},"states":{"ignore":{},"default":{},"init":{"_id":true,"name":true,"login":true,"password":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,"defaults":true}},"isNew":false,"$locals":{},"$op":null,"_doc":{"_id":"5f8731a36fc003421db08921","name":"Kos","login":"kos","password":"secret","__v":0},"$init":true}

  @UseInterceptors(ClassSerializerInterceptor)
  @Get('first_lean_stripped')
  async firstLeanStripped(): Promise<User> {
    return this.userModel.findOne().lean()
  }
  
  //returns
  // {"_id":"5f8731a36fc003421db08921","name":"Kos","login":"kos","password":"secret","__v":0}

Finally I've found that only manual instantiation of User class does somehow what it should do, so I've added constructor to User class

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

and then it finally returned what was expected - no password prop in result

  @UseInterceptors(ClassSerializerInterceptor)
  @Get('first')
  async first(): Promise<User> {
    return new User(await this.userModel.findOne().lean());
  }
  
  //finally returns what's expected
  // {"name":"Kos","login":"kos","__v":0,"id":"5f8731a36fc003421db08921"}

Am I missing something? Somehow it seems a bit overwhelming...

UPDATE: it is either question about NestJS mongoose and serialization coupling - why this

  @UseInterceptors(ClassSerializerInterceptor)
  @Get('first')
  async first(): Promise<User> {
    return await this.userModel.findOne().lean();
  }

doesn't work and this

  @UseInterceptors(ClassSerializerInterceptor)
  @Get('first')
  async first(): Promise<User> {
    return new User(await this.userModel.findOne().lean());
  }

works (which also means for each result enumerable map with entity creations required)


After spending several hours finally I found a solution which was described in this post

The Mongoose library that we use for connecting to MongoDB and fetching entities does not return instances of our User class. Therefore, the ClassSerializerInterceptor won’t work out of the box.

First: create a interceptor for mongoose serialization:

mongooseClassSerializer.interceptor.ts

import {
  ClassSerializerInterceptor,
  PlainLiteralObject,
  Type,
} from '@nestjs/common';
import { ClassTransformOptions, plainToClass } from 'class-transformer';
import { Document } from 'mongoose';
 
function MongooseClassSerializerInterceptor(
  classToIntercept: Type,
): typeof ClassSerializerInterceptor {
  return class Interceptor extends ClassSerializerInterceptor {
    private changePlainObjectToClass(document: PlainLiteralObject) {
      if (!(document instanceof Document)) {
        return document;
      }
 
      return plainToClass(classToIntercept, document.toJSON());
    }
 
    private prepareResponse(
      response: PlainLiteralObject | PlainLiteralObject[],
    ) {
      if (Array.isArray(response)) {
        return response.map(this.changePlainObjectToClass);
      }
 
      return this.changePlainObjectToClass(response);
    }
 
    serialize(
      response: PlainLiteralObject | PlainLiteralObject[],
      options: ClassTransformOptions,
    ) {
      return super.serialize(this.prepareResponse(response), options);
    }
  };
}
 
export default MongooseClassSerializerInterceptor;

update your controller to apply this interceptor:

@UseInterceptors(MongooseClassSerializerInterceptor(User))

and your model(schema) should look like this:

import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
import { Document } from 'mongoose';
import { Exclude, Transform } from 'class-transformer';
 
export type UserDocument = User & Document;
 
@Schema()
export class User {
  @Transform(({ value }) => value.toString())
  _id: string;
 
  @Prop({ unique: true })
  email: string;
 
  @Prop()
  name: string;
 
  @Prop()
  @Exclude()
  password: string;
}
 
export const UserSchema = SchemaFactory.createForClass(User);

As explained by @Ali Sherafat, unfortunately solution didn't worked for me.

The Mongoose library that we use for connecting to MongoDB and fetching entities does not return instances of our User class. Therefore, the ClassSerializerInterceptor won’t work out of the box.

Definitely we would be requiring interceptor for mongoose serialization. So, I came up with one more similar solution with modifications.

Create interceptor for mongoose serialization as:

import {
    CallHandler,
    ExecutionContext,
    NestInterceptor,
    UseInterceptors,
} from '@nestjs/common';
import { plainToClass } from 'class-transformer';
import { map, Observable } from 'rxjs';

interface ClassConstructor {
    new ( ...args: any[ ] ): { };
}

export function MongooseClassSerializerInterceptor( dto: any ) {
    return UseInterceptors( new SerializeInterceptor( dto ) );
}

export class SerializeInterceptor implements NestInterceptor {
    constructor( private dto: any ) { }
    intercept( context: ExecutionContext, handler: CallHandler ): Observable< any > {

        return handler.handle( ).pipe(
            map( ( data: any ) => { 
                return plainToClass( this.dto, data, { 
                    excludeExtraneousValues: true
                } )
            } )
        )
    }
}

Create user dto as, this way you can use it for different role. So, for normal user we can expose required things:

import { Expose } from "class-transformer";

    export class UserDto {
    
        @Expose( )
        id: number;
    
        @Expose( )
        name: string;

        @Expose( )
        login: string;
    
    }

Now in your controller use @MongooseClassSerializerInterceptor( UserDto )

Using exclude in schema is not very flexible when want to return response based on some role, e.g in required case admin may have access to more fields than normal user or vice-versa. In that case this is better approach.