Navigation

Node.js

How to Define Mongoose Schema with Validation

Create robust MongoDB schemas with built-in validation, custom validators, and schema options in Node.js 2025

Table Of Contents

Quick Fix: Basic Schema with Validation

const mongoose = require('mongoose');

// Basic schema with validation
const userSchema = new mongoose.Schema({
  name: {
    type: String,
    required: [true, 'Name is required'],
    minlength: [2, 'Name must be at least 2 characters'],
    maxlength: [50, 'Name cannot exceed 50 characters'],
    trim: true
  },
  email: {
    type: String,
    required: true,
    unique: true,
    lowercase: true,
    match: [/^\w+([.-]?\w+)*@\w+([.-]?\w+)*(\.\w{2,3})+$/, 'Invalid email format']
  },
  age: {
    type: Number,
    min: [0, 'Age must be positive'],
    max: [120, 'Age cannot exceed 120']
  }
}, {
  timestamps: true // Adds createdAt and updatedAt
});

const User = mongoose.model('User', userSchema);

The Problem: Advanced Schema Design

// Complex schema with custom validation
const userSchema = new mongoose.Schema({
  // String validations
  username: {
    type: String,
    required: [true, 'Username is required'],
    unique: true,
    minlength: 3,
    maxlength: 20,
    match: [/^[a-zA-Z0-9_]+$/, 'Username can only contain letters, numbers, and underscores'],
    index: true
  },
  
  email: {
    type: String,
    required: true,
    unique: true,
    lowercase: true,
    validate: {
      validator: function(email) {
        return /^\w+([.-]?\w+)*@\w+([.-]?\w+)*(\.\w{2,3})+$/.test(email);
      },
      message: 'Please provide a valid email address'
    }
  },
  
  // Password with custom validation
  password: {
    type: String,
    required: [true, 'Password is required'],
    minlength: [8, 'Password must be at least 8 characters'],
    validate: {
      validator: function(password) {
        // At least one uppercase, one lowercase, one number, one special char
        return /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]/.test(password);
      },
      message: 'Password must contain at least one uppercase letter, one lowercase letter, one number, and one special character'
    },
    select: false // Don't include in queries by default
  },
  
  // Nested objects
  profile: {
    firstName: { type: String, trim: true },
    lastName: { type: String, trim: true },
    bio: { 
      type: String, 
      maxlength: [500, 'Bio cannot exceed 500 characters'] 
    },
    avatar: {
      url: String,
      filename: String
    },
    socialLinks: {
      twitter: { 
        type: String,
        validate: {
          validator: function(v) {
            return !v || /^@[A-Za-z0-9_]+$/.test(v);
          },
          message: 'Twitter handle must start with @ and contain only letters, numbers, and underscores'
        }
      },
      linkedin: String,
      website: {
        type: String,
        validate: {
          validator: function(v) {
            return !v || /^https?:\/\/.+/.test(v);
          },
          message: 'Website must be a valid URL'
        }
      }
    }
  },
  
  // Arrays with validation
  skills: [{
    type: String,
    trim: true,
    maxlength: 30
  }],
  
  // Array with length validation
  tags: {
    type: [String],
    validate: {
      validator: function(tags) {
        return tags.length <= 10;
      },
      message: 'Cannot have more than 10 tags'
    }
  },
  
  // Enum validation
  role: {
    type: String,
    enum: {
      values: ['user', 'admin', 'moderator'],
      message: 'Role must be either user, admin, or moderator'
    },
    default: 'user'
  },
  
  // Date validations
  dateOfBirth: {
    type: Date,
    validate: {
      validator: function(date) {
        return date < new Date();
      },
      message: 'Date of birth must be in the past'
    }
  },
  
  // Custom field with async validation
  phoneNumber: {
    type: String,
    validate: {
      validator: async function(phone) {
        if (!phone) return true; // Optional field
        
        // Check if phone number already exists
        const existingUser = await mongoose.model('User').findOne({ 
          phoneNumber: phone,
          _id: { $ne: this._id } // Exclude current document
        });
        
        return !existingUser;
      },
      message: 'Phone number already exists'
    }
  },
  
  // Boolean with default
  isActive: {
    type: Boolean,
    default: true
  },
  
  // Reference to another model
  company: {
    type: mongoose.Schema.Types.ObjectId,
    ref: 'Company'
  },
  
  // Array of references
  projects: [{
    type: mongoose.Schema.Types.ObjectId,
    ref: 'Project'
  }]
}, {
  timestamps: true,
  
  // Schema options
  toJSON: {
    transform: function(doc, ret) {
      delete ret.password;
      delete ret.__v;
      return ret;
    }
  },
  
  toObject: {
    transform: function(doc, ret) {
      delete ret.password;
      return ret;
    }
  }
});

