This chapter follows Schema design in MongoDB. Use Mongoose when you want declarative validation, hooks, and populate in exchange for another abstraction layer. Use the native driver when you want minimal overhead, aggregation-heavy pipelines, or tight control over BSON types.
pnpm add mongooseThis chapter assumes Mongoose 9.11.x with the mongodb driver 7.x it bundles.
Schemas and TypeScript inference
A schema defines paths, defaults, and validators. Infer document types from the schema:
import { Schema, model, InferSchemaType, HydratedDocument } from 'mongoose';
const kittenSchema = new Schema( { name: { type: String, required: true, trim: true }, age: { type: Number, min: 0 }, }, { timestamps: true },);
kittenSchema.methods.speak = function speak(this: HydratedDocument<KittenDoc>) { return this.name ? `Meow, I am ${this.name}` : 'I have no name';};
type KittenDoc = InferSchemaType<typeof kittenSchema>;export const Kitten = model('Kitten', kittenSchema);timestamps: true adds createdAt and updatedAt automatically.
Models and documents
The model is the constructor and query entry point; a document is one persisted instance with .save() and instance methods.
const doc = new Kitten({ name: 'Milo', age: 2 });await doc.save();console.log(doc.speak());
const found = await Kitten.findOne({ name: 'Milo' }).exec();Validation
Validators run on save() and on most update helpers when { runValidators: true }:
kittenSchema.path('age').validate((v: number) => v >= 0, 'Age must be non-negative');Driver-level $jsonSchema (previous chapter) still applies if you enable it on the collection; Mongoose adds application-side checks before BSON hits the wire.
Middleware
Pre and post hooks run around lifecycle events:
kittenSchema.pre('save', function () { if (this.isModified('name')) { this.set('name', this.name.trim()); }});Query middleware runs when you await a Query—for example findOneAndUpdate.
Virtuals
Virtuals are computed properties not stored in MongoDB:
kittenSchema.virtual('info').get(function () { return `${this.name} is ${this.age ?? '?'} years old`;});Enable virtuals in JSON output when needed: schema.set('toJSON', { virtuals: true }).
Populate
Replace stored ObjectId refs with documents from another collection:
const storySchema = new Schema({ title: String, author: { type: Schema.Types.ObjectId, ref: 'Person' },});export const Story = model('Story', storySchema);
const story = await Story.findOne({ title: 'Casino Royale' }).populate('author').exec();Populate is convenient but issues extra queries—sometimes $lookup in aggregation or embedding (see schema design) is faster.
lean()
.lean() returns plain objects instead of Mongoose documents—less overhead for read-only API responses:
const rows = await Kitten.find({ age: { $gte: 1 } }).lean().exec();Aggregations always return POJOs; they are never hydrated documents.
Connect with Mongoose
import mongoose from 'mongoose';
export async function connectMongoose(uri = process.env.MONGODB_URI) { if (!uri) throw new Error('MONGODB_URI is not set'); await mongoose.connect(uri);}One Mongoose connection wraps the driver pool; do not open a second native client to the same cluster without reason.