Schemas — Types, Validators, Defaults, Virtuals, and Getters/Setters

Mongoose is the ODM (Object Document Mapper) that sits between your Node.js application and MongoDB. Where the raw MongoDB driver gives you total flexibility — storing anything in any shape — Mongoose adds a schema layer that defines the structure, types, validation rules, default values, and computed properties of your data. This is the contract between your application code and your database. A well-designed Mongoose schema is self-documenting, catches data integrity errors before they reach the database, and provides computed properties that keep your business logic centralised in one place. This lesson covers the full schema API from primitive types through custom validators to virtual properties.

Mongoose SchemaTypes

SchemaType JS Equivalent Notes
String string Options: minlength, maxlength, enum, match (regex), trim, lowercase, uppercase
Number number Options: min, max, enum
Boolean boolean
Date Date Options: min, max. Stored as ISODate in MongoDB
Buffer Buffer Binary data — file contents, image bytes
ObjectId mongoose.Types.ObjectId Reference to another document. Use ref for populate()
Array Array Shorthand for SchemaType[]. Supports nested schemas
Map Map Key-value pairs with dynamic keys
Mixed any No type constraint — use sparingly
Decimal128 High-precision decimal — use for currency
UUID string Mongoose 7+ — stores as BSON Binary UUID

Schema Path Options

Option Type Effect
required boolean / [bool, msg] / fn Field must be present — validation error if missing
default value / fn Value used when field is absent on create
unique boolean Creates a unique index — NOT a Mongoose validator
index boolean Creates a regular index on this field
select boolean false = exclude from all queries by default
validate fn / {validator, message} Custom validation function
get fn Transform value when reading from document
set fn Transform value when writing to document
alias string Alternative property name for this field
immutable boolean Field cannot be changed after creation
Note: unique: true on a Mongoose schema path creates a MongoDB unique index but is NOT a Mongoose validator. This means Mongoose’s validation step (which runs before saving) does not check uniqueness — it happens at the database level when MongoDB tries to insert. The result is a MongoDB error with code 11000, not a Mongoose ValidationError. Your global error handler must specifically detect and handle err.code === 11000 to return a proper 409 response.
Tip: Use select: false on sensitive fields like password, refreshTokens, and resetToken. This ensures they are excluded from every query result by default — you can never accidentally return them in an API response. When you explicitly need the field (login, password reset), select it back with: User.findOne({ email }).select('+password'). The + prefix adds the field back to a query that would otherwise exclude it.
Warning: Mongoose validation only runs on save(), create(), and when runValidators: true is explicitly passed to findOneAndUpdate(), updateOne() etc. Calling updateOne() or findByIdAndUpdate() without { runValidators: true } bypasses all schema validators. Always include this option in update operations: Task.findByIdAndUpdate(id, data, { new: true, runValidators: true }).

Complete Schema Examples

const mongoose = require('mongoose');
const bcrypt   = require('bcryptjs');
const { Schema } = mongoose;

// ── Full User schema with all schema features ─────────────────────────────
const userSchema = new Schema({

    // Basic types with built-in validators
    name: {
        type:      String,
        required:  [true, 'Name is required'],
        trim:      true,
        minlength: [2,   'Name must be at least 2 characters'],
        maxlength: [100, 'Name cannot exceed 100 characters'],
    },

    email: {
        type:      String,
        required:  [true, 'Email is required'],
        unique:    true,            // index only — not a validator
        lowercase: true,            // auto-transform to lowercase
        trim:      true,
        match:     [/^\S+@\S+\.\S+$/, 'Please provide a valid email address'],
    },

    password: {
        type:    String,
        required:[true, 'Password is required'],
        minlength:[8, 'Password must be at least 8 characters'],
        select:  false,             // NEVER returned in queries by default
    },

    role: {
        type:    String,
        enum:    {
            values:  ['user', 'admin', 'moderator'],
            message: '{VALUE} is not a valid role',
        },
        default: 'user',
    },

    avatar: {
        type:    String,
        default: '/uploads/avatars/default.png',
    },

    // Boolean with default
    isVerified: { type: Boolean, default: false },
    isActive:   { type: Boolean, default: true  },

    // Date with min/max
    birthDate: {
        type: Date,
        max:  [new Date(), 'Birth date cannot be in the future'],
        validate: {
            validator: function(v) {
                if (!v) return true;  // optional field
                const age = (Date.now() - v.getTime()) / (365.25 * 24 * 3600 * 1000);
                return age >= 13;
            },
            message: 'You must be at least 13 years old',
        },
    },

    // Number with range
    loginAttempts: {
        type:    Number,
        default: 0,
        min:     0,
        max:     [10, 'Maximum login attempts exceeded'],
        select:  false,
    },

    lockUntil: {
        type:   Date,
        select: false,
    },

    // Immutable field — cannot be changed after creation
    plan: {
        type:      String,
        enum:      ['free', 'pro', 'enterprise'],
        default:   'free',
        immutable: true,   // once set, cannot be updated via findOneAndUpdate
    },

    // Object/subdocument — embedded preferences
    preferences: {
        theme:    { type: String, enum: ['light', 'dark'], default: 'light' },
        timezone: { type: String, default: 'UTC' },
        notifications: {
            email: { type: Boolean, default: true  },
            push:  { type: Boolean, default: false },
        },
    },

    // Array of subdocuments (refresh tokens)
    refreshTokens: {
        type:   [{
            token:     { type: String, required: true },
            device:    String,
            expiresAt: { type: Date, required: true },
        }],
        select: false,
        default: [],
    },

    // Custom getter: always return email in lowercase for display
    displayEmail: {
        type: String,
        get:  v => v ? v.toLowerCase() : v,
    },

    // Field with custom setter: strip whitespace and truncate bio
    bio: {
        type: String,
        maxlength: 500,
        set:  v => typeof v === 'string' ? v.trim().substring(0, 500) : v,
    },

    // Alias — access field as either 'phone' or 'phoneNumber'
    phone: {
        type:  String,
        alias: 'phoneNumber',
        match: [/^\+?[1-9]\d{6,14}$/, 'Invalid phone number format'],
    },

    // Custom validator with async support (e.g. check external service)
    username: {
        type: String,
        validate: {
            validator: async function(v) {
                const reserved = ['admin', 'root', 'system', 'support'];
                return !reserved.includes(v.toLowerCase());
            },
            message: 'This username is reserved',
        },
    },

}, {
    timestamps: true,                    // adds createdAt and updatedAt automatically
    toJSON:    { virtuals: true, getters: true },  // include virtuals/getters in JSON output
    toObject:  { virtuals: true, getters: true },
});

