Nest.js Tutorial

Virtual properties with MongoDB and Mongoose

Marcin Wanago
JavaScriptMongoDBNestJSTypeScript

In this series, we’ve used Mongoose to define properties in our schemas and work with models for documents. We’ve also defined various relations between collections. With Mongoose, we can also take advantage of virtual properties that are not stored in MongoDB. To understand them, we first grasp the concept of getters and setters.

You can find the code from this article in this repository.

Getters and setters with Mongoose

We can execute custom logic when we get and set properties in a document with getters and setters.

Getters

By using getters, we can modify the data of a document when we retrieve it. Let’s create an example when the user has a credit card number that we want to obfuscate when responding to the API request.

user.schema.ts
1import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
2import { Document } from 'mongoose';
3 
4export type UserDocument = User & Document;
5 
6@Schema({
7  toJSON: {
8    getters: true,
9  },
10})
11export class User {
12  @Prop({ unique: true })
13  email: string;
14 
15  @Prop({
16    get: (creditCardNumber: string) => {
17      if (!creditCardNumber) {
18        return;
19      }
20      const lastFourDigits = creditCardNumber.slice(
21        creditCardNumber.length - 4,
22      );
23      return `****-****-****-${lastFourDigits}`;
24    },
25  })
26  creditCardNumber?: string;
27 
28  // ...
29}
30 
31export const UserSchema = SchemaFactory.createForClass(User);

When we return the documents from our API, NestJS stringifies our data. When that happens, the toJSON method is called on our Mongoose models. Therefore, if we want our getters to be considered, we need to add getters: true to our configuration explicitly.

Documents also have the toObject method and we can customize it in a similar way.

We also use toJSON in our MongooseClassSerializerInterceptor. For more details, check out API with NestJS #44. Implementing relationships with MongoDB

In our code above, we obfuscate the credit card number every time we return the user’s document from our API.

Mongoose assignes our schemas a virtual getter for the id field. It now appears in the response because we’ve turned on getters through getters: true. More on virtuals later.

There are times where we want to access the original, non-modified property. To do that, we can use the Document.prototype.get() function.

1const user = await this.usersService.getByEmail(email);
2const creditCardNumber = await this.usersService.getByEmail(email);

Setters

With setters, we can modify the data before saving it in the database.

post.schema.ts
1import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
2import { Document, ObjectId } from 'mongoose';
3import { Transform } from 'class-transformer';
4 
5export type PostDocument = Post & Document;
6 
7@Schema()
8export class Post {
9  @Transform(({ value }) => value.toString())
10  _id: ObjectId;
11 
12  @Prop()
13  title: string;
14 
15  @Prop({
16    set: (content: string) => {
17      return content.trim();
18    },
19  })
20  content: string;
21  
22  // ...
23}
24 
25export const PostSchema = SchemaFactory.createForClass(Post);

Thanks to doing the above, we now remove whitespace from both ends of the content string.

While setters are a valid technique, you might prefer to put this logic in the service for increased readability. However, even if that’s the case, setters are worth knowing.

Virtual properties

A virtual is a property that we can get and set, but it is not stored inside the database. Let’s define a simple example of a use case.

user.schema.ts
1import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
2import { Document } from 'mongoose';
3 
4export type UserDocument = User & Document;
5 
6@Schema()
7export class User {
8  @Prop()
9  firstName: string;
10 
11  @Prop()
12  lastName: string;
13 
14  @Prop()
15  fullName: string;
16 
17  // ...
18}
19 
20export const UserSchema = SchemaFactory.createForClass(User);

The above approach is flawed, unfortunately. If we persist the fullName property into MongoDB, we duplicate the information because we already have the firstName and lastName. A more appropriate approach would be to create the fullName on the fly based on other properties.

Getters

We can achieve the above with the virtual property. So, let’s create it along with a getter.

user.schema.ts
1import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
2import { Document } from 'mongoose';
3 
4export type UserDocument = User & Document;
5 
6@Schema({
7  toJSON: {
8    virtuals: true,
9  },
10})
11export class User {
12  @Prop()
13  firstName: string;
14 
15  @Prop()
16  lastName: string;
17 
18  fullName: string;
19  
20  // ...
21}
22 
23const UserSchema = SchemaFactory.createForClass(User);
24 
25UserSchema.virtual('fullName').get(function (this: UserDocument) {
26  return `${this.firstName} ${this.lastName}`;
27});
28 
29export { UserSchema };

