Why MongoZod
MongoZod as a Mongoose alternative for TypeScript, compared with Mongoose, Typegoose, the driver and Zod by hand and other libraries, the Mongoose issues it solves and when not to use it.
MongoZod is a Mongoose alternative for TypeScript: one Zod schema validates every write, types every query and defines the inputs of each endpoint, on the official MongoDB driver.
A project on MongoDB and TypeScript usually describes each document three times: in a Mongoose schema, in a TypeScript type, and in the Zod schemas that validate what its endpoints receive. They drift apart, and each one checks something different:
import mongoose from 'mongoose'
import * as z from 'zod'
// For TypeScript
interface Post {
title: string
status: 'draft' | 'published'
tags: string[]
createdAt: Date
updatedAt: Date
}
// For MongoDB: the same fields again
const postSchema = new mongoose.Schema<Post>(
{
title: { type: String, required: true, minlength: 1 },
status: { type: String, enum: ['draft', 'published'], default: 'draft' },
tags: { type: [String], default: [] }
},
{ timestamps: true }
)
const PostModel = mongoose.model<Post>('Post', postSchema)
// For the endpoint: the same fields a third time
const createPostInput = z.object({
title: z.string()
.min(1),
status: z.enum(['draft', 'published'])
.optional(),
tags: z.string()
.array()
.optional()
})MongoZod keeps one description, the Zod schema, and builds the rest from it: the model validates every write with it, the types of every query and result come from it, and the inputs of the endpoints are derived from it:
import { MongoClient, ObjectId } from 'mongodb'
import { mongozod } from 'mongozod'
import * as mz from 'mongozod/mzod'
import * as z from 'zod'
const client = new MongoClient('mongodb://localhost:27017')
const postSchema = z.object({
_id: mz.objectId()
.default(() => new ObjectId()),
title: z.string()
.min(1),
status: z.enum(['draft', 'published'])
.default('draft'),
tags: z.string()
.array()
.default([]),
createdAt: mz.date(),
updatedAt: mz.date()
})
const PostsModel = mongozod({
collection: 'posts',
db: client.db(),
schema: postSchema,
timestamps: true
})
const createPostInput = postSchema.pick({
title: true,
status: true,
tags: true
})
type Post = z.output<typeof postSchema>Compared with Mongoose
| Mongoose 9 | MongoZod | |
|---|---|---|
| Schema | A Mongoose schema, apart from what validates your endpoints | One Zod schema for the document, its types and the inputs of your endpoints |
| Document type | Inferred only from a schema written inline; otherwise an interface kept in sync by hand, or the classes and decorators of Typegoose | The type of the Zod schema, wherever you declare it, with no interface and no decorators |
| Update validation | Off by default, and only on $set, $unset, $push, $addToSet, $pull and $pullAll | Always, on every operator, with refinements, and on the document an upsert would insert |
| Required fields | Not checked by an update unless it $unsets them | An upsert fails when the document it would insert misses one |
| Filters | Cast at runtime: a value that cannot be cast throws a CastError | Typed: a value of the wrong type does not compile, and what clients send is parsed by mz |
| Results | Documents that track changes, or plain objects with lean() | The driver's plain objects, typed by the projection and populate |
| Endpoint inputs | Not covered | mongozod/mzod: MongoDB syntax from clients, limited to what each endpoint declares |
| Indexes | Created when the app starts, unless you turn autoIndex off | Created by sync() when you call it, or on the first operation with the opt-in autoIndex plugin |
| Driver | Mongoose's own API on top of the driver | The driver's names, options and results, and the driver itself for everything else |
Both libraries can share one connection, so a project can move one model at a time. The cheatsheet maps each Mongoose feature to its equivalent, and Differences lists what works another way.
Compared with the driver and Zod by hand
The driver with a Zod schema already validates an insert: parse the document, then insert the result. The rest is what a team ends up writing again in each project:
- Updates: an update is a set of operators on paths, not a document. MongoZod validates the
value of each path with the schema of that field, applies the rules of each operator (
$incon numbers,$unseton optional fields, array operators on arrays) and, for an upsert, builds and validates the document MongoDB would insert. - Types: the driver types a filter loosely. MongoZod checks every filter, update, projection and sort against the schema, dotted paths included, and narrows the result of a projection.
- Relations and computed fields:
populate, virtuals and timestamps, each typed in the reads that use it. - Inputs: a client that sends a filter, a sort or a page needs an allow list per field, a
parser for each value and a type that fits the driver.
mzbuilds them from the schema. - Cross-cutting rules: plugins wrap every operation, like the official soft delete.
Compared with other libraries
Other libraries that work with MongoDB and TypeScript, and what MongoZod chooses instead:
- Papr is also a thin layer over the driver. It defines
schemas with its own helpers and validates writes with a
$jsonSchemaon the server. MongoZod validates each write in the app with Zod, refinements and transforms included, and can also apply a$jsonSchemagenerated from the schema withsync(). - zod-mongoose writes Mongoose schemas with Zod, and Mongoose runs underneath. MongoZod works on the driver, with its names, options and results.
- Prisma describes the data in its own schema language and generates a client. Prisma ORM 7 does not support MongoDB yet, and its docs point to 6.19 for it. MongoZod keeps the description in TypeScript, as a Zod schema, with nothing to generate.
- TypeORM and MikroORM model entities as classes with decorators, and TypeORM calls its MongoDB support basic. MongoZod reads and writes plain objects, with no classes and no decorators.
Mongoose issues it solves
Open issues of Mongoose that MongoZod handles by design:
- Validating updates: the required fields of an upsert
(#7037), the subdocuments of a
findOneAndUpdate(#5234) and every error of an update at once, not only the first (#3037). - Types: projections with dotted paths
(#16575) and a typed
populate(#16010). - Queries: a filter value outside an enum does not compile (#15581).
- Discriminators: several levels, the most upvoted open issue
(#2851, also
#8361 and
#9756), with a
z.discriminatedUnioninside another. See Document types. - Populate: a
limitper document (#15863) and a reference stored in another key (#9034). - Field types: GeoJSON (#6606) and arrays
without repeated values (#8082,
#12916), with
mz.set(). - Soft delete, as an official plugin (#7304).
- Explicit
sync(): indexes and collections are not created on their own (#15803, #13546), unless you opt in toautoIndexfor the indexes.
Mongoose's types have also slowed editors down (#10349). MongoZod's CI fails when the type instantiations of a typical project go over a budget, so a release cannot make your editor slower without anyone noticing.
When not to use it
MongoZod is not the right fit, or not yet, when a project needs:
- Documents with methods and change tracking, like Mongoose's
doc.save(). MongoZod writes with explicit operations, as the driver does. - Filters cast at runtime from untyped values. MongoZod types them instead, and parses what
clients send with
mz. - Zod 3, Node.js before 22.15 or TypeScript before 5.5.
- Some Mongoose features that are planned but not there yet:
select: false,immutableandaliason fields, optimistic concurrency with a version field, field-level encryption, and a plugin that keeps references consistent on delete.
Last updated on
MongoZod is an independent open-source project. It is not affiliated with, endorsed by or sponsored by MongoDB, Inc. MongoDB® is a registered trademark of MongoDB, Inc.