Differences from Mongoose
What works another way than in Mongoose, such as explicit writes instead of save(), filters without casting, __v and an explicit sync().
MongoZod keeps the driver's way of working with documents: a read returns the document as MongoDB
stores it, and each write is an operation that says what changes. Most differences with Mongoose
come from there. The habit that changes the most is save():
const post = await Post.findById(id)
post.status = 'published'
post.tags.push('mongodb')
await post.save()In MongoZod, the change is the update:
import { ObjectId } from 'mongodb'
import { mongozod } from 'mongozod'
import * as mz from 'mongozod/mzod'
import * as z from 'zod'
const postSchema = z.object({
_id: mz.objectId()
.default(() => new ObjectId()),
title: z.string()
.min(1),
body: z.string()
.default(''),
status: z.enum(['draft', 'published'])
.default('draft'),
tags: z.string()
.array()
.default([]),
authorId: mz.objectId(),
__v: z.number()
.int()
.default(0)
})
const PostsModel = mongozod({
collection: 'posts',
db,
schema: postSchema
})
const id = mz.objectId().parse('64f1c2a9e4b0a1b2c3d4e5f6')
await PostsModel.updateOne(
{ _id: id },
{ $set: { status: 'published' }, $push: { tags: 'mongodb' } }
)The cheatsheet has the equivalent of each feature. This page explains the differences that change how you write code.
No documents and no save()
Mongoose returns documents: instances of the model that track your changes, which save() sends as
an updateOne() of the modified paths
(Mongoose docs). MongoZod returns
the driver's plain objects, like a Mongoose query with lean()
(Mongoose docs), and writes with the driver's
operations:
| With a Mongoose document | With MongoZod |
|---|---|
new Post(data).save() | insertOne(data), which returns the stored document |
post.title = title, then post.save() | updateOne({ _id }, { $set: { title } }) |
post.tags.push(tag), addToSet, pull | $push, $addToSet, $pull |
A document changed in memory, then save() | replaceOne({ _id }, post), which validates the whole document |
post.deleteOne() | deleteOne({ _id }) |
post.isModified('title') | No equivalent: the update says what changes |
| An instance method | A function that takes the document |
Every write is validated with the schema, not only save(): an insert and a replacement as a whole
document, and an update value by value, with the schema of each path and the rules of each operator.
Mongoose runs its update validators only with runValidators, and only on some operators
(Mongoose docs). See
Validation.
Middleware on save has no operation to attach to. Mongoose does not run it on updateOne() or
findOneAndUpdate() either (Mongoose docs).
A pre('save') that changes a field becomes a .transform() of the field, which runs on every
write of that field (see Transforms), and the rest becomes a
plugin.
Updates take operators
Mongoose treats the keys of an update that are not operators as a $set
(Mongoose docs). MongoZod
passes the update to the driver as it is, so it takes operators only:
// @ts-expect-error: "status" is not an update operator
await PostsModel.updateOne({ _id: id }, { status: 'published' })
await PostsModel.updateOne({ _id: id }, { $set: { status: 'published' } })Without types, the first one throws a ZodError: "status" is not an update operator. To write the
whole document, use replaceOne.
Filters are not cast
Mongoose casts each value of a filter to the type of its field when the query runs, such as '50'
to 50 or a hex string to an ObjectId, and throws a CastError when it cannot
(Mongoose docs). MongoZod leaves filters
as they are, as the driver does, and checks them with their types instead: a value of the wrong type
does not compile.
// @ts-expect-error: a string is not an ObjectId
await PostsModel.findOne({ _id: '64f1c2a9e4b0a1b2c3d4e5f6' })
// @ts-expect-error: status is a string, so an array needs $in
await PostsModel.find({ status: ['draft', 'published'] })
await PostsModel.findOne({ _id: mz.objectId().parse('64f1c2a9e4b0a1b2c3d4e5f6') })
await PostsModel.find({ status: { $in: ['draft', 'published'] } })- Without types, such as in JavaScript or with a filter typed
any, a value of another type matches nothing, without an error:{ _id: '64f1…' }finds no document whose_idis anObjectId. An object of operators is worse:{ email: { $ne: null } }matches every document, because MongoDB reads it as an operator. A request body is often typedany, so it compiles. - What a client sends arrives without types, so parse it first:
mz.objectId().parse(id)for an id, andmz.filterfor a whole filter, which converts each value with the schema of its field and accepts only what the endpoint declares. This replaces Mongoose'ssanitizeFilter: model methods don't sanitize at runtime (see Security). - Keys that are not paths of the schema do not compile, where Mongoose's
strictQuerykeeps or drops them (Mongoose docs). Anundefinedvalue does not compile either, and the model throws aZodErrorfor one that arrives typedany: it would match the documents without the field, or every document withignoreUndefined. - Pipelines: Mongoose does not cast them
(Mongoose docs), so a string for an
ObjectIdin a$matchfinds nothing (Automattic/mongoose#1399). MongoZod types the$matchstages at the start of a pipeline like a filter (see Aggregation).
Reads are plain and not validated
A read returns what the driver returns, with the virtuals and the populated references you ask for. MongoZod does not run the schema on it:
- No getters: a transform runs when a value is written, not when it is read.
- No defaults on old documents: a document stored before a field or its
.default()existed is read without that field, although its type says it is there. Migrate those documents when you add the field (see Schema migrations). - No validation: a stored document that does not fit the schema is read as it is. Before you
switch a collection to MongoZod, check its documents with
safeParse(see Migrating from Mongoose).
insertMany validates the whole batch
Mongoose validates each document before it inserts, and with ordered: false it inserts the valid
ones and reports the others (Mongoose docs).
MongoZod validates every document first, also with ordered: false: one invalid document rejects
the batch, nothing is written, and a single ZodError lists the issues of every invalid document
under its index, such as [1, 'title']. ordered: false still lets the other documents through
when the server rejects one, such as for a duplicate key. bulkWrite works the same way. See
Validation.
An explicit sync()
When a Mongoose app starts, each model creates its collection and calls createIndex for each index
of its schema, unless its autoCreate and autoIndex options are off
(Mongoose docs). The Mongoose docs recommend
turning the creation of indexes off in production
(Mongoose docs), and syncIndexes() also drops
the indexes the schema does not declare
(Mongoose docs).
MongoZod does nothing when the app starts, unless a model uses the opt-in
autoIndex plugin, which creates its indexes on its first operation.
sync() creates the declared indexes, and applies the $jsonSchema when the model asks for it,
when you call it, such as from a deploy script. It never drops an index: it returns the ones the
model does not declare in extraIndexes. MongoDB creates the collection on the first write.
const { indexes, extraIndexes } = await PostsModel.sync()While Mongoose and MongoZod share a collection, Mongoose keeps creating the indexes of its schema when it starts. An index that both declare with the same keys and options exists once. See Sync.
__v and optimistic concurrency
Mongoose writes a version key, __v, in each document it creates, and only save() changes it
(Mongoose docs). With optimisticConcurrency,
save() fails when the document changed since it was read
(Mongoose docs).
MongoZod does not know about __v. An update that does not write it keeps it, but a z.object
drops the keys it does not declare, so a replaceOne or a findOneAndReplace removes it. While
Mongoose still writes the collection, declare it as postSchema does above,
__v: z.number().int().default(0), so that the documents MongoZod inserts start from 0, as
Mongoose's do, and Mongoose's save() works on them. A replacement keeps it when you pass the
document you read. Leave it out if the Mongoose schema sets versionKey: false.
Optimistic concurrency is planned as a plugin. Until then, write it in the update: filter by the version you read, and increase it.
const read = await PostsModel.findOne({ _id: id })
if (read) {
const { matchedCount } = await PostsModel.updateOne(
{ _id: read._id, __v: read.__v },
{ $set: { title: 'Hello again' }, $inc: { __v: 1 } }
)
// matchedCount is 0 when another write changed the post since the read.
}Once Mongoose no longer writes the collection and you remove __v from the schema, the stored
documents keep it. Remove it from them through the driver's collection, which takes any path:
await PostsModel.collection.updateMany({}, { $unset: { __v: '' } })Smaller differences
- Collection names, optional fields and required strings: Mongoose derives the collection name
from the model, and a Zod key is required unless it is
.optional(). See Write the Zod schema and the cheatsheet. - Empty objects: Mongoose removes them by default, with its
minimizeoption (Mongoose docs, Automattic/mongoose#8505). MongoZod stores them. - Upserts: Mongoose adds the defaults of the schema to the document an upsert inserts, with
$setOnInsert(Mongoose docs). MongoZod does too, and also validates that document: an upsert that would insert one without a required field throws aZodError(Automattic/mongoose#7037). See Validation. findOneAndUpdate: both return the document as it was before the update, by default (Mongoose docs). Mongoose'snew: trueis the driver'sreturnDocument: 'after'.- Discriminator key: Mongoose adds its own field,
__tby default (Mongoose docs). In MongoZod the key of az.discriminatedUnionis a field of the schema, so for documents written by Mongoose it is__t, or thediscriminatorKeyof its schema (see Document types). - Errors: a validation throws Zod's
ZodError, not aValidationError, and there is noCastError. The driver's errors, such as a duplicate key, are the same in both. - Transactions: MongoZod takes the session in the options of each operation,
{ session }, also when Mongoose'stransactionAsyncLocalStorageis on (see Share the connection).
Not there yet
Some Mongoose features have no equivalent yet. These are planned:
select: false,immutableandalias, as plugins or recipes. Until then, leave a field out with a projection in each read, such as{ projection: { password: 0 } }, and leave the fields that never change out of the allow lists ofmz.update.- A version plugin, for optimistic concurrency with
__v(see above). - Field-level encryption declared in the schema.
- A plugin for the integrity of references, with cascading deletes.
- Temporal codecs. Dates are
Dates, and a date without a time, such as a birthday, is a string ofz.iso.date()(see Dates and money). refPathand thecountoption ofpopulate.
Others have no plan yet: virtual setters, getters, timestamps in subdocuments, the populate of a
Map, Model.populate() on documents already read, and global plugins.
Common mistakes
- A projection as the second argument of
find: it is the options, as in the driver, sofind(filter, { title: 1 })does not compile, and without types the driver ignores the unknown option and returns whole documents. Writefind(filter, { projection: { title: 1 } }). - An update without operators:
updateOne(filter, { title })does not compile and throws aZodError. Write{ $set: { title } }. - A string where an
ObjectIdgoes: it does not compile, and without types it finds nothing. Parse it withmz.objectId(), or take the filter throughmz.filter. - Strings for
sort,projectionorpopulate, such assort: '-createdAt': they do not compile, and they throw aZodError. Write objects:{ createdAt: -1 },{ title: 1 },{ authorId: true }. - Changing a read and expecting it to be saved: a read is a plain object. Write the change with
updateOne, or the whole document withreplaceOne. - Chaining Mongoose's query methods: the cursor of
findchainssort,limit,skipandprojectonly.populate,lean()andexec()do not exist:populategoes in the options, and the other two are not needed.
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.