Default values and required validators are the two most commonly used schema features in Mongoose โ and also two of the most commonly misconfigured. A default value ensures a field always has a meaningful starting state even when the client does not supply it. A required validator ensures a field cannot be omitted. Together they define the minimum viable state of every document in your collection. In this lesson you will understand every form of default and required, how they interact with each other and with update operations, and the subtle bugs that trip up developers who do not understand how Mongoose applies them.
Default Values
const postSchema = new mongoose.Schema({
// โโ Static default โ the same value every time โโโโโโโโโโโโโโโโโโโโโโโโโโโโ
published: { type: Boolean, default: false },
viewCount: { type: Number, default: 0 },
tags: { type: [String], default: [] },
role: { type: String, default: 'user', enum: ['user', 'editor', 'admin'] },
// โโ Function default โ computed at creation time โโโโโโโโโโโโโโโโโโโโโโโโโโ
// Use a function when the default should be dynamic (e.g. current time)
expiresAt: {
type: Date,
default: () => new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7 days from now
},
// โโ Function that reads other document fields โโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
// 'this' refers to the document being created
// Note: arrow functions do NOT bind 'this' โ use regular function
displayName: {
type: String,
default: function () {
return this.firstName ? `${this.firstName} ${this.lastName}` : this.email;
},
},
// โโ Default null โ field exists but has no value โโโโโโโโโโโโโโโโโโโโโโโโโโ
coverImage: { type: String, default: null },
publishedAt: { type: Date, default: null },
deletedAt: { type: Date, default: null },
});
Model.create() will already have all defaults applied โ you do not need to set them manually in your controller.default: [] in your schema. Without it, array fields are undefined for documents created before the field was added to the schema, causing TypeError: Cannot read properties of undefined (reading 'push') bugs when you try to $push to them in update operations. With default: [], the field is always an array โ even in legacy documents.default: [] as a reference to a shared array literal. Instead write default: () => [] or just default: [] (Mongoose handles this correctly for arrays internally). However, for object defaults always use a function: default: () => ({ settings: {} }) โ otherwise all documents share a reference to the same object and mutations to one document’s default affect all others.Required Validators
const postSchema = new mongoose.Schema({
// โโ Simple required โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
title: { type: String, required: true },
// โโ Required with custom error message โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
body: { type: String, required: [true, 'Post body is required'] },
author: {
type: mongoose.Schema.Types.ObjectId,
ref: 'User',
required: [true, 'Post must have an author'],
},
// โโ Conditional required โ function form โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
// 'this' refers to the document being validated
publishedAt: {
type: Date,
required: function () {
return this.published === true;
// publishedAt is required only when published is true
},
},
// โโ Required with a descriptive message using the field value โโโโโโโโโโโโโ
status: {
type: String,
enum: ['draft', 'published'],
required: [true, 'Post status must be "draft" or "published"'],
},
});
How Defaults and Required Work Together
// Scenario: Post schema has published: { type: Boolean, default: false, required: true }
// Create without supplying published
const post = await Post.create({ title: 'Test', body: '...', author: id });
// Step 1: default applied โ published = false
// Step 2: required check โ false is a truthy value? No โ but 'false' IS a non-null value
// Step 3: required passes! (required checks for null/undefined, not truthiness)
// Result: post.published === false โ valid โ
// Create with published: null explicitly
const post = await Post.create({ title: 'Test', body: '...', author: id, published: null });
// Step 1: null overrides the default โ no default applied when a value is provided
// Step 2: required check โ published is null โ FAILS
// Result: ValidationError: published is required โ
Required and Update Operations
// IMPORTANT: required validators do NOT run on update operations by default
// This will succeed even though title is required in the schema:
await Post.updateOne({ _id: id }, { $set: { title: null } });
// No ValidationError! required only runs on save() and create() by default
// Fix: always pass runValidators: true to update operations
await Post.findByIdAndUpdate(
id,
{ $set: { title: null } },
{ runValidators: true } // โ now required validator runs
);
// ValidationError: title: Post title is required โ
// Note: runValidators runs in the context of the update query, not the document
// Conditional required using 'this' will not work correctly with runValidators
// Use express-validator for complex cross-field validation on update routes
The validate Hook โ Manual Validation
// Sometimes you want to validate without saving
const post = new Post({ title: 'Test', body: '...' });
try {
await post.validate(); // runs all validators โ does NOT save
console.log('Document is valid');
} catch (err) {
if (err.name === 'ValidationError') {
const messages = Object.values(err.errors).map(e => e.message);
console.error('Validation failed:', messages.join(', '));
}
}
// Validate specific paths only
await post.validate(['title', 'body']); // only validate title and body
Reading Validation Errors
try {
await Post.create({ body: 'Content without title or author' });
} catch (err) {
if (err.name === 'ValidationError') {
// err.errors is an object keyed by field name
console.log(err.errors.title.message); // 'Post title is required'
console.log(err.errors.author.message); // 'Post must have an author'
console.log(err.errors.title.kind); // 'required'
console.log(err.errors.title.path); // 'title'
console.log(err.errors.title.value); // undefined
// Format for API response
const errors = Object.values(err.errors).map(e => ({
field: e.path,
message: e.message,
}));
// [{ field: 'title', message: 'Post title is required' }, ...]
}
}
Common Mistakes
Mistake 1 โ Object defaults shared across documents
โ Wrong โ a default object literal is shared by reference:
const sharedObj = { theme: 'light', notifications: true };
settings: { type: Object, default: sharedObj }
// All documents share the same object โ mutating one affects all
โ Correct โ use a function that returns a new object each time:
settings: { type: Object, default: () => ({ theme: 'light', notifications: true }) } // โ
Mistake 2 โ Assuming required blocks null on updates without runValidators
โ Wrong โ trusting that required prevents null on update:
await Post.updateOne({ _id: id }, { $set: { title: null } });
// Succeeds! required is NOT enforced without runValidators: true
โ Correct โ always include runValidators on update operations:
await Post.findByIdAndUpdate(id, { $set: { title: null } }, { runValidators: true }); // โ
Mistake 3 โ Conditional required using arrow function (loses ‘this’ binding)
โ Wrong โ arrow function in required: ‘this’ is undefined:
publishedAt: {
required: () => this.published === true, // 'this' is undefined in arrow function
}
โ Correct โ use a regular function to access document context via ‘this’:
publishedAt: {
required: function () { return this.published === true; } // 'this' is the document โ
}
Quick Reference
| Task | Schema Syntax |
|---|---|
| Static default | default: false |
| Dynamic default | default: () => new Date() |
| Object default | default: () => ({ key: 'value' }) |
| Required with message | required: [true, 'Field is required'] |
| Conditional required | required: function() { return this.x === true } |
| Validate without saving | await doc.validate() |
| Enforce required on update | findByIdAndUpdate(id, upd, { runValidators: true }) |
| Read validation errors | Object.values(err.errors).map(e => e.message) |