DEV Community

Cover image for How to Make Your Mongoose Models Smarter and Your Code Cleaner with Setters, Getters, and More
Ayomide Ajewole
Ayomide Ajewole

Posted on

How to Make Your Mongoose Models Smarter and Your Code Cleaner with Setters, Getters, and More

I've been building, deploying, and maintaining Node.js applications for years now, and my ODM of choice, like for most others, when using MongoDB, the most popular non-relational database in the Node ecosystem, is Mongoose.

I'm pretty sure you know what Mongoose is if you're reading this. Still, in case you don't, it is an object data modelling tool that provides a straightforward, schema-based approach to model application data with validation, query building, and more out of the box. All of this is useful because it can be a hassle to deal with MongoDB's native Node.js drivers, query language, and schema-less structure, which makes data consistency a nightmare. In the words of Mongoose's creators:

Let's face it, writing MongoDB validation, casting and business logic boilerplate is a drag. That's why we wrote Mongoose.

So Mongoose makes it very easy to work with MongoDB, but many developers miss just how feature-packed it is. Beyond simple schema definitions and queries, Mongoose has a toolbox of powerful features that can dramatically improve how you structure, validate, and maintain your codebase.

In this post, we'll go beyond the basics - diving into getters, setters, virtuals, custom validators, middleware, statics, and methods - so you can start writing cleaner and smarter models.

Getters and Setters

Every developer has encountered use cases that require data to be stored separately from its usage in code. A common use case is the standard practice of storing names and emails in lowercase for consistency, but displaying them in title case.
With getters and setters, you can handle that directly at the schema level:

const transactionSchema = new mongoose.schema({
  email: {
    type: String,
    set: v => v.toLowerCase(),
  },
  firstName: {
    type: String,
    set: v => v.toLowerCase(),
    get: v => toTitleCase(v), // assuming you have a toTitleCase util
  },
  lastName: {
    type: String,
    set: v => v.toLowerCase(),
    get: v => toTitleCase(v),
  },
})
Enter fullscreen mode Exit fullscreen mode

The get and set methods are functions that can be used to modify the variable as desired before it gets fetched from or saved to the DB, similar to transformers in TypeORM.

Virtuals

Let's say the product team suddenly wants to display a user's full name on the frontend. Instead of concatenating names manually everywhere in your code, you can define a virtual property. It lets you define computed properties that don't actually exist in the database.
Example:

userSchema.virtual('fullName').get(function () {
  return `${this.firstName} ${this.lastName}`;
});
Enter fullscreen mode Exit fullscreen mode

This will allow the fullName field to get returned whenever the user collection is queried, even though it's not stored in the database.

Middleware(Hooks)

Think of it just like a regular middleware. A logic you want to execute before you go on to the main logic, the model event in this case, like save, find, remove, and more. Mongoose provides built-in event-based middleware. It can be used for complex validation, removing dependent documents, triggers, and more.

userSchema.pre('save', function (next) {
  if (this.phoneNumber) {
  this.phoneNumber = formatPhoneNumber(this.phoneNumber); // format phone number to desired form.
}
  next();
});
userSchema.pre('remove', async function (next) {
  await Post.deleteMany({ author: this._id }); // delete all of the user's posts
  next();
});
userSchema.pre(/^find/, function (next) {
  this.where({ isDeleted: { $ne: true } }); // Exclude soft-deleted users
  next();
});
Enter fullscreen mode Exit fullscreen mode

So, we format certain fields before saving, delete dependent data to avoid orphaned data, and exclude soft-deleted users without ever worrying about adding that condition everywhere the query is made.

Statics and Methods: Class-Like Behavior

Mongoose models behave like classes, and just like in classes, you can add static methods (model-level) and instance methods (document-level):

userSchema.statics.findByEmail = function (email) {
  return this.findOne({ email });
};
userSchema.methods.isAdult = function () {
  return this.age >= 18;
};
Enter fullscreen mode Exit fullscreen mode

So that:

const user = await User.findByEmail('xyz@yopmail.com');
console.log(user.isAdult) // true or false
Enter fullscreen mode Exit fullscreen mode

Clean, expressive, and object-oriented. 

Bonus

A few extra schema features that make your models better:

match: /^[a-zA-Z0-9_-]$/
Enter fullscreen mode Exit fullscreen mode

This allows you to enforce valid characters for a field using a regex.

index: true
Enter fullscreen mode Exit fullscreen mode

Index a field to increase query performance when you query by that field. Useful when you have a field that is queried regularly. You can also use compound indexes.

unique: true
Enter fullscreen mode Exit fullscreen mode

To prevent duplicate values for a field. It also creates an index on the field.

And schema-level options:

timestamps: true
Enter fullscreen mode Exit fullscreen mode

Automatically add createdAt and updatedAt

expireAt: { type: Date, default: Date.now, expires: 600 }
// OR
expireAt: { type: Date, default: Date.now, expires: '10m' }
Enter fullscreen mode Exit fullscreen mode

This is a TTL (Time-to-live) index that automatically deletes the document 10 minutes (configurable) after the expireAt field is set or after the document was created when a default value is set. Useful for one-time passwords.

toObject: { getters: true, virtuals: true }
toJSON: { getters: true, virtuals: true }
Enter fullscreen mode Exit fullscreen mode

The toJSON and toObject schema options convert the mongoose document to a JavaScript object and can also contain modifications that apply to every document in the model, with the difference being that toJSON takes effect when toJSON or JSON.stringify() is called in JavaScript code. It's important to set the getters and virtuals fields to true in both.

The final model then looks like this:

const userSchema = new mongoose.schema({
  email: {
    type: String,
    set: v => v.toLowerCase(),
    unique: true,
  },
  firstName: {
    type: String,
    set: v => v.toLowerCase(),
    get: v => toTitleCase(v), // assuming you have a toTitleCase util
  },
  lastName: {
    type: String,
    set: v => v.toLowerCase(),
    get: v => toTitleCase(v),
  },
  username: {
    type: String,
    match: /^[a-zA-Z0-9_-]$/,
    set: v => v.toLowerCase(),
    unique: true,
  },
  age: {
    type: Number,
},
  pin: {
    type: String,
},
phoneNumber: {
    type: String,
},
},
{
  timestamps: true,
  toJSON: { getters: true, virtuals: true},
  toObject: { getters: true, virtuals: true},
});

userSchema.virtual('fullName').get(function () {
  return `${this.firstName} ${this.lastName}`;
});

userSchema.pre('save', function (next) {
  if (this.phoneNumber) {
  this.phoneNumber = formatPhoneNumber(this.phoneNumber); // format phone number to desired form.
}
  next();
});
userSchema.pre('remove', async function (next) {
  await Post.deleteMany({ author: this._id }); // delete all of the user's posts
  next();
});
userSchema.pre(/^find/, function (next) {
  this.where({ isDeleted: { $ne: true } }); // Exclude soft-deleted users
  next();
});
userSchema.statics.findByEmail = function (email) {
  return this.findOne({ email });
};
userSchema.methods.isAdult = function () {
  return this.age >= 18;
};

export const User = mongoose.model('User', userSchema);
Enter fullscreen mode Exit fullscreen mode

Looks like the better, more functional, and more maintainable way to write production code, doesn't it?
So, whether you prefer using it as an extra layer of validation or to enforce consistency, or to reduce boilerplate, or have simply been convinced to write cool code like this, it is very clear that Mongoose is much more powerful than most are aware of. So, don't just use it, leverage it.

Top comments (0)