MongoDB

Mongoose Schema Types Explained With Examples

imagine a futuristic digital network rendered in h Mongoose Schema Types Explained With Examples
On this page
  1. All schema types at a glance
  2. A realistic example
  3. Validation options you will use most
  4. Embed or reference?
  5. Common mistakes
  6. Custom validators and useful error messages
  7. Getters, setters and virtuals
  8. Indexes belong in the schema
  9. Schema options worth knowing
  10. Mongoose with TypeScript
  11. Changing a schema on a live app
  12. Frequently asked questions
  13. Oh hi there 👋It’s nice to meet you.
  14. Sign up to receive awesome content in your inbox.

MongoDB itself accepts any shape of document. Mongoose adds a schema on top, so every document in a collection has the fields and types you expect. Getting the types right saves you from bugs like ages stored as text and prices that lose cents.

Want a schema without typing it? Paste a sample document into the JSON to Mongoose schema converter and adjust the result using this guide.

All schema types at a glance

Type Use it for Example
String Text name: String
Number Integers and decimals that don’t need exact precision stock: Number
Boolean Yes/no values isActive: Boolean
Date Dates and times publishedAt: Date
Schema.Types.ObjectId References to other documents author: { type: Schema.Types.ObjectId, ref: 'User' }
[Type] Lists tags: [String]
Nested schema Embedded objects address: addressSchema
Map Objects with keys you don’t know in advance prices: { type: Map, of: Number }
Schema.Types.Decimal128 Money and exact decimals amount: Schema.Types.Decimal128
Buffer Small binary data thumbnail: Buffer
Schema.Types.UUID UUID values externalId: Schema.Types.UUID
Schema.Types.Mixed Anything (no checks) metadata: Schema.Types.Mixed

A realistic example

const mongoose = require('mongoose');
const { Schema } = mongoose;

const addressSchema = new Schema(
  {
    city: { type: String, required: true, trim: true },
    pincode: { type: String, match: /^[1-9][0-9]{5}$/ },
  },
  { _id: false }
);

const productSchema = new Schema(
  {
    title: { type: String, required: true, trim: true, maxLength: 120 },
    slug: { type: String, required: true, unique: true, lowercase: true },
    price: { type: Number, required: true, min: 0 },
    status: { type: String, enum: ['draft', 'active', 'archived'], default: 'draft' },
    tags: { type: [String], default: [] },
    seller: { type: Schema.Types.ObjectId, ref: 'User', required: true },
    warehouse: addressSchema,
    specs: { type: Map, of: String },
  },
  { timestamps: true }
);

module.exports = mongoose.models.Product || mongoose.model('Product', productSchema);

Validation options you will use most

Option Works on What it does
required All types The field must be present
default All types Value used when the field is missing; use a function such as () => new Date() for fresh values
enum String, Number Only allows listed values
min / max Number, Date Lowest and highest allowed value
minLength / maxLength String Length limits
match String Must match a regular expression
trim, lowercase, uppercase String Cleans the value before saving
immutable All types Can’t be changed after the document is created
select: false All types Hidden from query results unless requested, useful for password hashes
validate All types Your own check function

Embed or reference?

Use a nested schema (embedding) when the data belongs to one parent and is read together with it, such as an order’s shipping address. Use an ObjectId reference when the data is shared or grows without limit, such as the user who placed many orders. Load referenced documents with .populate('seller').

Common mistakes

  • Treating unique: true as validation. It creates a database index; it is not a validator. Duplicates cause an E11000 duplicate key error, which you should catch.
  • Storing money as Number. Floating point can turn 0.1 + 0.2 into 0.30000000000000004. Use Decimal128 or store amounts in paise as whole numbers.
  • Using Mixed everywhere. Mongoose can’t validate it and doesn’t notice changes inside it unless you call doc.markModified('field').
  • A default of Date.now(). With parentheses, every document gets the time the server started. Write default: Date.now without parentheses.
  • Arrays that grow forever. A document is limited to 16 MB. Put unbounded lists, like comments, in their own collection.

Custom validators and useful error messages

Built-in options cover most rules, but some checks are specific to your app. Add them with validate, and write messages a user can act on, because Mongoose errors often end up in your API responses:

const eventSchema = new Schema({
  title: { type: String, required: [true, 'Give the event a title'] },
  startsAt: { type: Date, required: true },
  endsAt: {
    type: Date,
    required: true,
    validate: {
      validator(value) { return value > this.startsAt; },
      message: 'The end time must be after the start time',
    },
  },
  seats: { type: Number, min: [1, 'At least one seat is needed'], max: [500, 'Maximum 500 seats'] },
  website: {
    type: String,
    match: [/^https?:\/\/\S+$/, 'Website must start with http:// or https://'],
  },
});

