Virtuals, Getters, and Setters

A virtual is a property computed from other paths. It is never stored, never queryable, never indexable — if you need to filter or sort on it, store a real field and maintain it in a hook. Getters and setters transform a path that is stored, going out and coming in.

One computed property, one stored path with a getter and a setterJavaScript
const bookSchema = new Schema({
  title: String, year: Number,
  cents: { type: Number, get: (c) => c / 100, set: (d) => Math.round(d * 100),
           alias: 'price' },
  email: { type: String, set: (v) => v.trim().toLowerCase() }
}, { toJSON: { virtuals: true, getters: true, versionKey: false } });
bookSchema.virtual('label').get(function () { return `${this.title} (${this.year})`; });
const b = await Book.create({ title: 'Solaris', year: 1961, price: 12.5,
                              email: '  Ada@Example.COM ' });
console.log('label ', b.label, '| email', b.email);
console.log('price ', b.price, '| stored cents', b.get('cents', null, { getters: false }));
console.log('toJSON', JSON.stringify(b));
Output
label  Solaris (1961) | email ada@example.com
price  12.5 | stored cents 1250
toJSON {"title":"Solaris","year":1961,"cents":12.5,"email":"ada@example.com",
        "_id":"6ab20b5512fbe182449b8166","price":12.5,"label":"Solaris (1961)",
        "id":"6ab20b5512fbe182449b8166"}

The database holds 1250 while the API sees 12.5: money as an integer, formatted at the edge. The alias option adds a second name for the same path, so price reads and writes cents, and the setter ran at assignment, which is why the stored email is already normalized — a setter cannot reject, only transform. id in the output is the virtual every schema gets for free, the hex string of _id, unless you turn it off with { id: false }.

Watch the rest of that line, though. getters: true applies the getter to cents as well, so the response carries cents: 12.5 and price: 12.5, and a client that trusts cents is off by a hundred. Either turn getters off and expose only the virtual, or add a transform to the toJSON options — a function (doc, ret) => { ... } that deletes the raw path before serialization. None of this survives lean(). Virtuals can also populate: declaring ref, localField and foreignField on one gives a book its reviews without storing a review array, and count: true gives just the number.