BSON types
Zod schemas for MongoDB ObjectId, Decimal128, binary data, UUIDs, dates, sets and GeoJSON, which take them as JSON from clients.
MongoDB stores BSON, which has types that JSON lacks, such as ObjectId, Decimal128 and dates. The
field types of mongozod/mzod go in a document's schema: each one takes what a JSON client sends
and what your server passes, and returns the BSON value the driver stores and reads back.
import { Decimal128, ObjectId, UUID } 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),
authorId: mz.objectId(),
tags: mz.set(z.string())
.default([]),
publishedAt: mz.date()
.optional()
})
const PostsModel = mongozod({
collection: 'posts',
db,
schema: postSchema
})
// The strings of a JSON request body
const { document: post } = await PostsModel.insertOne({
title: 'Hello, MongoDB',
authorId: '65c3b9b4f1d2a34b5c6d7e8f',
publishedAt: '2026-10-12T09:00:00Z'
})
// { _id: ObjectId('…'), title: 'Hello, MongoDB', authorId: ObjectId('65c3b9b4f1d2a34b5c6d7e8f'),
// tags: [], publishedAt: 2026-10-12T09:00:00.000Z }The same field types parse the values of the endpoint inputs, so the hex
string of a filter by authorId reaches the model as an ObjectId.
The field types
| Field type | A JSON client sends | The server passes | Stored and read as |
|---|---|---|---|
mz.objectId() | its 24-character hex string | an ObjectId | an ObjectId |
mz.decimal128() | a string such as "10.50", an exact number, or { "$numberDecimal": … } | a Decimal128 | a Decimal128 |
mz.binary() | the base64 string of its bytes | a Uint8Array, a Buffer or a Binary | a Binary |
mz.uuid() | its string, with hyphens | a UUID | a UUID, a Binary of subtype 4 |
mz.date() | an ISO datetime string with Z or an offset | a Date | a Date |
mz.set(item) | an array without repeated values | an array | an array |
mz.point() and the other GeoJSON types | a GeoJSON object | the same | the same object |
Each one returns the type that reads return. Reads are not parsed: the driver gives back its own
BSON values, so the output of the schema has to match them. That is why mz.binary() returns a
Binary and not a Uint8Array, and mz.set() returns an array and not a Set.
A value that doesn't fit gives one issue, with a message that says what the field takes, such as
Invalid ObjectId: expected an ObjectId or its 24-character hex string. A missing value adds
, received undefined, as Zod says of any missing field.
Where they run
mongozod/mzod never imports the driver, so a schema that uses it also runs in web and React Native
clients, such as one shared between a form and its endpoint. The BSON types come from
bson, a peer dependency of MongoZod of the same major as your
driver (see Installation). A bundler that tree-shakes, such as
Vite, leaves bson out unless a field needs it: mz.date() and the GeoJSON types import only Zod.
React Native's Metro does not tree-shake, so a client that imports mongozod/mzod gets bson, about
22 KB gzipped. bson warns when it loads without crypto.getRandomValues: a client that generates
ids installs a polyfill at the top of its entry point, as the
bson README says.
A schema shared with a client imports ObjectId, Decimal128 or UUID from bson, since the
client cannot import the driver. Server code can import them from mongodb, which re-exports
bson's.
ObjectId
mz.objectId() takes an ObjectId or its 24-character hex string, in lowercase or uppercase, and
returns an ObjectId. Give the _id a default to generate it, as in the example above. See
Schemas for the other forms of the _id.
A route parameter is a string, so parse it with the field type before you query:
const authorId = mz.objectId().parse('65c3b9b4f1d2a34b5c6d7e8f')
const byAuthor = await PostsModel.find({ authorId })
post.authorId.equals(authorId) // trueCompare two ids with .equals() and check a value with ObjectId.isValid(), never with
instanceof. In an ES module app, the driver reads values with the CommonJS build of bson, and
what your code builds may come from its ES module build: they are two classes, so instanceof fails
with half of the values. mz.objectId() recognizes both. The same goes for Decimal128 and UUID.
Decimal128
mz.decimal128() is the type for money. It takes a Decimal128, its string, such as '10.50', or
a number, and returns a Decimal128:
const invoiceSchema = z.object({
_id: mz.objectId()
.default(() => new ObjectId()),
total: mz.decimal128()
})
const InvoicesModel = mongozod({
collection: 'invoices',
db,
schema: invoiceSchema
})
const { document: invoice } = await InvoicesModel.insertOne({ total: '10.50' })
// $inc and $mul parse the amount with the field, so the sum stays exact
await InvoicesModel.updateOne({ _id: invoice._id }, { $inc: { total: '0.10' } })
// total: Decimal128('10.60')- Numbers: a number passes when it is the decimal it was written as, with up to 15 significant
digits, such as
10.5or19.99 * 3. One with more digits, such as the0.30000000000000004of0.1 + 0.2, fails and asks for the string: storing it would change the amount without a word. - Strings: they go through
Decimal128.fromString(), so a string that is not a decimal fails, and so does one thatbsonwould round, with more than 34 digits. NaNandInfinityfail in every form, since an amount is a number.- JSON:
JSON.stringifywrites aDecimal128as{ "$numberDecimal": "10.50" }, andmz.decimal128()takes that form too, so a document your endpoint returned comes back as it was. $incand$mulleave out the field's checks, such as a.refine()that keeps a balance at zero or above: they validate the value, not what changes it.
A filter on a decimal takes a Decimal128, not a number. A double is not always the decimal that
was written, and MongoDB compares them by value: a filter with the number 9.99 can leave out a
stored 9.99.
const large = await InvoicesModel.find({ total: { $gte: Decimal128.fromString('100') } })
// @ts-expect-error: a filter on a Decimal128 takes a Decimal128
await InvoicesModel.find({ total: { $gte: 100 } })What a client sends goes through mz.filter, which converts its strings. See
Dates and money for more.
Binary data
mz.binary() takes the base64 string of the bytes, which a JSON client sends, a Uint8Array or a
Buffer, which your server passes, or a Binary, and returns a Binary, the type the driver reads
binary data as:
const attachmentSchema = z.object({
_id: mz.objectId()
.default(() => new ObjectId()),
postId: mz.objectId(),
name: z.string(),
data: mz.binary()
})
const AttachmentsModel = mongozod({
collection: 'attachments',
db,
schema: attachmentSchema
})
const { document: attachment } = await AttachmentsModel.insertOne({
postId: post._id,
name: 'hello.txt',
data: 'aGVsbG8='
})
attachment.data.value() // the bytes: 104, 101, 108, 108, 111
attachment.data.toString('base64') // 'aGVsbG8='A Binary keeps its subtype, and a UUID stays a UUID. Bytes and base64 become a generic
Binary, subtype 0, as the driver stores bytes.
UUID
mz.uuid() stores a UUID as a Binary of subtype 4, which takes 16 bytes where its string takes 36.
It takes the string of a UUID, with hyphens and in lowercase or uppercase, or a UUID, and returns
a UUID, which is what the driver reads:
const sessionSchema = z.object({
_id: mz.uuid()
.default(() => new UUID()),
userId: mz.objectId(),
expiresAt: mz.date()
})
const SessionsModel = mongozod({
collection: 'sessions',
db,
schema: sessionSchema
})
const session = await SessionsModel.findOne({
_id: mz.uuid().parse('0f1aef5a-e7a8-4f60-811b-6685550d99ff')
})A string without hyphens fails, although bson takes it, because it is not the form a client sends.
A Binary of subtype 4 becomes a UUID, and one of another subtype fails.
Dates
mz.date() takes a Date or an ISO datetime string with Z or an offset, such as
'2026-10-12T09:00:00Z' or '2026-10-12T06:00:00-03:00', and returns a Date. A string without
either fails, because it doesn't say which instant it is. It is the
isoDatetimeToDate codec of the Zod docs, taking an
offset too, in a union that also takes a Date.
Use it for every date a client may send, such as publishedAt above, and for createdAt and
updatedAt with timestamps. A plain z.date() rejects the ISO string that JSON
gives.
Sets
mz.set(item) is an array that keeps each value once, the array of $addToSet. A repeated value
fails with a ZodError at its index: the value is not removed for you.
await PostsModel.updateOne(
{ _id: post._id },
{ $addToSet: { tags: { $each: ['mongodb', 'zod'] } } }
)
// @ts-expect-error: "tags" is a set: use $addToSet
await PostsModel.updateOne({ _id: post._id }, { $push: { tags: 'zod' } })- Equality: the one of the
uniqueItemsof MongoDB's$jsonSchema. Numbers compare by value (1and1.0), BSON values by theirs (two copies of anObjectId), dates by their instant and objects by their fields in any order. The elements are parsed first, so a hex string and theObjectIdit decodes to are the same value. - Updates: only
$addToSetadds.$push, and writing an element by its index or a positional operator, such astags.0ortags.$[], don't compile and throw aZodError. A$setof the whole array is parsed, and$pull,$pullAlland$popremove. - On the server: with the
jsonSchemaoption,sync()marks the array withuniqueItems: true, so MongoDB also rejects a repeated value that arrives some other way, such as a$pushthrough the driver. Without it, there is one case MongoZod doesn't see:$addToSetcompares objects with the order of their fields, so it adds an equal object with its fields in another order.
It returns an array, not a Set, because the driver reads arrays. A z.set() in a model's schema
throws an Error when the model is defined: the driver would store it as {}.
GeoJSON
mz.point(), mz.lineString(), mz.polygon(), mz.multiPoint(), mz.multiLineString(),
mz.multiPolygon() and mz.geometryCollection() are Zod schemas of the GeoJSON objects MongoDB
stores and queries. They don't convert values: they validate the shape.
const placeSchema = z.object({
_id: mz.objectId()
.default(() => new ObjectId()),
name: z.string(),
location: mz.point(),
area: mz.polygon()
.optional()
})
const PlacesModel = mongozod({
collection: 'places',
db,
schema: placeSchema,
indexes: (index) => [
index({ location: '2dsphere' })
]
})
await PlacesModel.sync()
await PlacesModel.insertOne({
name: 'Obelisco',
location: { type: 'Point', coordinates: [-58.3816, -34.6037] }
})
const nearby = await PlacesModel.find({
location: {
$near: {
$geometry: { type: 'Point', coordinates: [-58.38, -34.6] },
$maxDistance: 1000
}
}
})They check what MongoDB rejects with a 2dsphere index, and stores as it is without one:
- Positions:
[longitude, latitude], in that order and in their ranges, from -180 to 180 and from -90 to 90, with an optional altitude. - Lines: two different positions at least.
- Polygons: closed rings, whose last position repeats the first, with four positions at least.
- Multi geometries and collections: not empty. A
GeometryCollectionholds the other types, never another collection.
Whether a ring crosses itself, or a hole lies inside the exterior ring, the 2dsphere index checks.
A query such as $near needs that index: declare it in indexes and run sync().
Back to JSON
z.encode turns a document into what a client sends: an ObjectId into its hex string, a
Decimal128 and a UUID into their strings, a Binary into base64 and a Date into an ISO string
in UTC.
const sent = z.encode(postSchema, post)
// { _id: '6a0…', title: 'Hello, MongoDB', authorId: '65c3b9b4f1d2a34b5c6d7e8f', tags: [],
// publishedAt: '2026-10-12T09:00:00.000Z' }JSON.stringify, which res.json() uses, gives the same strings, except for a Decimal128, which
it writes as { "$numberDecimal": "10.50" }. mz.decimal128() takes both, so a document your endpoint
returns can come back in a request either way.
Fields without a codec
sync() can apply a $jsonSchema generated from the schema, with the BSON type of each
field. A field that Zod doesn't describe, such as a z.custom() or a z.instanceof(), gets no rule.
Declare its type with Zod's metadata API, .meta({ bsonType }):
const ledgerSchema = z.object({
_id: mz.objectId()
.default(() => new ObjectId()),
balance: z.instanceof(Decimal128)
.meta({ bsonType: 'decimal' }),
entries: z.number()
.int()
.min(0)
.max(2_147_483_647)
.meta({ bsonType: 'int' })
})- It replaces the inferred type, so it also narrows a
numberto anint. The driver writes a number as anintonly when it is an integer that fits in 32 bits: withint, the server rejects a larger one. - The field types use it:
mz.objectId()is anobjectId,mz.decimal128()adecimal,mz.binary()andmz.uuid()arebinData, andmz.date()adate. - With
.nullable(), it is the type of the value, andnullstill passes, so.meta()goes before or after.nullable()alike. A later.min()or.refine()keeps it, as Zod keeps any metadata. - Typed: MongoZod adds
bsonTypeto Zod'sGlobalMeta, so the editor suggests MongoDB's aliases and a misspelled one doesn't compile. It takes one alias: a field of several types is az.union. - Zod Mini declares it with its
z.meta()check, as inz.custom<Decimal128>().check(z.meta({ bsonType: 'decimal' })).
Common mistakes
- Checking ids with
instanceof ObjectId: it fails for the values of the other build ofbson, without an error. Use.equals()andObjectId.isValid(). - Filtering by a hex string by hand: the model doesn't cast filters, so
{ authorId: '65c3…' }doesn't compile. Pass anObjectId, or parse the client's value withmz.objectId()ormz.filter. - Using
z.date()for a date a client sends: it rejects the ISO string of JSON, in writes and inmzinputs. Usemz.date(). An ISO string withoutZor an offset fails too: send the offset. - Comparing a decimal with a number: it doesn't compile, and MongoDB could leave out the stored
amount. Use
Decimal128.fromString('9.99'), or the client's string throughmz.filter. - Sending amounts as numbers from a client:
0.1 + 0.2fails withsend it as a string, and so does a number thatJSON.parsealready rounded. Send amounts as strings. - Using
z.set()or$pushon a set: the model throws anErrorat definition forz.set(), and a$pushdoesn't compile. Usemz.set()and$addToSet. - Writing
[latitude, longitude]: GeoJSON takes the longitude first.mz.point()only catches the swap when the latitude falls outside -90 to 90. - Expecting a
Uint8Arrayfrom a binary field: reads return aBinary. Itsvalue()gives the bytes.
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.
TypeScript
Type-safe MongoDB queries in TypeScript, with filters, updates, projections and sorts typed by the Zod schema, and the types to pass them through services and repositories.
Endpoint inputs
Accept MongoDB filters, updates, sorts, projections, populate and search terms from clients safely, limited to what each endpoint declares.