TL;DR: Mongoose's type system was a constant source of friction. So I built Mongster; a TypeScript ODM where one schema drives runtime validation, type inference, and index metadata. Typed filters, typed updates, typed aggregation, populate-via-
$lookup, transaction helpers, and index sync. One runtime dependency: the officialmongodbdriver.npm install mongodb mongster.
It was a perfectly ordinary Wednesday when Mongoose finally broke me.
I was writing a TypeScript query, something I'd done thousands of times:
const user = await User.findOne({ role: "admin" }).populate("orgId");
You already know the result type. any. Of course it was any. I forgot to do the usual dance of casting it, annotating it, and asserting it. And I'd been doing this dance for years.
Mongoose do have "types". But they always felt like a thin layer of optimism painted over a fundamentally JavaScript-first API. HydratedDocument<T, TMethodsAndOverrides, TVirtuals> is not a type you should be writing for dozens of models. lean() returns something close to your document type, but not quite, specially when populate, or select is involved. InferSchemaType and the Schema generic don't always agree (You need to do a lot more rituals for that). Discriminated unions, nested arrays, and refs? Good luck.
So I did what SWE do when a tool consistently frustrates them: I started from scratch. :)
The result was Mongster; a type-safe MongoDB ODM for TypeScript. It's been my daily driver for a few months now, it's at v0.3.0 on npm, and I am writing this to tell why it exists and how it works.
But Why Not Just Use…
Fair question. There are a few other TypeScript-first options in this space. Here's why none of them were quite right for me.
Prisma is excellent... for SQL
It's a completely different development process, and not in a minor way. You define a .prisma schema file in Prisma's own DSL, run a codegen step, and get a generated client that abstracts the database behind its own query API. For relational databases, that's a reasonable tradeoff. For MongoDB, it feels like fighting the database. MongoDB's document model, flexible schemas, nested arrays, arbitrary nesting, and native aggregation pipelines doesn't map cleanly to how Prisma thinks about data. You also lose the ability to express MongoDB-specific things naturally; if you want to use $lookup, $facet, or $graphLookup, you're going outside the Prisma client.
Typegoose: adding fuel to the fire
Typegoose solves the syntax problem with Mongoose. You write classes with decorators instead of new Schema({...}) calls; but it doesn't solve the underlying problem because it's still Mongoose underneath. All the same type holes are still there; they're just expressed differently. And the API is heavily generic by design: DocumentType<User>, ReturnModelType<typeof User>, Ref<User>, ArraySubDocumentType<Address>. If you're writing a model with references and sub-documents, you're managing a lot of type parameters by hand. But that's what I have been doing with Mongoose for years anyway!
Papr: very close
Papr is the one that's closest in spirit to what I wanted. It's a thin TypeScript wrapper over the official MongoDB driver with proper schema inference. No Mongoose, no codegen, good types. I have a lot of respect for the project. But couldn't use it in production.
The problem is the API surface is intentionally minimal. You get find, findOne, insertOne, updateOne, updateMany, deleteOne, deleteMany. The basics. There's no aggregation builder, no populate with type inference, no hooks, no transaction helpers, no index sync. That's a deliberate design choice and it's valid, but it wasn't enough for my use cases. Anything beyond basic CRUD meant dropping back to raw MongoDB driver calls, which defeats the purpose.
So I built Mongster
Papr's philosophy (thin wrapper, native driver, real types) but with the full feature set I actually needed from Mongoose.
The Main Bet
I built Mongster one central idea: one schema should do everything.
That single schema declaration:
- Should drive runtime validation: Real errors with field paths and cause chains. No silent corruption.
- Should drive TypeScript inference: Types flow from the schema, you never have to write them by hand!
- Should carry index metadata: Indexes live with the fields they describe.
Everything else:
The model API, filters, updates, projections, populate, and aggregation - all flows from that one schema. There is no need for manual generics. There is no as any in normal use. When you write a bad filter, TypeScript tells you before MongoDB gets a chance to shrug.
Under the hood, Mongster is a thin wrapper over the official mongodb Node.js driver. Single runtime dependency - with no magic query layer, no vendored BSON. Just typed ergonomics on top of something that already works well.
I am not hating on Mongoose
When I started building Mongster, I tried to keep the APIs as similar as possible to Mongoose. I tried with the exact API Mongoose schema builder has... but I failed.
Mongoose was built before the TypeScript era. All the APIs are written beautifully for raw JavaScript. Making that schema API "type-safe" would make me go balder than I already am.
The eureka moment
A couple weeks later while walking back to home, I realized Zod has a schema builder with types! I have been using it for a long time. I can just do a builder pattern!
And so it started. My tug-of-war with TypeScript. I learned about the TypeScript types which I never had to on a day-to-day work. The Michigan Typescript channel was a big help.
After 3 months of weekend-coding, I finally had a good enough ODM that worked.
A 30-Second Tour
Defining a schema:
import M from "mongster";
const userSchema = M.schema({
name: M.string().min(1),
email: M.string().match(/^[^\s@]+@[^\s@]+\.[^\s@]+$/),
role: M.string().enum(["admin", "member", "viewer"]),
}).withTimestamps();
type User = M.infer<typeof userSchema>;
// {
// _id: ObjectId;
// name: string;
// email: string;
// role: "admin" | "member" | "viewer";
// createdAt: Date;
// updatedAt: Date;
// }
type UserInput = M.inferInput<typeof userSchema>;
// Same; only _id, createdAt, and updatedAt are optional.
Create a model and query:
import { mongster, model } from "mongster";
await mongster.connect("mongodb://localhost:27017/mongster");
const User = model("users", userSchema);
const user = await User.findOne({ role: "admin" }); // fully typed
const bad = await User.findOne({ roles: "admin" }); // TS error: 'roles' does not exist
Populate a reference:
const postSchema = M.schema({
title: M.string(),
authorId: M.objectId().ref(() => User), // typed reference
published: M.boolean().default(false),
});
const Post = model("posts", postSchema);
// TypeScript knows authorId is now a User, not an ObjectId
const posts = await Post.find({ published: true })
.populate("authorId", { select: ["name", "email"], excludeId: true });
Run a typed aggregation:
const stats = await Post.aggregate()
.match({ published: true })
.group("$authorId", { totalPosts: { $sum: 1 } })
.sort({ totalPosts: -1 })
.limit(10)
.exec();
Clean, composable, type-checked end to end.
How the Typing Works
Two Type Spaces
Every schema exposes two distinct TypeScript shapes, and keeping them separate is one of the design decisions I'm most satisfied with.
M.infer<typeof schema> is the storage type: what a document looks like when you read it from MongoDB. All fields present, ObjectIds are ObjectId, timestamps are Date.
M.inferInput<typeof schema> is the input type: what you pass to create() or insertOne(). Optional fields can be omitted, fields with .default() don't need to be provided, _id and updatedAt are absent.
Mongoose collapses these into one shape, which forces you to either over-specify (marking _id as required when it isn't on insert) or under-specify (losing required-field guarantees). Mongster explicitly makes the distinction between what goes in and what comes out,
Filter and Update Inference
The filter type knows your schema. This means:
// ✅ Valid
await User.find({ role: { $in: ["admin", "member"] } });
// ❌ TS error: Type '"superadmin"' is not assignable to type 'RegExp | BSONRegExp | "admin" | "member"'.
await User.find({ role: { $in: ["admin", "superadmin"] } });
// ❌ TS error: Type 'number' is not assignable to type 'undefined'.
await User.updateMany({ role: "admin" }, { $inc: { name: 1 } });
Update operators ($set, $inc, $push, $setOnInsert, and friends) are checked against the types of the fields they target. Projection is typed too — find().include(["name", "social.github"]) narrows the return type to only those fields, including nested dot-notation paths.
The Interesting Internals
Populate as an Aggregation Pipeline
Most ODMs implement populate as 2 separate queries: fetch the documents, extract the IDs, fire a second query, merge results.
Mongster implements populate as a $lookup aggregation stage under the hood. This means it composes naturally with sort, limit, and project. You declare the ref once on the schema field with .ref(() => Model). Mongster builds the right lookup stage from it at query time. There is no second round-trip.
Index Hashing and Sync
syncIndexes() sounds like a boring feature until you've spent an afternoon debugging index drift between environments.
When you call it per model, or globally with mongster.syncIndexes(), Mongster compares the indexes declared in your schema against the ones actually present in MongoDB. It normalizes both sides into a canonical form, hashes them, and diffs:
- Unchanged indexes are untouched
- Missing indexes are created
-
Extra indexes (present in DB but absent from schema) are dropped when
autoDropis enabled
The hashing and normalization logic is correct and reliable, but not glamorous or clever. I'll leave it up to you to check it out if you want to.
Transactions Without Ceremony
MongoDB transactions require a ClientSession threaded through every operation. In practice, this means adding an optional session param everywhere and praying someone doesn't forget it.
I took a different approach with Mongster:
await mongster.transaction(async (ctx) => {
const TxUser = ctx.use(User);
const TxLog = ctx.use(Log);
await TxUser.updateOne({ _id: userId }, { $set: { role: "admin" } });
await TxLog.insertOne({ action: "role_change", userId });
});
ctx.use(Model) returns a transaction-scoped model that silently injects the session into every operation — no monkey-patching, no global state, no forgotten params. The transaction lifecycle is handled automatically.
Hooks With Predictable Order
Hooks fire in a deterministic sequence:
model.pre → schema.pre → query → schema.post → model.post
When both schema-level and model-level hooks exist for the same event, you always know what fires first. Supported lifecycle events cover insert, find, update, delete, bulkWrite, and their aliases (save, modify, remove).
What's Missing
This post won't be complete without these honest parts:
-
Populate only works on top-level scalar refs. No
M.objectId().ref(...)inside arrays, and no nested-path refs yet. -
.ref()is terminal. Chaining.optional()or.default()after it might break your queries. -
Aggregation expression operators aren't typed. Stages like
$match,$group, and$sortare typed. Expression operators like$toUpper,$cond, and$addare not. You have to use.raw()for those pipelines with manual type-casting for now. - Transactions require a replica set. This is a MongoDB constraint. But worth noting because I wasted a few hours debugging this.
- No aggregation hooks yet.
Try It
npm install mongodb mongster
One runtime dependency. Everything else is inference.
- npm: mongster
- GitHub: IshmamR/mongster
- docs: mongster.ishmam.dev
If you have ever found yourself fighting Mongoose's type system more than writing your actual application code, I think the schema-first approach here is worth an afternoon. The types are the foundation everything else is built from in Mongster.