// ── Virtual properties — computed, not stored in DB ───────────────────────

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

// fullName virtual: setter
userSchema.virtual('fullName').set(function(name) {
    const parts       = name.trim().split(' ');
    this.firstName    = parts[0];
    this.lastName     = parts.slice(1).join(' ');
});

// isLocked virtual — derived from loginAttempts and lockUntil
userSchema.virtual('isLocked').get(function() {
    return !!(this.lockUntil && this.lockUntil > Date.now());
});

// avatarUrl virtual — construct full URL from stored filename
userSchema.virtual('avatarUrl').get(function() {
    return `${process.env.BASE_URL}${this.avatar}`;
});

// taskCount virtual — reference to related collection
userSchema.virtual('tasks', {
    ref:          'Task',
    localField:   '_id',
    foreignField: 'user',
});

// Usage:
// User.findById(id).populate('tasks')  — resolves the virtual population

How It Works

Step 1 — Schema Defines the Contract, Model Enforces It

A Mongoose schema is a blueprint — it describes the fields, types, and constraints but does not interact with the database. Calling mongoose.model('User', userSchema) creates a Model class bound to that schema. The Model is the active record — it provides the methods (find, create, save) that interact with MongoDB using the schema as validation and transformation rules.

Step 2 — required and validate Run Before Database Writes

When you call user.save() or User.create(data), Mongoose runs all validators synchronously and asynchronously before issuing any MongoDB command. If any validator fails, Mongoose throws a ValidationError and no database write occurs. This catches data integrity errors in application code, with descriptive messages, before bad data can reach MongoDB.

Step 3 — Virtuals Are Computed Properties Not Stored in MongoDB

Virtuals exist only on Mongoose document instances. user.isLocked is computed from user.lockUntil on the fly — it is never stored in MongoDB. When you call user.toJSON() or res.json(user), virtuals are included only if you set { toJSON: { virtuals: true } } in the schema options. Without this, virtuals are invisible in API responses even though they are accessible on the document object.

Step 4 — Getters and Setters Transform Data Transparently

A getter transforms a field’s value every time it is read. A setter transforms a value every time it is written. They are transparent to the rest of your code — you just read and write the field normally. Getters are useful for formatting stored data (capitalising a name, constructing a URL from a path). Setters are useful for normalising input before storage (trimming whitespace, hashing a PIN).

Step 5 — select: false Protects Sensitive Fields Everywhere

Setting select: false on a field modifies the default projection for all queries on that model. MongoDB still stores the field, but Mongoose never returns it unless you explicitly request it with .select('+fieldName'). This is a defence-in-depth measure — even if a developer forgets to exclude the password field in a specific query, it will not appear in the result. It is much safer than relying on every query to explicitly exclude sensitive fields.

Real-World Example: Complete Task Schema

// models/task.model.js — complete schema with all features
const mongoose = require('mongoose');

const attachmentSchema = new mongoose.Schema({
    filename:   { type: String, required: true },
    url:        { type: String, required: true },
    size:       { type: Number, min: 0 },
    mimeType:   String,
    uploadedAt: { type: Date, default: Date.now },
}, { _id: true });

