Custom Validators — Beyond Built-in Rules

Built-in validators — required, minlength, min, enum, match — cover the mechanical rules. Custom validators cover business rules: a slug must not contain reserved words, a post cannot be scheduled more than one year in the future, a user’s new password must be different from their current one, or an email domain must not be from a known disposable mail service. In this lesson you will write synchronous and asynchronous custom validators, surface their error messages cleanly to React, and understand how to validate fields that depend on other fields in the same document.

Custom Validator Syntax

// ── Inline custom validator ────────────────────────────────────────────────────
const postSchema = new mongoose.Schema({
  slug: {
    type: String,
    validate: {
      validator: function (v) {
        // Return true if valid, false if invalid
        return /^[a-z0-9-]+$/.test(v);
      },
      message: props => `'${props.value}' is not a valid slug — only lowercase letters, numbers, and hyphens allowed`,
    },
  },
});
// ── Multiple validators for one field ─────────────────────────────────────────
const postSchema = new mongoose.Schema({
  slug: {
    type: String,
    validate: [
      {
        validator: (v) => /^[a-z0-9-]+$/.test(v),
        message:   'Slug must only contain lowercase letters, numbers, and hyphens',
      },
      {
        validator: (v) => !['admin', 'api', 'login', 'register', 'dashboard'].includes(v),
        message:   props => `'${props.value}' is a reserved word and cannot be used as a slug`,
      },
      {
        validator: (v) => v.length <= 100,
        message:   'Slug cannot exceed 100 characters',
      },
    ],
  },
});
Note: Custom validator functions receive the value being validated as their first argument. They must return true (valid) or false (invalid) — or throw an Error with the error message. If the validator is async (returns a Promise), Mongoose awaits it. The message can be a static string or a function that receives a props object containing props.value, props.path, and props.kind.
Tip: For email validation, the built-in match: /regex/ validator handles simple format checking. Use an async custom validator for stronger checks — like verifying the email domain is not a known disposable mail provider — by checking against an external list or making an API call inside the validator function.
Warning: Asynchronous custom validators only work with save() and create(). They do NOT run with findByIdAndUpdate() even when runValidators: true is set — Mongoose cannot properly await async validators in the update context. For async validation logic on update operations, run the validation explicitly in your controller before calling the update method.

Async Custom Validators

const userSchema = new mongoose.Schema({
  email: {
    type: String,
    required: true,
    validate: {
      // async validator — returns a Promise
      validator: async function (email) {
        // Check if another user already has this email
        // 'this' is the document being validated
        const existingUser = await mongoose.model('User').findOne({
          email,
          _id: { $ne: this._id }, // exclude the current document (for updates)
        });
        return !existingUser; // valid if NO other user has this email
      },
      message: 'Email address is already registered',
    },
  },

  username: {
    type: String,
    validate: {
      validator: async function (username) {
        const User = mongoose.model('User');
        const count = await User.countDocuments({
          username: username.toLowerCase(),
          _id: { $ne: this._id },
        });
        return count === 0;
      },
      message: props => `Username '${props.value}' is already taken`,
    },
  },
});

Cross-Field Validation with Schema-Level Validators

// Validate relationships between fields using a pre-validate hook
// (Schema-level validate is not a feature — use pre('validate') instead)

postSchema.pre('validate', function (next) {
  // If published is true, publishedAt must be set
  if (this.published && !this.publishedAt) {
    this.publishedAt = new Date(); // auto-set publishedAt when publishing
  }

  // scheduledFor cannot be in the past
  if (this.scheduledFor && this.scheduledFor < new Date()) {
    this.invalidate('scheduledFor', 'Scheduled date cannot be in the past', this.scheduledFor);
  }

  // endDate must be after startDate (if both are set)
  if (this.startDate && this.endDate && this.endDate <= this.startDate) {
    this.invalidate('endDate', 'End date must be after start date', this.endDate);
  }

  next();
});

