A Mongoose schema is not just a list of field names — each field has a SchemaType that controls how values are stored, coerced, and validated. Understanding every SchemaType and its options in depth is what separates a schema that silently accepts bad data from one that enforces the exact shape your application expects. In this lesson you will go beyond the basics and master the full SchemaType system — including the less-common types like Map, Mixed, and nested schemas — with examples grounded in the MERN Blog data model.
All Mongoose SchemaTypes
| SchemaType | BSON Type | Declaration | Common Use |
|---|---|---|---|
| String | String | String or { type: String } |
Text fields — title, slug, email, body |
| Number | Double / Int32 | Number |
Counts, prices, ratings, page numbers |
| Boolean | Boolean | Boolean |
Flags — published, active, verified |
| Date | Date | Date |
Timestamps, scheduled dates, expiry times |
| ObjectId | ObjectId | mongoose.Schema.Types.ObjectId |
Foreign key references — author, post |
| Array | Array | [String] or [{ type: ObjectId }] |
Tags, roles, embedded sub-documents |
| Map | Object | { type: Map, of: String } |
Key-value pairs with dynamic keys — metadata, translations |
| Mixed | Any | mongoose.Schema.Types.Mixed |
Truly unstructured data — use sparingly |
| Buffer | BinData | Buffer |
Binary data — file contents, hashed tokens |
| Decimal128 | Decimal128 | mongoose.Schema.Types.Decimal128 |
High-precision financial amounts |
| UUID | BinData subtype 4 | mongoose.Schema.Types.UUID |
UUIDs as an alternative to ObjectId |
viewCount: Number and you set post.viewCount = '42', Mongoose converts the string '42' to the number 42 before saving. If coercion fails — for example setting a Number field to 'hello' — Mongoose stores NaN and a ValidationError is thrown when the document is validated. Be aware of coercion when building forms in React that send all values as strings.localizedTitle field that stores the post title in multiple languages: { en: 'Hello', fr: 'Bonjour', de: 'Hallo' }. Map types are stored as regular BSON objects but Mongoose gives you a JavaScript Map interface with .get(), .set(), and .has() methods.doc.save() after modifying a Mixed field requires explicitly calling doc.markModified('fieldName') or the change will be silently ignored.String SchemaType — All Options
const postSchema = new mongoose.Schema({
title: {
type: String,
required: [true, 'Title is required'],
trim: true, // strip leading/trailing whitespace before saving
lowercase: false, // auto-lowercase (useful for email, slug)
uppercase: false, // auto-uppercase (useful for country codes)
minlength: [3, 'Title must be at least 3 characters'],
maxlength: [200, 'Title cannot exceed 200 characters'],
match: [/^[a-zA-Z0-9\s\-_'".,!?]+$/, 'Title contains invalid characters'],
enum: undefined, // (used for fields with a fixed value set)
default: undefined,
},
status: {
type: String,
enum: {
values: ['draft', 'published', 'archived'],
message: '{VALUE} is not a valid status', // {VALUE} is replaced with the actual value
},
default: 'draft',
},
slug: {
type: String,
lowercase: true, // always stored as lowercase
trim: true,
unique: true, // creates a unique MongoDB index
sparse: true, // unique index ignores null values (useful for optional unique fields)
},
});
Number SchemaType — All Options
const postSchema = new mongoose.Schema({
viewCount: {
type: Number,
default: 0,
min: [0, 'View count cannot be negative'],
max: [1e9, 'View count seems unrealistically high'],
validate: {
validator: Number.isInteger,
message: 'View count must be an integer',
},
},
rating: {
type: Number,
min: 0,
max: 5,
// No default — null means "not rated yet"
},
price: {
type: mongoose.Schema.Types.Decimal128, // high-precision for financial values
default: null,
},
});
Date SchemaType
const postSchema = new mongoose.Schema({
publishedAt: {
type: Date,
default: null, // null means not yet published
},
scheduledFor: {
type: Date,
default: null,
validate: {
validator: function (v) {
return !v || v > new Date(); // must be in the future if set
},
message: 'Scheduled date must be in the future',
},
},
}, { timestamps: true }); // auto adds createdAt and updatedAt as Date fields
Nested Schemas — Sub-documents
// Define a reusable sub-document schema
const addressSchema = new mongoose.Schema({
street: { type: String, required: true },
city: { type: String, required: true },
country: { type: String, required: true, default: 'US' },
zip: { type: String, match: /^\d{5}(-\d{4})?$/ },
}, { _id: false }); // _id: false — sub-documents don't need their own _id
// Use it as a nested field
const userSchema = new mongoose.Schema({
name: { type: String, required: true },
address: addressSchema, // single embedded sub-document
previousAddresses: [addressSchema], // array of sub-documents
});
// The embedded address is validated as part of the parent document save
const user = await User.create({
name: 'Jane',
address: { street: '123 Main St', city: 'Austin', country: 'US' },
});
Map SchemaType
// Map type — dynamic keys, typed values
const postSchema = new mongoose.Schema({
localizedTitle: {
type: Map,
of: String, // all values must be strings
default: new Map(),
},
metadata: {
type: Map,
of: mongoose.Schema.Types.Mixed, // values can be anything
},
});
// Usage
const post = new Post({
localizedTitle: new Map([
['en', 'Getting Started with MERN'],
['fr', 'Démarrer avec MERN'],
['de', 'Einstieg in MERN'],
]),
});
// Access
post.localizedTitle.get('en'); // 'Getting Started with MERN'
post.localizedTitle.set('es', '...'); // add a new key
post.localizedTitle.has('jp'); // false
// In JSON output
// "localizedTitle": { "en": "Getting Started...", "fr": "Démarrer..." }
Common Mistakes
Mistake 1 — Using Number for currency/financial fields
❌ Wrong — floating point precision issues with regular Number:
price: { type: Number }
// 0.1 + 0.2 = 0.30000000000000004 — floating point error
// Critical for prices, balances, financial calculations
✅ Correct — use Decimal128 for financial precision, or store prices as integers (cents):
priceInCents: { type: Number } // store 1999 for $19.99 ✓
// or
price: { type: mongoose.Schema.Types.Decimal128 } // high-precision decimal ✓
Mistake 2 — Mutating a Mixed field without markModified
❌ Wrong — changes to Mixed fields are silently ignored without markModified:
post.metadata.newKey = 'value'; // modifying Mixed field
await post.save(); // Mongoose does not detect this change — not saved!
✅ Correct — call markModified after mutating Mixed or nested plain object fields:
post.metadata.newKey = 'value';
post.markModified('metadata'); // tell Mongoose the field changed ✓
await post.save();
Mistake 3 — Forgetting { _id: false } on sub-document schemas
❌ Wrong — every embedded address gets its own _id, bloating the document:
const addressSchema = new mongoose.Schema({ street: String, city: String });
// Each embedded address: { _id: ObjectId, street: '...', city: '...' }
// Usually unnecessary overhead for embedded value objects
✅ Correct — use _id: false for embedded value objects that do not need independent identification:
const addressSchema = new mongoose.Schema({ street: String, city: String }, { _id: false });
// { street: '...', city: '...' } — clean, no extra _id ✓
Quick Reference
| Type | Declaration | Key Options |
|---|---|---|
| String | String |
trim, lowercase, uppercase, minlength, maxlength, enum, match |
| Number | Number |
min, max, default |
| Boolean | Boolean |
default |
| Date | Date |
default, min, max |
| ObjectId ref | { type: ObjectId, ref: 'Model' } |
ref, required |
| String array | [String] |
default: [] |
| Sub-document | nested schema or object | _id: false for value objects |
| Map | { type: Map, of: String } |
of (value type), default |