Built-In and Custom Validation

Validators run inside save(), after casting. That order explains the two error types you will meet: a value that cannot become the declared type raises a CastError immediately, while one that casts but breaks a rule is collected into a ValidationError.

Every validator style in one schema, then one bad documentJavaScript
const reviewSchema = new Schema({
  bookId: { type: Schema.Types.ObjectId, ref: 'Book', required: true },
  rating: { type: Number, required: [true, 'a review needs a rating'], min: 1, max: 5 },
  email:  { type: String, validate: {
    validator: (v) => /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(v),
    message: (p) => `${p.value} is not an email address` } }
});
try { await new Review({ rating: 9, email: 'ada[at]example.com' }).save(); }
catch (err) {
  console.log(err.name, '|', err.message);
  for (const [p, e] of Object.entries(err.errors))
    console.log(` ${p.padEnd(7)} kind=${(e.kind ?? '').padEnd(8)} value=${e.value}`);
}
try { await Review.findOne({ bookId: 'not-an-id' }); }   // casting runs here too
catch (err) { console.log(err.name, '|', err.message, '| kind', err.kind); }
Output
ValidationError | Review validation failed: bookId: Path `bookId` is required., rating: Path
`rating` (9) is more than maximum allowed value (5)., email: ada[at]example.com is not an
email address
 bookId  kind=required value=undefined
 rating  kind=max      value=9
 email   kind=user defined value=ada[at]example.com
CastError | Cast to ObjectId failed for value "not-an-id" (type string) at path "bookId" for
model "Review" | kind ObjectId

That shape is the whole point. A ValidationError reports every failing path at once in err.errors, an object keyed by path whose values are ValidatorError instances carrying path, kind, value, message and properties — so mapping it to the Bookshelf 422 envelope is one line: Object.values(err.errors).map((e) => ({ in: 'body', field: e.path, code: e.kind, message: e.message })).

A CastError is flatter — path, kind, value, reason — and is thrown from queries too, which is the trap: a route that puts a URL segment straight into a filter turns a wrong-looking id into a 500 unless you guard it or map CastError to 400.

Custom validation comes in grades: a validate function returning a boolean is per-path, one returning a promise is awaited, and schema.pre('validate', ...) handles rules that span paths. A uniqueness check is none of these. unique: true is not a validator, it only asks Mongoose 243,355 to build a unique index, so a duplicate surfaces as a MongoServerError with code: 11000 from the server, which your handler must turn into the 409 the API promises.