// Compound indexes
userSchema.index({ email: 1, isActive: 1 });
userSchema.index({ 'profile.firstName': 1, 'profile.lastName': 1 });

// Virtual properties
userSchema.virtual('fullName').get(function() {
  return `${this.profile.firstName} ${this.profile.lastName}`.trim();
});

userSchema.virtual('age').get(function() {
  if (!this.dateOfBirth) return null;
  return Math.floor((Date.now() - this.dateOfBirth) / (365.25 * 24 * 60 * 60 * 1000));
});

// Custom validation methods
userSchema.methods.isAdult = function() {
  return this.age >= 18;
};

// Static methods for validation
userSchema.statics.validateEmail = function(email) {
  return /^\w+([.-]?\w+)*@\w+([.-]?\w+)*(\.\w{2,3})+$/.test(email);
};

// Custom schema type for enhanced validation
function EmailType(key, options) {
  mongoose.SchemaType.call(this, key, options, 'EmailType');
}

EmailType.prototype = Object.create(mongoose.SchemaType.prototype);

EmailType.prototype.cast = function(val) {
  if (typeof val !== 'string') {
    throw new Error('Email must be a string');
  }
  
  const email = val.toLowerCase().trim();
  
  if (!this.constructor.validateEmail(email)) {
    throw new Error('Invalid email format');
  }
  
  return email;
};

EmailType.validateEmail = function(email) {
  return /^\w+([.-]?\w+)*@\w+([.-]?\w+)*(\.\w{2,3})+$/.test(email);
};

mongoose.Schema.Types.EmailType = EmailType;

// Schema with conditional validation
const productSchema = new mongoose.Schema({
  name: { type: String, required: true },
  type: { 
    type: String, 
    enum: ['physical', 'digital'],
    required: true 
  },
  
  // Conditional validation based on type
  weight: {
    type: Number,
    required: function() {
      return this.type === 'physical';
    },
    min: [0, 'Weight must be positive']
  },
  
  downloadUrl: {
    type: String,
    required: function() {
      return this.type === 'digital';
    },
    validate: {
      validator: function(url) {
        if (this.type !== 'digital') return true;
        return /^https?:\/\/.+/.test(url);
      },
      message: 'Download URL must be valid for digital products'
    }
  },
  
  price: {
    type: Number,
    required: true,
    min: [0, 'Price must be positive'],
    validate: {
      validator: function(price) {
        // Custom business rule
        if (this.type === 'digital' && price < 1) {
          return false;
        }
        return true;
      },
      message: 'Digital products must cost at least $1'
    }
  }
});

// Schema with pre-validation hook
userSchema.pre('validate', function(next) {
  // Ensure email domain is allowed
  const allowedDomains = ['gmail.com', 'yahoo.com', 'company.com'];
  
  if (this.email) {
    const domain = this.email.split('@')[1];
    if (!allowedDomains.includes(domain)) {
      this.invalidate('email', 'Email domain not allowed');
    }
  }
  
  next();
});

// Schema with custom error handling
userSchema.post('save', function(error, doc, next) {
  if (error.name === 'MongoServerError' && error.code === 11000) {
    const field = Object.keys(error.keyValue)[0];
    next(new Error(`${field} already exists`));
  } else {
    next(error);
  }
});

const User = mongoose.model('User', userSchema);

Mongoose schemas solve "data validation", "document structure", and "business rule enforcement" issues. Use built-in validators for common cases, custom validators for business logic. Always validate on both client and server. Alternative: Joi for validation, TypeScript for type safety, JSON Schema validation.

Share this article

Add Comment

No comments yet. Be the first to comment!

More from Node.js