MongoZod

Mongoose cheatsheet

Each Mongoose feature with its MongoZod equivalent, from schema types and options to queries, populate, virtuals, timestamps, middleware and indexes.

Each Mongoose feature and what replaces it in MongoZod. The schema is a Zod schema of the document as MongoDB stores it, and the model has the driver's operations. A Mongoose schema:

import { Schema, model } from 'mongoose'

const userSchema = new Schema(
  {
    name: { type: String, required: true, trim: true },
    email: { type: String, required: true, lowercase: true, match: /^\S+@\S+$/ },
    role: { type: String, enum: ['reader', 'author', 'admin'], default: 'reader' },
    age: { type: Number, min: 13 },
    avatar: Buffer,
    links: { type: Map, of: String },
    addresses: [{ city: String, zip: String }]
  },
  { timestamps: true }
)

userSchema.index({ email: 1 }, { unique: true })

export const User = model('User', userSchema)

The same in MongoZod:

import { ObjectId } from 'mongodb'
import { definePlugin, mongozod } from 'mongozod'
import * as mz from 'mongozod/mzod'
import * as z from 'zod'

const userSchema = z.object({

  _id: mz.objectId()
    .default(() => new ObjectId()),

  name: z.string()
    .trim()
    .min(1),

  email: z.string()
    .toLowerCase()
    .regex(/^\S+@\S+$/),

  role: z.enum(['reader', 'author', 'admin'])
    .default('reader'),

  age: z.number()
    .min(13)
    .optional(),

  avatar: mz.binary()
    .optional(),

  links: z.record(z.string(), z.string())
    .optional(),

  addresses: z.object({

    _id: mz.objectId()
      .default(() => new ObjectId()),

    city: z.string()
      .optional(),

    zip: z.string()
      .optional()
  })
    .array()
    .default([]),

  createdAt: mz.date(),

  updatedAt: mz.date()
})

const UsersModel = mongozod({

  collection: 'users',

  db,

  schema: userSchema,

  indexes: (index) => [
    index({ email: 1 }, { unique: true })
  ],

  timestamps: true
})

The rest of the page also uses posts and their comments:

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(),

  createdAt: mz.date(),

  updatedAt: mz.date()
})

const commentSchema = z.object({

  _id: mz.objectId()
    .default(() => new ObjectId()),

  postId: mz.objectId(),

  body: z.string()
    .min(1),

  createdAt: mz.date(),

  updatedAt: mz.date()
})

const PostsModel = mongozod({

  collection: 'posts',

  db,

  schema: postSchema,

  references: {

    authorId: () => UsersModel,

    comments: {

      model: () => CommentsModel,

      foreignField: 'postId'
    }
  },

  timestamps: true
})

const CommentsModel = mongozod({

  collection: 'comments',

  db,

  schema: commentSchema,

  references: {

    postId: () => PostsModel
  },

  timestamps: true
})

Schema types

From SchemaTypes in the Mongoose docs:

MongooseMongoZod
new Schema({ … })z.object({ … })
Stringz.string()
Numberz.number(), or z.int() for integers
Booleanz.boolean()
Datemz.date(), which also takes the ISO string a JSON client sends, or z.date()
ObjectIdmz.objectId(), which also takes the hex string
Decimal128mz.decimal128()
Buffermz.binary()
UUIDmz.uuid()
BigIntz.bigint(), with useBigInt64: true on the client (see Schemas)
Int32z.int32()
Mixedz.unknown(), or a schema of its shape
Map with ofz.record(z.string(), …): a z.map() does not come back from MongoDB, so a model rejects it
Unionz.union([…]), or z.discriminatedUnion()
[String], an arrayz.string().array().default([]): Mongoose arrays default to []
A nested object or a subdocumentz.object({ … }), with the _id that Mongoose gives each subdocument
GeoJSON as { type: { type: String }, coordinates: [Number] }mz.point(), mz.polygon() and the rest of the GeoJSON types
An array of unique values, by handmz.set()

Field options

From the SchemaType options:

MongooseMongoZod
No required.optional(): a Zod key is required unless it says so. .nullable() too if the field stores null.
required: trueNothing: Zod keys are required
required: true on a String.min(1): Mongoose's required rejects '' (Mongoose docs)
default: value, default: () => ….default(value), .default(() => …)
enum: [ … ]z.enum([ … ])
min, max on a number or a date.min(), .max()
minLength, maxLength.min(), .max() on the string
match: /…/.regex(/…/)
trim, lowercase, uppercase.trim(), .toLowerCase(), .toUpperCase(), before the checks that depend on them
validate.refine(), also async (see Validation)
set.transform(), or .overwrite() to keep the type (see Transforms)
getNo equivalent: reads return what is stored. A virtual computes another field.
index, unique, sparseAn index in indexes, such as index({ email: 1 }, { unique: true }) (see Indexes)
expires: 3600 on a dateindex({ createdAt: 1 }, { expireAfterSeconds: 3600 }), a TTL index on the field
refreferences (see Populate)
select: falseNot yet, it is planned. Leave the field out with a projection in each read, such as { projection: { password: 0 } }.
immutableNot yet, it is planned. timestamps already rejects updates of createdAt.
aliasNot yet, it is planned.

Schema options

From the schema options:

MongooseMongoZod
model('User', schema)mongozod({ schema, collection: 'users', db }): the collection is named, not derived from the model
collectioncollection
timestamps: truetimestamps: true, with createdAt and updatedAt in the schema (see Timestamps)
timestamps: { createdAt: 'created_at' }The same: timestamps: { createdAt: 'created_at' }
strict: true, the defaultz.object(), which drops the keys it does not declare
strict: 'throw'z.strictObject(), which rejects them
strict: falsez.looseObject(), which keeps them
_id: false on a subdocumentLeave the _id out of its schema
id, the id virtualA virtual (see IDs)
versionKey, __vDeclared as a field while Mongoose writes (see __v)
optimisticConcurrencyNot yet, it is planned. Filter by the version by hand (see __v)
minimizeNo equivalent: empty objects are stored as they are
autoIndex, autoCreatesync(), which you call, or the opt-in autoIndex() plugin
discriminatorKeyThe key of the z.discriminatedUnion (see Discriminators)
toJSON, toObjectNot needed: reads are plain objects (see Back to JSON)
capped, timeseries, collationCreate the collection with the driver, before sync() (see Models)

Models and operations

From Models and the Model API. A MongoZod model has the driver's operations, with the driver's arguments, options and results. insertOne and insertMany also return the stored documents.

MongooseMongoZod
Model.create(doc), new Model(doc).save()insertOne(doc), which returns the stored document
Model.create([ … ]), Model.insertMany([ … ])insertMany([ … ]), which returns the stored documents
Model.find(filter, projection, options)find(filter, { projection, …options }): the second argument is the options
Model.findOne(filter)findOne(filter)
Model.findById(id)findOne({ _id: id }), with an ObjectId: mz.objectId().parse(id) turns the hex into one
Model.findByIdAndUpdate(id, update, { new: true })findOneAndUpdate({ _id: id }, update, { returnDocument: 'after' })
Model.findByIdAndDelete(id)findOneAndDelete({ _id: id })
Model.updateOne(filter, { title })updateOne(filter, { $set: { title } }): an update takes operators
updateMany, replaceOne, deleteOne, deleteManyThe same names
countDocuments, estimatedDocumentCount, distinctThe same names
Model.bulkWrite([ … ])bulkWrite([ … ]), which validates every operation before writing
Model.exists(filter)findOne(filter, { projection: { _id: 1 } })
Model.aggregate(pipeline)aggregate(pipeline, { schema }) (see Aggregation)
Model.watch()Model.collection.watch(), the driver's
Model.validate(object)schema.parse(object), or parseAsync with async refinements
Model.collection, Model.dbModel.collection and Model.db: the driver's Collection and Db
doc.save(), doc.set(), doc.isModified()No documents: write with an operation (see Differences)
doc.deleteOne()deleteOne({ _id: doc._id })
doc.toObject(), Model.hydrate()Not needed: reads are plain objects
schema.methodsFunctions that take the document
schema.staticsFunctions next to the model, or the methods of a plugin
const id = mz.objectId().parse('64f1c2a9e4b0a1b2c3d4e5f6')

const { document: user } = await UsersModel.insertOne({ name: 'Ana', email: 'Ana@example.com' })
// { _id: ObjectId('…'), name: 'Ana', email: 'ana@example.com', role: 'reader', … }

