Virtuals and Instance Methods in Mongoose

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
});
Note: Virtuals are NOT included in JSON output by default. To include them when your Express route sends 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.
Tip: Use virtual populate to create a virtual field that lazily loads related documents when you call .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.
Warning: Virtuals are not included in 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

🧠 Test Yourself

You define a readTimeMinutes virtual on the Post schema and add { toJSON: { virtuals: true } } to the schema options. However, when you call await Post.find({}).lean(), the virtual does not appear in the results. Why?