Models
Define a model with mongozod(), connect it to a database, and reach the driver for what the model does not wrap.
A model ties a schema to a collection. mongozod() takes an object with the schema, the name of
the collection and the driver's database, and returns the model, whose operations validate what
they write and type what they read.
import type { Db } from 'mongodb'
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()
})
export const PostsModel = mongozod({
collection: 'posts',
db,
schema: postSchema
})
const published = await PostsModel.find({ status: 'published' })Options
schema, collection and db are required. The others are optional, and each has its own
guide:
| Option | Takes | Guide |
|---|---|---|
schema | A z.object, or a z.discriminatedUnion of objects, of Zod or Zod Mini | Schemas |
collection | The name of the collection | This page |
db | The driver's Db, or a function that returns one | This page |
indexes | A callback that declares the indexes with index(key, options), which sync() or the opt-in autoIndex creates | Sync |
jsonSchema | true, or { validationLevel, validationAction }: a $jsonSchema that sync() applies | Sync |
plugins | The plugins of the model, in the order they apply | Plugins |
references | The fields that store the _id of another document, which populate replaces with that document | Populate |
timestamps | true, or { createdAt, updatedAt } to rename or turn off each: createdAt on insert, updatedAt on every write | Timestamps |
virtuals | Fields that reads compute from others, each a Zod schema that picks its fields and transforms them | Virtuals |
A collection that stores several types of document has a z.discriminatedUnion as its schema (see
Document types).
collection
The name of the collection. MongoDB creates it on the first write, and sync() creates it with
its validator when the model asks for jsonSchema. A collection with options of its own, such as
a time series, a capped collection or a default collation, is created with the driver before
sync() runs (see Sync).
db
The driver's Db, as client.db() returns it. The model neither connects nor closes the client:
your code keeps doing that, as it does with the driver alone.
db can also be a function that returns the Db. The model calls it on every operation, so you
can define the model when its module is imported, before the connection exists, such as in tests
that start a server first:
// Set by the test setup, once the server runs.
let testDb: Db | undefined
const commentSchema = z.object({
postId: mz.objectId(),
body: z.string()
.min(1)
})
const CommentsModel = mongozod({
collection: 'comments',
db: () => {
if (!testDb) throw new Error('the test database is not ready')
return testDb
},
schema: commentSchema
})When the function throws, the operation rejects with its error, and a cursor on its first read.
Since it runs on every operation, the function can also return the database of each request, such
as a tenant's from an AsyncLocalStorage:
import { AsyncLocalStorage } from 'node:async_hooks'
const tenants = new AsyncLocalStorage<Db>()
const acmeDb = client.db('acme')
const TenantPostsModel = mongozod({
collection: 'posts',
db: () => {
const tenantDb = tenants.getStore()
if (!tenantDb) throw new Error('no tenant: run the operation inside a request')
return tenantDb
},
schema: postSchema
})
// Writes to the posts of the database `acme`.
await tenants.run(acmeDb, () =>
TenantPostsModel.insertOne({ title: 'Hello, Acme', authorId: new ObjectId() })
)- Keep a
Dbper tenant:client.db(name)builds a new one on each call, and the model keeps the collection of eachDbit sees. - A cursor reads where it was created:
findandaggregatestart on their first read, but run in the async context in which they were created, like a promise. A cursor created in a tenant's request reads that tenant's database, also forpopulateand the hooks of plugins, even when it is read after the request. One created outside of a request doesn't see the tenant, even when it is read inside one: with the function above, its first read throws. - Throw without a tenant: a fallback such as
tenants.getStore() ?? dbsends what runs outside of a request to that database without an error. Use one only for a database every tenant shares. sync()runs on the current database: run it for each tenant.
Multi-tenant covers the whole setup.
What a model has
Besides its operations (see Operations), a model exposes:
| Member | What it is |
|---|---|
schema | The schema of each document. |
name | The name of the collection. |
db | The driver's Db: the one the function returns now, when db is a function. |
collection | The driver's Collection, typed with the output of the schema, of that same Db. |
virtuals | The virtuals of the model. |
references | The references of the model. |
indexes | The indexes the model declares, which sync() and autoIndex create. |
sync() | Creates the declared indexes and applies the $jsonSchema (see Sync). |
The methods of its plugins are members of the model too, such as the ones of soft delete.
The driver behind the model
A model wraps the operations that read and write documents. For everything else, such as change
streams, the administration of indexes or dropping the collection, use the driver through
model.collection and model.db:
const changes = PostsModel.collection.watch([{ $match: { operationType: 'insert' } }])
const indexes = await PostsModel.collection.indexes()model.collection is typed with the output of the schema, but it is the driver's: what you write
through it skips the validation, timestamps and the plugins. From model.db you also reach the
client, model.db.client, which starts the sessions of transactions.
Configuration errors
mongozod() throws an Error when the model is defined if its options cannot work, such as a
schema whose root is not a document, a field name with a ., an index on a path that does not
exist, a timestamps field missing from the schema, or a plugin method with the name of a member of
the model. Most of them do not compile either. The guide of each option lists its own, and the
API reference lists them all.
Common mistakes
- Building a
Dbon every call:db: () => client.db(tenant)works, but builds aDbper operation, and the model, which keeps a collection perDb, builds a collection each time too. Keep oneDbper tenant, such as in aMap. - Writing through
model.collection: it is the driver's collection, so nothing is validated, stamped or passed through the plugins. Use the model's operations for documents. - Creating a cursor outside the request: a helper that calls
findbefore entering theAsyncLocalStoragedoesn't see the tenant, so it reads another database, or throws. Callfindinside the request. - Expecting the indexes to appear: a model doesn't create them unless it opts in with the
autoIndexplugin, which creates them on its first operation. Callsync()when you deploy (see When to run it).
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.