const renamed = await UsersModel.findOneAndUpdate(
  { _id: user._id },
  { $set: { name: 'Ana María' } },
  { returnDocument: 'after' }
)

// @ts-expect-error: an update takes operators, such as $set
await UsersModel.updateOne({ _id: user._id }, { name: 'Ana' })

const exists = (await PostsModel.findOne({ _id: id }, { projection: { _id: 1 } })) !== null

Queries

From Queries and Query casting. In MongoZod, find returns a cursor that you await for the array, or iterate with for await. Its options are the driver's.

MongooseMongoZod
await query, .exec()await
.lean()Not needed: reads are plain objects
.select('title -body'), .select({ title: 1 }){ projection: { title: 1 } }, or .project({ title: 1 }) on the cursor
.sort('-createdAt'){ sort: { createdAt: -1 } }, or .sort({ createdAt: -1 }) on the cursor
.limit(n), .skip(n){ limit, skip }, or .limit(n) and .skip(n) on the cursor
.where('views').gte(100){ views: { $gte: 100 } }
.populate( … ){ populate: { … } } (see Populate)
.session(session){ session }
.maxTimeMS(), .hint(), .collation(), .comment()The options of the same names
.cursor(), for await over a queryfor await (const post of PostsModel.find())
.orFail()Check for null
Query helpers, schema.queryA function that builds the filter or the cursor
Casting '42' to 42, or a hex string to an ObjectIdNo casting: a value of the wrong type does not compile (see Differences)
An array for a field that is not one, as an implicit $in{ $in: [ … ] }
strictQueryA key that is not a path of the schema does not compile; an undefined value throws a ZodError
sanitizeFilter: trueParse each request value with mz.filter, mz.objectId() or a Zod schema
const recent = await PostsModel.find({ status: 'published', tags: { $in: ['mongodb', 'zod'] } })
  .sort({ createdAt: -1 })
  .limit(20)
  .project({ title: 1, createdAt: 1 })
// Array<{ _id: ObjectId; title: string; createdAt: Date }>

for await (const post of PostsModel.find({ authorId: user._id })) {
  console.log(post.title)
}

// @ts-expect-error: the sort is an object, not a string
await PostsModel.find({}, { sort: '-createdAt' })

Populate

From Populate:

MongooseMongoZod
ref: 'User'references: { authorId: () => UsersModel }
A ref to a collection without a modelreferences: { authorId: { collection: 'users', schema } }
.populate('authorId'){ populate: { authorId: true } }
.populate('authorId', 'name'), select{ populate: { authorId: { projection: { name: 1 } } } }
matchfilter
options: { sort }sort
perDocumentLimitlimit, which counts per document
limit, which counts across documentsNo equivalent: limit is per document
populate inside populatepopulate inside the options of a reference
A virtual populate, with localField and foreignFieldA reference with foreignField: { model: () => CommentsModel, foreignField: 'postId' }
justOne: trueA reference with localField and no foreignField (see Populate)
refPath, the count optionNot yet, they are planned
Model.populate(docs)No equivalent: populate in the read
populate in an aggregationA $lookup stage

A reference that points to a missing document is null, as in Mongoose. See Populate.

await Post.find({ status: 'published' })
  .populate('authorId', 'name')
  .populate({ path: 'comments', options: { sort: { createdAt: -1 } }, perDocumentLimit: 3 })
const withComments = await PostsModel.find(
  { status: 'published' },
  {
    populate: {
      authorId: { projection: { name: 1 } },
      comments: { sort: { createdAt: -1 }, limit: 3 }
    }
  }
)
// Array<{ …post, authorId: { _id, name } | null, comments: Comment[] }>

Virtuals

From Virtuals:

MongooseMongoZod
schema.virtual('fullName').get(function)virtuals: { fullName: schema.pick({ … }).transform(…) }
A virtual setterNo equivalent
toJSON: { virtuals: true }, lean virtualsNot needed: the reads that return documents compute them
A filter or a sort on a virtualNot possible, as in Mongoose: virtuals are not stored
// Another model of the same collection, with a virtual.
const ProfilesModel = mongozod({

  collection: 'users',

  db,

  schema: userSchema,

  virtuals: {

    handle: userSchema
      .pick({ email: true })
      .transform((user) => user.email.split('@')[0] ?? '')
  }
})