Please notice that we don’t use the @Prop() decorator on the fullName property. Instead, we call the UserSchema.virtual function at the bottom of the file.

Thanks to adding virtuals: true, our virtual properties are visible when converting a document to JSON. Even though we can see fullName in the above response, it isn’t saved to the database.

Setters

With virtual, we can also create setters. We can use them, for example, to set multiple properties at once.

user.schema.ts
1import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
2import { Document, ObjectId } from 'mongoose';
3import { Transform } from 'class-transformer';
4export type UserDocument = User & Document;
5 
6@Schema({
7  toJSON: {
8    getters: true,
9    virtuals: true,
10  },
11})
12export class User {
13  @Prop()
14  firstName: string;
15 
16  @Prop()
17  lastName: string;
18 
19  fullName: string;
20 
21  // ...
22}
23 
24const UserSchema = SchemaFactory.createForClass(User);
25 
26UserSchema.virtual('fullName')
27  .get(function (this: UserDocument) {
28    return `${this.firstName} ${this.lastName}`;
29  })
30  .set(function (this: UserDocument, fullName: string) {
31    const [firstName, lastName] = fullName.split(' ');
32    this.set({ firstName, lastName });
33  });
34 
35export { UserSchema };

Above, we set the firstName and lastName properties based on the fullName.

Populating virtual properties

A handy feature of virtual properties is using them to populate documents from another collection.

We learn the basics of the populate feature in API with NestJS #44. Implementing relationships with MongoDB

In an example in the previous article, we create a schema for a post, using it to store the reference to the author.

post.schema.ts
1import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
2import { Document, ObjectId } from 'mongoose';
3import * as mongoose from 'mongoose';
4import { User } from '../users/user.schema';
5import { Transform, Type } from 'class-transformer';
6 
7export type PostDocument = Post & Document;
8 
9@Schema()
10export class Post {
11  @Transform(({ value }) => value.toString())
12  _id: ObjectId;
13 
14  @Prop()
15  title: string;
16 
17  @Prop()
18  content: string;
19 
20  @Prop({ type: mongoose.Schema.Types.ObjectId, ref: User.name })
21  @Type(() => User)
22  author: User;
23}
24 
25export const PostSchema = SchemaFactory.createForClass(Post);

Therefore, when we fetch the User document, we don’t have information about any posts. We can use virtual properties to tackle this issue.

user.schema.ts
1import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
2import { Document } from 'mongoose';
3import { Type } from 'class-transformer';
4import { Post } from '../posts/post.schema';
5 
6export type UserDocument = User & Document;
7 
8@Schema({
9  toJSON: {
10    getters: true,
11    virtuals: true,
12  },
13})
14export class User {
15  @Prop({ unique: true })
16  email: string;
17 
18  @Type(() => Post)
19  posts: Post[];
20 
21  // ...
22}
23 
24const UserSchema = SchemaFactory.createForClass(User);
25 
26UserSchema.virtual('posts', {
27  ref: 'Post',
28  localField: '_id',
29  foreignField: 'author',
30});
31 
32export { UserSchema };

The last step is to call the populate function along with the document of the user. While we’re at it, we can also populate the nested categories property.

users.service.ts
1import { Injectable, NotFoundException } from '@nestjs/common';
2import { InjectModel } from '@nestjs/mongoose';
3import { Model } from 'mongoose';
4import { UserDocument, User } from './user.schema';
5 
6@Injectable()
7export class UsersService {
8  constructor(@InjectModel(User.name) private userModel: Model<UserDocument>) {}
9 
10  async getById(id: string) {
11    const user = await this.userModel.findById(id).populate({
12      path: 'posts',
13      populate: {
14        path: 'categories',
15      },
16    });
17 
18    if (!user) {
19      throw new NotFoundException();
20    }
21 
22    return user;
23  }
24}

Summary

In this article, we’ve learned what virtual properties are how they can be useful. We’ve used them both to add simple properties and populate documents from other collections. To better grasp the concept of virtual, we’ve also investigated getters and setters. All of the above can surely come in handy when using Mongoose to define MongoDB schemas.