// this.invalidate(path, message, value) — manually add a validation error
// These errors appear in the ValidationError just like built-in validator errors

Formatting Validation Errors for React

// server/src/middleware/errorHandler.js
// Handle Mongoose ValidationError — format as field-level errors

if (err.name === 'ValidationError') {
  const errors = Object.values(err.errors).map(e => ({
    field:   e.path,    // 'title', 'email', 'slug', etc.
    message: e.message, // 'Title is required', 'Email already registered', etc.
    value:   e.value,   // the invalid value that was provided
  }));

  return res.status(400).json({
    success: false,
    message: 'Validation failed',
    errors,
  });
}

// React can then display errors next to form fields:
// const titleError = errors.find(e => e.field === 'title')?.message;
// 
// {titleError && {titleError}}

Practical Custom Validators for the MERN Blog

// server/src/models/Post.js — complete validator examples

const postSchema = new mongoose.Schema({
  slug: {
    type:     String,
    validate: [
      {
        validator: (v) => /^[a-z0-9-]+$/.test(v),
        message:   'Slug must only contain lowercase letters, numbers, and hyphens',
      },
      {
        validator: (v) => !v.startsWith('-') && !v.endsWith('-'),
        message:   'Slug cannot start or end with a hyphen',
      },
    ],
  },

  tags: {
    type:     [String],
    validate: {
      validator: (tags) => tags.length <= 10,
      message:   'A post cannot have more than 10 tags',
    },
  },

  coverImage: {
    type:     String,
    validate: {
      validator: (v) => !v || /^https?:\/\/.+\.(jpg|jpeg|png|webp|gif)$/i.test(v),
      message:   'Cover image must be a valid image URL (jpg, png, webp, or gif)',
    },
  },

  readTimeMinutes: {
    type:     Number,
    validate: {
      validator: (v) => Number.isInteger(v) && v > 0,
      message:   'Read time must be a positive integer',
    },
  },
});

Common Mistakes

Mistake 1 — Using arrow function in validator that needs 'this'

❌ Wrong — arrow function loses 'this' binding to the document:

validator: async (email) => {
  const exists = await User.findOne({ email, _id: { $ne: this._id } }); // 'this' is undefined!
}

✅ Correct — use a regular function to access the document via 'this':

validator: async function (email) {
  const exists = await User.findOne({ email, _id: { $ne: this._id } }); // 'this' works ✓
}

Mistake 2 — Throwing instead of returning false in a validator

❌ Wrong — throwing an error with the wrong message format:

validator: (v) => {
  if (!isValid(v)) throw new Error('Invalid value'); // message overrides schema message
}

✅ Correct — return false and let the message property define the error text:

validator: (v) => isValid(v), // return false → triggers the message property
message:   'Value is invalid' // clean, controlled message ✓

Mistake 3 — Running expensive async validators on every save

❌ Wrong — async DB check runs even when the email has not changed:

validator: async function (email) {
  return !(await User.exists({ email, _id: { $ne: this._id } }));
  // Runs every save() even if only the name changed — unnecessary DB call
}

✅ Correct — guard with isModified to only validate when the field actually changed:

validator: async function (email) {
  if (!this.isModified('email')) return true; // skip if email not changed ✓
  return !(await User.exists({ email, _id: { $ne: this._id } }));
}

Quick Reference

Task Code
Sync validator validate: { validator: (v) => /regex/.test(v), message: '...' }
Async validator validate: { validator: async function(v) { return ... } }
Dynamic message message: props => `'${props.value}' is invalid`
Multiple validators validate: [{ validator: fn1 }, { validator: fn2 }]
Cross-field validation schema.pre('validate', function(next) { this.invalidate(...) })
Manual invalidation this.invalidate('field', 'message', value)
Check if field changed this.isModified('email')

🧠 Test Yourself

You add an async custom validator to the email field that checks for duplicates. A user updates their profile and saves without changing their email. The validator runs a database query and incorrectly fails because it finds the user's own document. What should you do?