const profile = await ProfilesModel.findOne({ _id: user._id }, { projection: { handle: 1 } })
// { _id: ObjectId('…'), handle: 'ana' } | null

See Virtuals.

Timestamps

From Timestamps:

MongooseMongoZod
timestamps: truetimestamps: true, with createdAt: mz.date() and updatedAt: mz.date() declared
Renamed fieldstimestamps: { createdAt: 'created_at', updatedAt: 'updated_at' }
timestamps: false in one operationtimestamps: false in its options
Writing updatedAt in an updateDoes not compile: pass timestamps: false to write it
Timestamps on subdocumentsNo equivalent

See Timestamps.

Discriminators

From Discriminators:

MongooseMongoZod
Event.discriminator('Click', schema)An option of a z.discriminatedUnion(), the model's schema
discriminatorKey: 'kind'z.discriminatedUnion('kind', [ … ])
The default key, __tz.discriminatedUnion('__t', [ … ]), each option with __t: z.literal('Click'), the name of its discriminator
A query through the Click modelA filter on the key, such as find({ kind: 'click' }), which narrows the type
overwriteDiscriminatorKeyreplaceOne with the new type
Embedded discriminatorsA z.discriminatedUnion() in a field or an array
A discriminator of a discriminatorA z.discriminatedUnion() inside another, up to three levels

See Document types.

Middleware

From Middleware:

MongooseMongoZod
pre('save') that changes a field, such as a hashA .transform() of the field, which runs on every write of it (see Transforms)
pre('validate'), checks across fields.refine() or .superRefine() on the schema
pre('find'), pre('updateOne') and other query middlewareA plugin's before hook, per operation or per group: read, insert, update, delete, bulk
post('save'), post('find')A plugin's after hook
pre('aggregate')A plugin's aggregate hook
Error handling middlewareA plugin's middleware, around next()
this, the document or the queryThe hook's { operation, args, context, model }
postSchema.pre('find', function () {
  this.where({ status: 'published' })
})
/** Reads published posts only. */
function publishedOnly() {
  return definePlugin({

    id: 'publishedOnly',

    hooks: {

      find: {

        before: ({ args }) => ({
          args: { ...args, filter: { $and: [args.filter, { status: 'published' }] } }
        })
      }
    }
  })
}

const PublishedPostsModel = mongozod({

  collection: 'posts',

  db,

  schema: postSchema,

  plugins: [publishedOnly()]
})

See Plugins and Writing plugins.

Plugins

From Plugins:

MongooseMongoZod
schema.plugin(plugin, options)plugins: [plugin(options)], in the order they apply
mongoose.plugin(), global pluginsNo equivalent: pass the same plugins to each model
A soft delete pluginsoftDelete(), from mongozod/plugins/soft-delete
The autoIndex optionautoIndex(), from mongozod/plugins/auto-index
mongoose-lean-virtualsNot needed: reads are plain objects with their virtuals

Indexes

From Indexes and the Model API:

MongooseMongoZod
schema.index(fields, options)indexes: (index) => [index(fields, options)], with the paths checked against the schema
autoIndex, Model.init(), Model.createIndexes()await Model.sync(), from a deploy script, or plugins: [autoIndex()]
Model.syncIndexes(), which drops the undeclared onessync(), which never drops one and returns them in extraIndexes
Model.diffIndexes()The extraIndexes of sync()
autoCreateMongoDB creates the collection on the first write, or sync() with jsonSchema

See Sync.

Transactions

From Transactions:

MongooseMongoZod
connection.transaction(fn), session.withTransaction(fn)The driver's client.withSession((session) => session.withTransaction(fn))
{ session } in each operation, .session(session){ session } in each operation
transactionAsyncLocalStorageNo equivalent: pass { session }

See Transactions.

Errors

From Errors:

MongooseMongoZod
ValidationErrorZod's ZodError, with the path of each issue (see Validation)
CastErrorNo casting in the model. What mz parses throws a ZodError.
MongoServerError, such as E11000The same driver error, unchanged
VersionErrorNo equivalent: there is no versioning
DocumentNotFoundErrorNo equivalent: check for null

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.

On this page