const taskSchema = new mongoose.Schema({
    title: {
        type:      String,
        required:  [true, 'Task title is required'],
        trim:      true,
        minlength: [1,   'Title cannot be empty'],
        maxlength: [200, 'Title cannot exceed 200 characters'],
        set:       v => typeof v === 'string' ? v.trim() : v,
    },
    description: {
        type:     String,
        trim:     true,
        maxlength:[2000, 'Description cannot exceed 2000 characters'],
    },
    status: {
        type:    String,
        enum:    { values: ['pending', 'in-progress', 'completed'], message: 'Invalid status: {VALUE}' },
        default: 'pending',
        index:   true,
    },
    priority: {
        type:    String,
        enum:    ['low', 'medium', 'high'],
        default: 'medium',
        index:   true,
    },
    dueDate: {
        type:     Date,
        validate: {
            validator: function(v) {
                // Only validate on create (new documents), not on update
                if (!this.isNew) return true;
                return !v || v > new Date();
            },
            message: 'Due date must be in the future',
        },
    },
    completedAt: {
        type:   Date,
        select: false,
    },
    tags: {
        type:     [{ type: String, trim: true, maxlength: 50 }],
        default:  [],
        validate: {
            validator: v => v.length <= 20,
            message:   'Maximum 20 tags allowed',
        },
    },
    attachments: {
        type:    [attachmentSchema],
        default: [],
        validate: {
            validator: v => v.length <= 10,
            message:   'Maximum 10 attachments per task',
        },
    },
    user: {
        type:     mongoose.Types.ObjectId,
        ref:      'User',
        required: [true, 'Task must belong to a user'],
        immutable: true,  // cannot reassign task to different user
        index:    true,
    },
    deletedAt: { type: Date, select: false },
}, {
    timestamps: true,
    toJSON:    { virtuals: true },
    toObject:  { virtuals: true },
});

// Virtuals
taskSchema.virtual('isOverdue').get(function() {
    return !!(this.dueDate && this.dueDate < new Date() && this.status !== 'completed');
});

taskSchema.virtual('isDeleted').get(function() {
    return !!this.deletedAt;
});

taskSchema.virtual('daysUntilDue').get(function() {
    if (!this.dueDate) return null;
    return Math.ceil((this.dueDate - new Date()) / 86400000);
});

// Indexes
taskSchema.index({ user: 1, status: 1, createdAt: -1 });
taskSchema.index({ user: 1, priority: 1, createdAt: -1 });
taskSchema.index({ user: 1, dueDate: 1 });
taskSchema.index({ title: 'text', description: 'text' }, { weights: { title: 10, description: 3 } });

module.exports = mongoose.model('Task', taskSchema);

Common Mistakes

Mistake 1 — Trusting unique: true as a validator

❌ Wrong — ValidationError is not thrown for duplicate emails:

userSchema.path('email').validate(async function(v) {
    // This custom validator runs on save() — but unique: true still only creates an index
    // Two concurrent requests can both pass validation and then hit the DB-level unique constraint
});
// Always handle err.code === 11000 in your error middleware

✅ Correct — handle duplicate key error in global error handler:

if (err.code === 11000) {
    const field = Object.keys(err.keyPattern)[0];
    return res.status(409).json({ message: `${field} already in use` });
}

Mistake 2 — Forgetting runValidators on update operations

❌ Wrong — invalid data saved via update without validation:

await Task.findByIdAndUpdate(id, { priority: 'critical' });
// 'critical' is not in the enum — but no ValidationError! Saved silently.

✅ Correct — always include runValidators on update operations:

await Task.findByIdAndUpdate(id, { priority: 'critical' }, { runValidators: true });
// ValidationError: priority: 'critical' is not a valid enum value

Mistake 3 — Not setting toJSON: { virtuals: true } — virtuals missing from API responses

❌ Wrong — isOverdue virtual never appears in JSON responses:

const taskSchema = new mongoose.Schema({ ... });
taskSchema.virtual('isOverdue').get(function() { ... });
// res.json(task) — isOverdue is absent even though task.isOverdue works in code

✅ Correct — configure toJSON and toObject to include virtuals:

const taskSchema = new mongoose.Schema({ ... }, {
    toJSON:   { virtuals: true },
    toObject: { virtuals: true },
});

Quick Reference

Feature Schema Syntax
Required field { type: String, required: [true, 'msg'] }
Enum validation { type: String, enum: ['a','b'], default: 'a' }
Hidden field { type: String, select: false }
Immutable field { type: ObjectId, immutable: true }
Auto-timestamp new Schema({...}, { timestamps: true })
Custom validator { validate: { validator: fn, message: 'msg' } }
Getter { get: v => transform(v) }
Setter { set: v => transform(v) }
Virtual property schema.virtual('name').get(fn)
Include virtuals in JSON new Schema({...}, { toJSON: { virtuals: true } })
Virtual populate schema.virtual('tasks', { ref, localField, foreignField })

🧠 Test Yourself

A user schema has password: { type: String, select: false }. A developer queries User.findOne({ email }) for login and needs to compare the password. How do they include the password field in the result?