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.
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); }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.