Mongoose models are not just data containers — they can be extended with computed properties (virtuals), document-level behaviour (instance methods), and model-level utilities (static methods). These features let you move business logic from your controllers into the model where it belongs, making your code more reusable, more testable, and easier to understand. In this lesson you will add a fullName virtual, a comparePassword instance method, a generateAuthToken static, and a postCount virtual populate to your MERN Blog schemas.
Virtuals — Computed Properties
A virtual is a property that is computed from other document fields but not stored in MongoDB. It exists only in the Mongoose document layer. Virtuals are perfect for derived display values, formatted representations, and computed URLs.
// ── Basic virtual — computed from other fields ─────────────────────────────────
userSchema.virtual('fullName').get(function () {
return `${this.firstName} ${this.lastName}`;
});
// Usage: user.fullName → 'Jane Smith' (not stored in MongoDB)
// ── Virtual with setter ────────────────────────────────────────────────────────
userSchema.virtual('fullName')
.get(function () {
return `${this.firstName} ${this.lastName}`;
})
.set(function (name) {
const parts = name.split(' ');
this.firstName = parts[0];
this.lastName = parts.slice(1).join(' ');
});
// user.fullName = 'Jane Smith'; → sets firstName = 'Jane', lastName = 'Smith'
// ── Post URL virtual ──────────────────────────────────────────────────────────
postSchema.virtual('url').get(function () {
return `${process.env.CLIENT_URL}/blog/${this.slug}`;
});
// post.url → 'https://myblog.com/blog/getting-started-with-mern'
// ── Reading time virtual ──────────────────────────────────────────────────────
postSchema.virtual('readTimeMinutes').get(function () {
const wordCount = this.body ? this.body.split(/\s+/).length : 0;
return Math.max(1, Math.ceil(wordCount / 200)); // avg reading speed: 200 words/min
});
res.json(document), add { toJSON: { virtuals: true } } and { toObject: { virtuals: true } } to your schema options. Without these options, user.fullName works in your Node.js code but disappears when the object is serialised to JSON for the HTTP response..populate(). For example, a postCount virtual on the User schema counts how many posts the user has authored — without storing that number in the user document. The count is always accurate because it is computed at query time from the posts collection.lean() query results — lean() strips all Mongoose document methods and virtuals, returning a plain JavaScript object that only contains what is in MongoDB. If you use lean() for performance on a query where you need virtual fields, you must either not use lean() or manually compute the virtual values after the query.Virtual Populate — Computed Relationships
// ── postCount virtual on User ──────────────────────────────────────────────────
userSchema.virtual('postCount', {
ref: 'Post', // the Model to query
localField: '_id', // field on User that matches...
foreignField: 'author', // ...field on Post
count: true, // return count instead of full documents
});
// Usage:
const user = await User.findById(userId).populate('postCount');
console.log(user.postCount); // 42 — number of posts by this user
// ── posts virtual on User — fetch actual post documents ───────────────────────
userSchema.virtual('posts', {
ref: 'Post',
localField: '_id',
foreignField: 'author',
// count: false (default) — returns array of documents
});
// Usage:
const user = await User.findById(userId).populate({
path: 'posts',
match: { published: true }, // only count/return published posts
select: 'title slug createdAt',
options: { sort: { createdAt: -1 }, limit: 5 },
});
console.log(user.posts); // [{ title: '...', slug: '...' }, ...]
Instance Methods — Document Behaviour
// ── comparePassword — used in login controller ────────────────────────────────
userSchema.methods.comparePassword = async function (candidatePassword) {
// 'this' is the user document
// password field has select: false — must be explicitly selected
return bcrypt.compare(candidatePassword, this.password);
};
// Usage in auth controller:
const user = await User.findOne({ email }).select('+password');
const isMatch = await user.comparePassword(req.body.password);
if (!isMatch) throw new AppError('Invalid credentials', 401);
// ── generateEmailVerifyToken ──────────────────────────────────────────────────
userSchema.methods.generateEmailVerifyToken = function () {
const token = crypto.randomBytes(32).toString('hex');
this.emailVerifyToken = crypto.createHash('sha256').update(token).digest('hex');
this.emailVerifyExpires = new Date(Date.now() + 24 * 60 * 60 * 1000); // 24h
return token; // return the UNHASHED token to send in the email
// Store the HASHED version — never store the raw token in the database
};
// Usage:
const token = user.generateEmailVerifyToken(); // sets hashed token on document
await user.save({ validateBeforeSave: false }); // save without full validation
await sendVerifyEmail(user.email, token); // send the raw token in the link
// ── softDelete — instance method ──────────────────────────────────────────────
postSchema.methods.softDelete = async function () {
this.deletedAt = new Date();
return this.save();
};
// Usage:
const post = await Post.findById(id);
await post.softDelete(); // sets deletedAt and saves
Static Methods — Model-Level Utilities
// ── Static method on User model ───────────────────────────────────────────────
userSchema.statics.findByEmail = async function (email) {
// 'this' is the Model
return this.findOne({ email: email.toLowerCase() });
};
// Usage:
const user = await User.findByEmail('jane@example.com');
// ── generateAuthToken static ──────────────────────────────────────────────────
userSchema.statics.generateAuthToken = function (userId) {
return jwt.sign({ id: userId }, process.env.JWT_SECRET, {
expiresIn: process.env.JWT_EXPIRES_IN || '7d',
});
};
// Usage in auth controller:
const token = User.generateAuthToken(user._id);
res.json({ token, data: user });
// ── findPublished static ──────────────────────────────────────────────────────
postSchema.statics.findPublished = function (filter = {}, options = {}) {
return this.find({ ...filter, published: true })
.sort({ createdAt: -1 })
.populate('author', 'name avatar');
};
// Usage:
const posts = await Post.findPublished({ tags: 'mern' });
Including Virtuals in JSON Output
// Must set schema options for virtuals to appear in res.json()
const userSchema = new mongoose.Schema(
{ firstName: String, lastName: String },
{
timestamps: true,
versionKey: false,
toJSON: { virtuals: true }, // include virtuals when .toJSON() is called
toObject: { virtuals: true }, // include virtuals when .toObject() is called
}
);
userSchema.virtual('fullName').get(function () {
return `${this.firstName} ${this.lastName}`;
});
// res.json(user) calls user.toJSON() internally
// With toJSON: { virtuals: true } → { firstName, lastName, fullName, ... }
// Without → { firstName, lastName, ... } (no fullName)
Common Mistakes
Mistake 1 — Forgetting toJSON: { virtuals: true }
❌ Wrong — virtuals work in Node.js but vanish in API responses:
const schema = new mongoose.Schema({ firstName: String, lastName: String });
// No toJSON virtuals option
schema.virtual('fullName').get(function() { return `${this.firstName} ${this.lastName}`; });
const user = await User.findById(id);
console.log(user.fullName); // 'Jane Smith' — works in Node.js
res.json(user); // { firstName, lastName } — fullName missing!
✅ Correct — add toJSON and toObject virtual options to the schema.
Mistake 2 — Using arrow functions for instance/static methods
❌ Wrong — arrow function loses ‘this’ binding to the document/model:
userSchema.methods.comparePassword = async (candidatePassword) => {
return bcrypt.compare(candidatePassword, this.password); // 'this' is undefined!
};
✅ Correct — always use regular functions for methods that need ‘this’:
userSchema.methods.comparePassword = async function (candidatePassword) {
return bcrypt.compare(candidatePassword, this.password); // 'this' is the user doc ✓
};
Mistake 3 — Trying to access virtuals after lean()
❌ Wrong — virtual is undefined after lean() query:
const user = await User.findById(id).lean();
console.log(user.fullName); // undefined — lean() strips virtuals
✅ Correct — either skip lean() when you need virtuals, or compute them manually:
const user = await User.findById(id); // full document — virtuals available
// or:
const user = await User.findById(id).lean({ virtuals: true }); // lean with virtuals ✓
Quick Reference
| Feature | Declaration | Usage |
|---|---|---|
| Virtual (getter) | schema.virtual('name').get(fn) |
doc.name |
| Virtual (setter) | schema.virtual('name').set(fn) |
doc.name = value |
| Virtual populate | schema.virtual('posts', { ref, localField, foreignField }) |
User.findById(id).populate('posts') |
| Instance method | schema.methods.methodName = function() {} |
doc.methodName(args) |
| Static method | schema.statics.methodName = function() {} |
Model.methodName(args) |
| Include in JSON | { toJSON: { virtuals: true } } |
Automatic in res.json() |
| Lean with virtuals | .lean({ virtuals: true }) |
Performance + virtuals |