When validation fails, Mongoose throws a ValidationError with one entry per field in err.errors. Turn it into a clean 400 response in your error handler:

app.use((err, req, res, next) => {
  if (err.name === 'ValidationError') {
    const errors = Object.values(err.errors).map((e) => ({ field: e.path, message: e.message }));
    return res.status(400).json({ message: 'Please fix the highlighted fields', errors });
  }
  next(err);
});

One detail catches many developers: validators run on save() and create(), but not on updateOne() or findOneAndUpdate() unless you pass { runValidators: true }. Inside update validators, this also doesn’t point to the document, so validators like the endsAt check above need a different approach for updates.

Getters, setters and virtuals

These three features shape how data goes in and out of your documents without changing what is stored.

Setters transform a value when it is assigned, for example turning a title into a URL slug. Getters transform it when it is read. Virtuals are computed fields that are not stored at all:

const productSchema = new Schema(
  {
    title: { type: String, required: true },
    slug: { type: String, set: (v) => v.toLowerCase().trim().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '') },
    pricePaise: { type: Number, required: true, min: 0 },
  },
  { toJSON: { virtuals: true }, toObject: { virtuals: true } }
);

productSchema.virtual('priceRupees').get(function () {
  return (this.pricePaise / 100).toFixed(2);
});

Storing prices in paise as whole numbers avoids floating point errors, and the virtual gives your frontend a readable value. The toJSON: { virtuals: true } option includes virtuals when you send the document with res.json().

Indexes belong in the schema

Every query you run often should be supported by an index, and the schema is the natural place to declare them:

productSchema.index({ status: 1, createdAt: -1 });   // list active products, newest first
productSchema.index({ seller: 1, createdAt: -1 });   // a seller's products
productSchema.index({ title: 'text' });              // simple full-text search

Mongoose builds these indexes when the app starts, which is convenient in development. On large production collections, building indexes can be slow, so many teams turn off autoIndex in production and create indexes deliberately during a deploy step.

Schema options worth knowing

Option What it does
timestamps: true Adds and maintains createdAt and updatedAt
strict On by default: fields not in the schema are silently dropped on save
versionKey The __v field used to detect conflicting array updates; set to false only if you know you don’t need it
toJSON Controls what res.json() sends, for example including virtuals or removing fields with a transform function
collection Sets the collection name instead of the automatic plural
discriminatorKey Lets several models with different fields share one collection

A toJSON transform is a clean way to never leak private fields, even when someone forgets select: false:

userSchema.set('toJSON', {
  transform(doc, ret) {
    delete ret.passwordHash;
    delete ret.__v;
    return ret;
  },
});

Mongoose with TypeScript

Mongoose infers TypeScript types from your schema, so you rarely need to write a separate interface. Define the schema, then extract its type:

import { Schema, model, InferSchemaType } from 'mongoose';

const userSchema = new Schema({
  email: { type: String, required: true },
  age: Number,
}, { timestamps: true });

export type User = InferSchemaType<typeof userSchema>;
export const UserModel = model('User', userSchema);

If you start from an existing API response instead, the JSON to TypeScript tool generates interfaces in one paste.

Changing a schema on a live app

Schemas change as your app grows, but the documents already in the database don’t change with them. Mongoose only applies the schema when it reads or writes a document, so old documents keep their old shape until something updates them. Plan changes in three safe steps:

  1. Add new fields as optional, with a sensible default, so old documents still load without validation errors.
  2. Backfill existing documents with a one-off script, for example await Product.updateMany({ status: { $exists: false } }, { $set: { status: 'active' } }).
  3. Only then make the field required, or remove the old field from the schema and the documents.

Renaming a field works the same way: add the new name, copy values with $rename in updateMany, update your code to use the new name, then remove the old one. Doing it in this order means no request ever sees a half-migrated document.

Frequently asked questions

What is the difference between String and Schema.Types.String?

None in practice. String is shorthand for the same schema type.

How do I make a field optional?

Fields are optional by default. Leave out required, and add a default if you want a value when it is missing.

Should I store dates as strings?

No. Use Date so you can sort, compare and query by range. Mongoose converts ISO date strings to Date objects for you.

Why are my extra fields not saved?

Mongoose schemas are strict by default, so fields not defined in the schema are dropped. Add the field to the schema, or use a Map or Mixed field for genuinely free-form data.

Should I use an enum or a separate collection for categories?

Use enum for a short, fixed list that only developers change, such as order statuses. Use a separate collection with ObjectId references when admins add or rename categories from a dashboard.

logo

Oh hi there đź‘‹
It’s nice to meet you.

Sign up to receive awesome content in your inbox.

We don’t spam! Read our privacy policy for more info.

Saurabh

Full-stack developer writing practical guides on the MERN stack, AI and DevOps. Every code sample on this site is tested before it is published.