Chapter 85 โ Mongoose with TypeScript
๐ Definition
Mongoose ships its own TypeScript types (no @types/mongoose needed since v5.11). You type a model by giving Schema and model generics โ the raw document interface, the model type (for statics), and the methods interface โ or by letting Mongoose infer the type from the schema with InferSchemaType.
๐ Key Type Names
| Type | Meaning |
|---|---|
IUser (your interface) |
Plain data shape stored in MongoDB |
HydratedDocument<IUser, IUserMethods> |
A full Mongoose document: data + save(), _id, methods |
Model<IUser, {}, IUserMethods> |
The model class โ User.find(), User.create() |
InferSchemaType<typeof schema> |
Data type inferred from a schema definition |
Types.ObjectId |
ObjectId in documents/interfaces |
FlattenMaps<T> / lean result |
What .lean() returns โ plain object, no methods |
๐ป Code Example โ Approach 1: Interface-First (most explicit)
// modules/users/user.model.ts
import { Schema, model, type Model, type HydratedDocument, type Types } from "mongoose";
import bcrypt from "bcrypt";
export type Role = "admin" | "user";
// 1. Raw document shape
export interface IUser {
email: string;
name: string;
password: string;
role: Role;
isActive: boolean;
orgId?: Types.ObjectId;
createdAt: Date;
updatedAt: Date;
}
// 2. Instance methods
export interface IUserMethods {
comparePassword(candidate: string): Promise<boolean>;
}
// 3. Model type with statics
export interface UserModel extends Model<IUser, {}, IUserMethods> {
findByEmail(email: string): Promise<UserDocument | null>;
}
export type UserDocument = HydratedDocument<IUser, IUserMethods>;
const userSchema = new Schema<IUser, UserModel, IUserMethods>(
{
email: { type: String, required: true, unique: true, lowercase: true, trim: true },
name: { type: String, required: true, trim: true, maxlength: 80 },
password: { type: String, required: true, select: false, minlength: 8 },
role: { type: String, enum: ["admin", "user"], default: "user" },
isActive: { type: Boolean, default: true },
orgId: { type: Schema.Types.ObjectId, ref: "Org" },
},
{ timestamps: true },
);
// Methods โ `this` is typed as UserDocument
userSchema.method("comparePassword", function (candidate: string) {
return bcrypt.compare(candidate, this.password);
});
// Statics โ `this` is typed as UserModel
userSchema.static("findByEmail", function (email: string) {
return this.findOne({ email: email.toLowerCase() }).select("+password");
});
// Hooks โ Mongoose 8 supports async hooks without calling next()
userSchema.pre("save", async function () {
if (!this.isModified("password")) return;
this.password = await bcrypt.hash(this.password, 12);
});
// Remove secrets from every JSON response
userSchema.set("toJSON", {
virtuals: true,
versionKey: false,
transform: (_doc, ret: Record<string, unknown>) => {
delete ret.password;
delete ret._id; // `id` virtual (string) is kept instead
return ret;
},
});
export const User = model<IUser, UserModel>("User", userSchema);Usage โ everything is typed:
const user = await User.findByEmail("a@b.com"); // UserDocument | null
if (user && (await user.comparePassword("secret"))) {
user.role; // "admin" | "user"
// user.rol โ compile error
}๐ป Code Example โ Approach 2: Schema-First with InferSchemaType
import { Schema, model, type InferSchemaType, type HydratedDocument } from "mongoose";
const productSchema = new Schema(
{
name: { type: String, required: true },
price: { type: Number, required: true, min: 0 },
tags: { type: [String], default: [] },
stock: { type: Number, default: 0 },
category: { type: String, enum: ["book", "electronics", "toy"] as const, required: true },
},
{ timestamps: true },
);
export type IProduct = InferSchemaType<typeof productSchema>;
// {
// name: string; price: number; tags: string[]; stock: number;
// category: "book" | "electronics" | "toy";
// createdAt: NativeDate; updatedAt: NativeDate;
// }
export type ProductDocument = HydratedDocument<IProduct>;
export const Product = model("Product", productSchema);| Interface-first | Schema-first (InferSchemaType) |
|
|---|---|---|
| Source of truth | Interface (risk: schema drifts) | Schema (no duplication) |
| Methods/statics typing | Clear generics | Use methods/statics schema options |
| Readability | Explicit, easy to share | Type is "hidden" until hover |
| Good for | Shared/public types, complex models | Fast iteration, simple models |
๐ป Code Example โ References & Typed populate
import { Schema, model, type Types } from "mongoose";
import type { IUser } from "../users/user.model.js";
export interface IOrderItem { product: Types.ObjectId; qty: number; price: number }
export interface IOrder {
user: Types.ObjectId;
items: IOrderItem[];
total: number;
status: "pending" | "paid" | "shipped" | "cancelled";
}
const orderSchema = new Schema<IOrder>(
{
user: { type: Schema.Types.ObjectId, ref: "User", required: true, index: true },
items: [{
product: { type: Schema.Types.ObjectId, ref: "Product", required: true },
qty: { type: Number, required: true, min: 1 },
price: { type: Number, required: true },
}],
total: { type: Number, required: true },
status: { type: String, enum: ["pending", "paid", "shipped", "cancelled"], default: "pending" },
},
{ timestamps: true },
);
export const Order = model<IOrder>("Order", orderSchema);
// Without the generic, order.user stays Types.ObjectId after populate.
// Tell TS what populate replaced:
const order = await Order.findById(id)
.populate<{ user: Pick<IUser, "name" | "email"> }>("user", "name email")
.orFail(); // throws DocumentNotFoundError instead of returning null
order.user.email; // โ
string๐ป Code Example โ .lean() and Its Type
// Hydrated: full Document with getters, save(), virtuals โ slower, more memory
const doc = await User.findById(id); // UserDocument | null
// Lean: plain JS object โ 3-5x faster for read-only endpoints
const plain = await User.findById(id).lean(); // (IUser & { _id: Types.ObjectId; __v: number }) | null
// plain.comparePassword โ no methods on lean objects
// plain.save() โUse .lean() for list/read endpoints; use hydrated docs when you'll mutate and save() (so hooks and validation run).
๐ป Code Example โ Typed Service with Pagination
// modules/users/user.service.ts
import type { FilterQuery } from "mongoose";
import { User, type IUser } from "./user.model.js";
import { AppError } from "../../utils/AppError.js";
import type { Paginated } from "@app/shared";
export const userService = {
async list(opts: { page: number; limit: number; search?: string }): Promise<Paginated<IUser>> {
const filter: FilterQuery<IUser> = { isActive: true };
if (opts.search) filter.name = { $regex: escapeRegex(opts.search), $options: "i" };
const [items, total] = await Promise.all([
User.find(filter)
.sort({ createdAt: -1 })
.skip((opts.page - 1) * opts.limit)
.limit(opts.limit)
.lean<IUser[]>(),
User.countDocuments(filter),
]);
return { items, total, page: opts.page, limit: opts.limit };
},
async findById(id: string) {
const user = await User.findById(id);
if (!user) throw AppError.notFound("User");
return user.toJSON();
},
async update(id: string, patch: Partial<Pick<IUser, "name" | "role">>) {
const user = await User.findByIdAndUpdate(id, patch, {
new: true, // return the updated doc
runValidators: true, // validators are OFF by default on updates!
});
if (!user) throw AppError.notFound("User");
return user.toJSON();
},
};
function escapeRegex(s: string) {
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); // prevents ReDoS/regex injection
}
FilterQuery<T>was renamed toQueryFilter<T>in Mongoose 9 โ check your version.
๐ป Code Example โ Transactions (Replica Set Required)
import mongoose from "mongoose";
export async function placeOrder(userId: string, items: { productId: string; qty: number }[]) {
const session = await mongoose.startSession();
try {
return await session.withTransaction(async () => {
let total = 0;
for (const { productId, qty } of items) {
// Atomic decrement only if stock is sufficient
const p = await Product.findOneAndUpdate(
{ _id: productId, stock: { $gte: qty } },
{ $inc: { stock: -qty } },
{ new: true, session },
);
if (!p) throw AppError.conflict(`Insufficient stock for ${productId}`);
total += p.price * qty;
}
const [order] = await Order.create(
[{ user: userId, items: items.map((i) => ({ product: i.productId, qty: i.qty, price: 0 })), total }],
{ session },
);
return order;
});
} finally {
await session.endSession();
}
}withTransaction retries on transient errors and commits/aborts for you. Transactions need a replica set (Atlas always is; locally run mongod --replSet rs0).
๐ป Code Example โ Connection with Typed Events
import mongoose from "mongoose";
export async function connectDB(uri: string) {
mongoose.set("strictQuery", true); // unknown filter fields are dropped
mongoose.connection.on("connected", () => console.log("Mongo connected"));
mongoose.connection.on("error", (err: Error) => console.error("Mongo error", err));
mongoose.connection.on("disconnected", () => console.warn("Mongo disconnected"));
await mongoose.connect(uri, {
maxPoolSize: 10,
serverSelectionTimeoutMS: 5000,
autoIndex: process.env.NODE_ENV !== "production", // build indexes via migrations in prod
});
}๐ Real-World Impact
- Typed
populateandleaneliminate the classic "is this an ObjectId or an object?" bug. runValidators: trueandorFail()close two of the most common silent-data-corruption holes.- Transactions + conditional
$incprevent overselling under concurrent checkouts.
๐ฏ Likely Interview Questions
- How do you type a Mongoose model? โ
Schema<IUser, UserModel, IUserMethods>+model<IUser, UserModel>(), orInferSchemaType. - What is
HydratedDocument? โ The document type returned by queries: your fields + Mongoose document API + methods. - What does
.lean()return and when do you use it? โ Plain object, no getters/methods/virtuals; faster for read-only responses. - Why aren't validators running on
findByIdAndUpdate? โ Update validators are opt-in:runValidators: true. - How do you type populated fields? โ
.populate<{ user: IUser }>("user"). - How do you prevent the password from leaking? โ
select: falsein schema +toJSONtransform + DTO mapping. - When do you need transactions in MongoDB? โ Multi-document atomic writes (order + stock). Single-doc ops are already atomic.
save()vsupdateOne()? โsave()runs allsavehooks and full validation on a hydrated doc;updateOneis a single atomic DB op, faster, runs only query middleware.
โ Typed Express Server | Index | Next: Authentication End-to-End โ