MongooseJS A.K.A Mongoose is a MongoDB object modeling tool for Node.js. It is intended to reduce the boilerplate code required to interact with MongoDB and, crucially, make your life easier.
It didn't make our lives easier. 🤯
And in this article I'll tell you why.
Mongoose was written in JavaScript in 2010 by Valeri Karpov and is now maintained by Automattic. Mongoose is a popular choice for many developers, and is one of the go-to libraries for MongoDB in Node.js. Given the prevalence of MongoDB, it's a common choice for many developers to use in their stack.
TypeScript came around in 2012 and was a major game changer for the JavaScript ecosystem. It brought static typing to the table and made it easier to write code that was easier to reason about. Nowadays TypeScript is becoming ubiquitous, over 67% of developers use it!.
TypeScript arriving after Mongoose caused issues: rather than rewriting Mongoose to use TypeScript, Mongoose remained in JavaScript and added type safety as a layer on top, an unfortunately common approach in the JavaScript ecosystem. Done right, this is fine and works well to support legacy or non-TypeScript libraries. Done wrong, it can cause a lot of pain (#foreshadowing).
Like many other companies, we began using MongoDB thanks to its ease of use, scalability and, most importantly, flexibility while developing. We were able to build a lot of our infrastructure quickly and easily, and we were able to iterate quickly on our product.
We chose Mongoose as our modelling layer for MongoDB because of its verbose docs and seemingly universal usage.
As our product grew, we started making changes to our data model, migrating collections, databases, and more. As this continued we experienced weird issues in CI and production where records were missing fields, had extra fields that had previously been removed from past migrations, and nested documents that were no longer nested or had been pushed out into their own collection.
So we sat down and ran through the database layer. This is where we discovered the issues.
Let's take mongoose's own example straight from their homepage, with a little modification to make it more interesting:
import mongoose, { model, Schema } from 'mongoose'; // ESM import for this example
mongoose.connect('mongodb://127.0.0.1:27017/test');
const Cat = mongoose.model('Cat', { name: String, age: Number }); // added age field
const kitty = new Cat({ name: 'Zildjian', age: 4 }); // added age field
await kitty.save() // awaited save() instead of then()
This is fine in JS, let me add some TypeScript goodness to it:
import mongoose, { model, Schema } from 'mongoose'; // ESM import for this example
mongoose.connect('mongodb://127.0.0.1:27017/test');
type Cat = {
name: string;
age: number;
}
const CatSchema = new Schema<Cat>({
name: { type: String },
age: { type: Number },
});
const Cat = model<Cat>('Cat', CatSchema);
const kitty = new Cat({ name: 'Zildjian', age: 4 });
await kitty.save()
Awesome, now let's query our cat from the database:
const cat1 = await Cat.findOne({ name: 'Zildjian' })
if(cat1 === null) throw new Error('Cat not found')
console.log(cat1.name) // 'Zildjian'
console.log(cat1.age) // 4
Looks good, right? Well, yes, assuming you're a superhuman developer who never makes mistakes...
const cat2 = new Cat({ foo: 'bar' });
await Cat.create({ foo: 'bar' });
const cat3 = new Cat({ name: false, age: 'invalid' })
const cat4 = new Cat({ name: null, age: 'i dont age', hasSuperPowers: true })
const cat5 = new Cat({ name: undefined, age: false, isRunningForPresident: 'yes' })
This flat out shouldn't work. Every TypeScript developer reading this right now is thinking "that won't compile". But they're wrong, it does!
Mongoose is incredibly lazy with its type checking. There's no validation that the fields you're passing to the model are actually valid for the schema or the type. This means you can provide variables of different types, e.g. a string for the age field or a boolean for the name field. Worse, you can add more fields than the schema defines and Mongoose doesn't care. Only at runtime will Mongoose enforce the schema and throw an error. The whole point of TypeScript is to catch these kinds of errors at compile time to save the hassle of debugging types at runtime (alongside regular, non-type related errors).
The only way to enforce compile-time type checking is to specify the generic type yourself:
const cat2 = new Cat<Cat>({ foo: 'bar' });
Which now won't compile, complaining about the foo field not being valid for the Cat type.
Most people turn around and go:
or
But you won't, you'll end up forgetting the generic type parameter at some point and then have to debug runtime errors to trace down a bug in you types. This is a long feedback loop of trace error -> fix types / fields -> compile -> run code / engineer error circumstances again -> repeat. It would be a lot easier if the types were enforced at compile-time by mongoose itself (which is a reasonable request, mongoose just uses type safety poorly in its API). Isn't this the whole point of TypeScript?
Now let's fix those runtime errors from before. We'll add the missing fields to the Cat type.
type Cat = {
name: string;
age: number;
hasSuperPowers: boolean;
isRunningForPresident: string;
}
And make a new cat with the new fields.
const cat6 = new Cat({ name: 'Zildjian', age: 4, hasSuperPowers: true, isRunningForPresident: 'yes' });
await cat6.save()
const cat7 = await Cat.findOne({ name: 'Zildjian' })
if(cat7 === null) throw new Error('Cat not found')
console.log(cat7.name) // 'Zildjian'
console.log(cat7.age) // 4
console.log(cat7.hasSuperPowers) // undefined
console.log(cat7.isRunningForPresident) // undefined
Huh? I specifically gave my cat super powers and made it run for president, but now they're undefined?!
Here's the issue:
type Cat = {
name: string;
age: number;
hasSuperPowers: boolean;
isRunningForPresident: string;
}
const CatSchema = new Schema<Cat>({
name: { type: String },
age: { type: Number },
});
I forgot to add the fields to the schema! This is an incredibly common mistake and easy trap to fall into. Mongoose enforces only the schema you specify when creating the model. TypeScript enforces that the generic type parameter in the model() function against the types in Mongoose for the Schema class. And this is where things fall apart. Mongoose's types fall short and do not check the generic type (Cat) against the schema being passed in. It should detect that there's two fields missing, but it doesn't - it just carries on.
If you're working on a sizeable codebase, you very likely have your schemas defined elsewhere to your types, making it difficult to spot differences. Further, your developers are (mostly) human and will forget to check the types line up against the schemas. Any project being actively developed will have changing types/schemas semi-frequently. Add into the mix several different types, schemas and models and you've got a recipe for disaster.
Now let's say my colleague has added a very similar type to our codebase:
type Dog = {
name: string;
age: number;
hasSuperPowers: boolean;
isRunningForPresident: string;
breed: string;
}
And their vibe-coding AI helper has mistakingly touched our code as so:
const CatSchema = new Schema<Dog>({
name: { type: String },
age: { type: Number },
hasSuperPowers: { type: Boolean }, // now present
isRunningForPresident: { type: String }, // now present
});
This compiles. It even runs correctly, no runtime errors! All our code from before is seemingly fine. But later down the line, you'll change the Dog type (due to business logic changes in handling Dogs) and all of your Cat related code may suddenly break or suffer from bugs. You're ensuring pain somewhere down the line for future you. Mongoose makes no effort to ensure models are typed such that they cannot be substituted for other structurally compatible models, which it really should do.
This issue occurs because TypeScript is a structural language, two types containing the same fields and field types are considered equal, even if one of those types contains extra fields compared to the other. This trips a lot of developers up because they're coming from languages with nominal typing, where two types are considered equal if and only if they have the same name (i.e. Dog != Cat). Now you could argue that this is a TypeScript issue, but I believe flexibility is a key feature of TypeScript and its type system should be structural rather than nominal. Nominal typing can easily be implemented on top of TypeScript's structural typing using branded types anyway, whereas structural typing cannot be implemented on top of nominal typing.
So why doesn't Mongoose use branded types to enforce model differentiation? Who knows!
At Prosopo, we suffered from these issues for a while before realising what had occurred, as we suspected other code / bugs were responsible. We've rectified the issues now, but had to do so by manually lining up types with schemas. It's only a matter of time before someone forgets to keep a type in sync with a schema and before we know it we'll be debugging the same issues all over again.
We looked at solving these issues via a library, but the only fit for us was Typegoose. Typegoose unfortunately still leaves some type safety gaps so doesn't 100% solve the issue, and worse does not play nicely with esbuild which we use throughout our codebase. 🙃
With no fix on the horizon, we are actively looking at replacements for Mongoose, such as MikroORM, Prisma or TypeORM. Alternatively, we are considering using Zod to validate our data and hit the MongoDB driver directly, as we already use Zod for runtime type validation anyway.
We're flabbergasted that nowadays Mongoose still doesn't have full - or even good - type safety, and we are not alone.
It is concerning that Mongoose has such a huge following and is highly recommended to newcomers via tutorials and such when it has such glaring issues with type safety. In the over a decade that TypeScript has been around, it's a shame that Mongoose has not added type safety to its library given how widespread and popular TypeScript has become.
Thanks to all of these issues regarding Mongoose, we no longer recommend other developers to use it. New packages and projects that we've got on the horizon will no longer use Mongoose and we'll shortly be transitioning away to a replacement in current packages and projects. In the future, if Mongoose fixes its type safety we would consider reverting back to it, but after being burned so much already we will need convincing that the types are rock solid.