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.
MongoZod types MongoDB queries from the document's Zod schema. Filters, updates, projections and sorts are checked path by path against it, dotted paths included, with every MongoDB operator, so a typo or a value of the wrong type does not compile, and a projection narrows what a read returns.
import type { Document } from 'mongodb'
import type {
DocOf,
FilterOf,
InsertOf,
ReadOf,
ReadOptionsArg,
SortOf,
UpdateOf,
UpdateOptionsArg
} from 'mongozod'
import { ObjectId } from 'mongodb'
import { 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(),
email: z.email()
})
const UsersModel = mongozod({
collection: 'users',
db,
schema: userSchema
})
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(),
views: z.number()
.int()
.default(0),
seo: z.object({
title: z.string(),
description: z.string()
.optional()
})
.optional(),
links: z.object({
url: z.url(),
checked: z.boolean()
.default(false)
})
.array()
.default([]),
reactions: z.record(z.string(), z.number().int())
.default({}),
publishedAt: mz.date()
.optional()
})
const PostsModel = mongozod({
collection: 'posts',
db,
schema: postSchema,
references: {
authorId: () => UsersModel
}
})
const published = await PostsModel.find({ 'status': 'published', 'seo.title': { $exists: true } })
// @ts-expect-error: "titel" is not a path of the document
await PostsModel.find({ titel: 'Hello, MongoDB' })The error names the problem, such as "titel" is not a path of the document or
"$regex" only applies to strings. A filter is checked by its types only, without casting: one
that arrives without types, such as a client's, goes through mz.filter
first. Updates are also validated at runtime (see Validation).
Writes and reads
Writes take the input of the schema, z.input, and reads return its output, z.output, with the
_id. When the schema does not declare _id, it is an ObjectId, in reads and in filters:
const authorId = new ObjectId()
const { document: post } = await PostsModel.insertOne({ title: 'Hello, MongoDB', authorId })
// post: { _id: ObjectId; title: string; body: string; status: 'draft' | 'published'; … }
// @ts-expect-error: title is required
await PostsModel.insertOne({ authorId })distinct takes a path of the document and returns the type of its values, with arrays flattened:
PostsModel.distinct('tags') gives a string[].
Filters
Each key of a filter is a path of the document, and each condition has to fit its field:
| Operators | Take |
|---|---|
A value, $eq, $ne, $in, $nin | The type of the field, or of its elements in an array. A RegExp on strings. null only where the field can be missing or null. |
$gt, $gte, $lt, $lte | Numbers, strings, dates and ids: not booleans or objects. |
$exists, $type | Any field. |
$regex, $options | Strings only. $not takes operators or a RegExp. |
$mod, $bitsAllSet and the other bitwise operators | Numbers only, and the bitwise ones also a Binary. |
$all, $size, $elemMatch | Arrays only. $elemMatch checks the element like a filter. |
$and, $or, $nor | Filters, each checked the same way. |
$expr, $jsonSchema, $text, $where, $comment | What the driver takes. |
await PostsModel.find({
'tags': 'mongodb',
'views': { $gte: 100 },
'title': /schema/i,
'links.url': { $regex: '^https://' },
'$or': [{ publishedAt: { $lt: new Date() } }, { status: 'draft' }]
})
// @ts-expect-error: a status is 'draft' or 'published'
await PostsModel.find({ status: 'archived' })
// @ts-expect-error: $regex only applies to strings
await PostsModel.find({ views: { $regex: '1' } })The same checks apply to every filter of the model: those of the reads, updates and deletes, the
filters of bulkWrite, and the $match stages at the start of an aggregate pipeline.
undefined and null
A key whose value may be undefined does not compile: the driver sends it as null, which matches
the documents without the field, and with ignoreUndefined it drops the key, which matches every
document. The error says "status" may be undefined. The model also rejects it at runtime with a
ZodError, for a value that arrives typed any, such as one of a request body. Leave the key out,
or write null or { $exists: false } when that is what you mean. Compare with undefined, so
that false, 0 and '' stay in the filter:
async function listPosts(status?: 'draft' | 'published') {
// @ts-expect-error: "status" may be undefined
await PostsModel.find({ status })
return PostsModel.find({ ...(status !== undefined && { status }) })
}No casting
Mongoose casts each value of a filter to the type of its field, such as a string to an ObjectId,
and throws a CastError when it cannot
(Mongoose docs). A model does not cast
filters at runtime: a value of the wrong type does not compile. Parse what arrives without types,
such as a route parameter, before it reaches the model:
async function getPost(id: string) {
// @ts-expect-error: the _id is an ObjectId, and the model does not cast the string
await PostsModel.findOne({ _id: id })
return PostsModel.findOne({ _id: mz.objectId().parse(id) })
}For filters that clients send, mz.filter parses each value with the schema of its field (see
Endpoint inputs).
Updates
Each path of an update is a path of the document, and each value has to fit its operator and its field. The runtime applies the same rules (see Validation):
| Operators | Take |
|---|---|
$set, $setOnInsert, $min, $max | The input of the field: an ISO string on a mz.date() field compiles. |
$unset | Optional fields only. One with .default() is not optional. |
$inc, $mul, $bit | Numbers only. $inc and $mul also a Decimal128 field. |
$currentDate | Dates only, with true or { $type: 'date' }. |
$push, $addToSet | Arrays only, with the input of an element, also in $each. $push takes $position, $slice and $sort. |
$pop, $pullAll | Arrays only. |
$pull | Arrays only, with a condition on the element, as $elemMatch takes it. |
$rename | From an optional field to another that stores the same type. |
await PostsModel.updateOne(
{ _id: post._id },
{
$set: { 'seo.title': 'Hello', 'publishedAt': '2026-10-12T09:00:00Z' },
$inc: { views: 1 },
$push: { tags: { $each: ['mongodb', 'zod'], $slice: -10 } }
}
)
// @ts-expect-error: "title" is required, so it cannot be unset
await PostsModel.updateOne({ _id: post._id }, { $unset: { title: '' } })
// @ts-expect-error: "tags" is not a number
await PostsModel.updateOne({ _id: post._id }, { $inc: { tags: 1 } })An update names the elements of an array with an index, $, $[] or $[id]. An update that uses
an identifier, $[id], needs arrayFilters, whose entries are checked against the elements it
names:
await PostsModel.updateOne(
{ _id: post._id },
{ $set: { 'links.$[link].checked': true } },
{ arrayFilters: [{ 'link.url': 'https://www.mongodb.com/docs/' }] }
)
// @ts-expect-error: "links.$[link].checked" uses an identifier: pass arrayFilters
await PostsModel.updateOne({ _id: post._id }, { $set: { 'links.$[link].checked': true } })- The
_idis written only by$setOnInsert, whole: the server rejects a change of it. - Timestamps: a model with
timestampssetsupdatedAt, so an update that writes it does not compile (see Timestamps). - Several types of document: a path has to be in every type the filter allows, so the filter fixes the discriminator (see Document types).
undefinedcompiles in an update: the key is dropped and the field keeps its value.- An update kept in a variable needs
as const: TypeScript widens its'',1andtruetostring,numberandboolean, which$unset,$currentDateand$popdo not take. The error says so:"nextRunAt" is a string: $unset takes '', 1 or true, so declare the update with as const. Written in the call, it needs nothing.
Projections
A projection narrows the type of what a read returns, as MongoDB answers it:
const titles = await PostsModel.find({}, { projection: { 'title': 1, 'seo.title': 1 } })
// Array<{ _id: ObjectId; title: string; seo?: { title: string } }>
const withoutBody = await PostsModel.findOne({ _id: post._id }, { projection: { body: 0 } })
// Every field but body, or nullA projection includes or excludes fields, with dotted paths or the nested form, such as
{ seo: { title: 1 } }. It also takes $slice and $elemMatch on arrays, the positional $,
and { $meta: 'textScore' }, which adds the relevance of a text search as a number. The
project() of the find cursor takes the same and replaces the projection of the options.
What MongoDB accepts but answers differently does not compile: an index, such as 'tags.0', a
path that does not exist, inclusion mixed with exclusion other than _id, $slice on a field that
is not an array, a string value, which MongoDB returns as a literal, or an empty sub-projection such
as { seo: {} }.
// @ts-expect-error: "title" mixes inclusion and exclusion
await PostsModel.find({}, { projection: { title: 1, body: 0 } })A projection kept in a variable needs as const: TypeScript widens its 1 to number, which a
projection does not take. One built at runtime, such as one a client sends, is typed as the
driver's Document, and then every field of the result is optional:
const card = { title: 1, tags: 1 } as const
const cards = await PostsModel.find({ status: 'published' }, { projection: card })
// Array<{ _id: ObjectId; title: string; tags: string[] }>
const requested: Document = { title: 1 }
const partial = await PostsModel.find({}, { projection: requested })
// Every field optionalSorts
A sort is an object whose keys are paths of the document, with 1, -1, 'asc', 'desc',
'ascending', 'descending' or { $meta: 'textScore' }. It is checked in the options and in the
cursor:
await PostsModel.find({ status: 'published' }, { sort: { publishedAt: -1, _id: -1 } })
await PostsModel.find().sort({ 'seo.title': 'asc' })
// @ts-expect-error: sort takes an object such as { createdAt: -1 }, not a string
await PostsModel.find({}, { sort: '-publishedAt' })A sort kept in a variable needs as const, like a projection: TypeScript widens its -1 to
number, and the error says "publishedAt" is a number: a sort takes 1 or -1, so declare it with as const. TypeScript 5 shows that message only in the options of a read: in .sort() of a cursor and
in SortOf it reports that the type is not assignable.
Dotted paths
TypeScript does not list every path of the schema: it looks up each key that the code writes, one segment at a time, as Zod walks a value. So there is no depth limit, and recursive schemas work.
- Arrays: filters go through arrays, as MongoDB does, and take indexes:
'links.url'and'links.0.url'. Updates do not go through them: they take an index,$,$[]or$[id], with$once per path. Projections go through arrays but take no index. - Records and catchalls: their keys are paths too, with the type of their values. A key must be
a literal, so a key typed
stringdoes not compile. A record keyed by an enum, az.enum()or a TypeScriptenum, takes only its members. - Computed keys: TypeScript widens the computed key of an object literal to
string(microsoft/TypeScript#13948), so{ [`reactions.${emoji}`]: 1 }does not compile. Type the object with the pattern, such asPartial<Record<`channels.${Channel}`, number>>for a record keyed by an enum:
async function react(postId: ObjectId, emoji: string) {
const reaction: Record<`reactions.${string}`, number> = { [`reactions.${emoji}`]: 1 }
await PostsModel.updateOne({ _id: postId }, { $inc: reaction })
}Paths stop where the schema has nothing to say about what is inside: a transform or a z.custom().
Under a z.unknown() or a z.any(), any path goes.
Editor suggestions
The editor suggests the paths of filters, sorts, projections, distinct and updates, the update
operators, and the keys of populate. They are computed from the schema up to 6 levels deep, with
$ and $[] on the arrays of updates. A deeper path still compiles, and so does one under a
record or a z.lazy(), which the suggestions do not enter.
The query operators, such as $gte or $in, are not suggested yet: inside a field of a filter,
the editor suggests the members of its type.
Types for services and repositories
A function that takes or returns what a model reads and writes, such as a service or a repository, types it from the model:
| Type | What it is |
|---|---|
DocOf<M> | A stored document, with its _id. |
ReadOf<M, O> | What a read returns, given its projection, its populate and the filter that fixes its type. |
InsertOf<M> | What an insert takes, with the fields that timestamps and the plugins fill optional. |
FilterOf<M, F> | The constraint of a filter that a function passes to the model. |
SortOf<M, Sort> | The constraint of a sort that a function passes to the model. |
BulkWriteOf<M, Ops, O> | The constraint of the operations of a bulkWrite that a function passes to the model. |
FindOptionsOf<M> | The options of a find that a service takes from a router: sort, populate, limit and more. |
UpdateOf<M, U, F, O> | The constraint of an update, given its filter and its options. |
UpdateOptionsOf<M, U, O> | The constraint of the options of an update, with its arrayFilters checked. |
PipelineOf<M, P> | The constraint of a pipeline whose $match stages a function passes to the model. |
FilterMethod<M, O>, UpdateMethod<M> and the other method types | The type of a method of a service, given with satisfies (see Repositories). |
ReadOptionsArg<M> | The options argument of the reads, with the context its plugins ask for. |
UpdateOptionsArg<M> | The options argument of the updates, with the context its plugins ask for. |
Input<S>, Output<S> | The input and the output of a schema. |
M is the type of a model, such as typeof PostsModel.
DocOf and ReadOf type what you read:
type Post = DocOf<typeof PostsModel>
function excerpt(post: Post) {
return post.body.slice(0, 140)
}
type PostCard = ReadOf<
typeof PostsModel,
{ projection: { title: 1; authorId: 1 }; populate: { authorId: { projection: { name: 1 } } } }
>
// { _id: ObjectId; title: string; authorId: { _id: ObjectId; name: string } | null }InsertOf types a document to insert:
function createPost(post: InsertOf<typeof PostsModel>) {
return PostsModel.insertOne(post)
}FilterOf, SortOf and UpdateOf are constraints of a generic, so that the function keeps the
checks and the suggestions of the model. Declare the generic with const, as the model does:
without it, the arrayFilters of the options stop being a tuple and upsert: true widens to
boolean, so their checks are lost.
function countPosts<const F extends FilterOf<typeof PostsModel, F>>(filter: F) {
return PostsModel.countDocuments(filter)
}
function updatePosts<
const F extends FilterOf<typeof PostsModel, F>,
const U extends UpdateOf<typeof PostsModel, U, F>
>(filter: F, update: U) {
return PostsModel.updateMany(filter, update)
}
function newestPosts<const Sort extends SortOf<typeof PostsModel, Sort>>(sort: Sort) {
return PostsModel.find({ status: 'published' }, { sort }).limit(10)
}
await countPosts({ tags: 'mongodb' })
await updatePosts({ authorId }, { $set: { status: 'published' } })
await newestPosts({ publishedAt: -1 })
// @ts-expect-error: "titel" is not a path of the document
await countPosts({ titel: 'Hello, MongoDB' })ReadOptionsArg passes the options of a read through, such as a session, or the context that a
plugin asks for, which makes them required:
async function hasDrafts(authorId: ObjectId, ...options: ReadOptionsArg<typeof PostsModel>) {
return (await PostsModel.countDocuments({ authorId, status: 'draft' }, ...options)) > 0
}
await hasDrafts(authorId, { maxTimeMS: 1000 })UpdateOptionsArg does the same for an update:
async function publishAll(authorId: ObjectId, ...options: UpdateOptionsArg<typeof PostsModel>) {
return PostsModel.updateMany({ authorId }, { $set: { status: 'published' } }, ...options)
}
await publishAll(authorId, { upsert: false })A function generic over any model, such as a repository that compares a model with
Model<any, …>, is expensive to type check. Make it generic over what it reads instead, with the
query as a callback: see Repositories.
exactOptionalPropertyTypes
MongoZod's types are tested with exactOptionalPropertyTypes on. What a mz.filter() or
mz.populate() schema returns, whose optional keys Zod types with | undefined, can be passed to
the operations, and the projection, sort and populate options take undefined. A filter key
that you set to undefined by hand still does not compile, because the driver would send it as
null.
Type cost
Types like these can make an editor slow. CI compiles a typical project, with its models, reads, writes and endpoint inputs, with TypeScript 7 and with 5.5, the minimum, and fails when its type instantiations go over a budget. A change that raises the cost on purpose raises the budget in the same commit.
Common mistakes
- An update or a sort in a variable without
as const: its literals widen and it does not compile. Declare it withas const, or write it in the call. - A projection in a variable without
as const: its1widens tonumberand it does not compile. Declare it withas const, or type its values as0 | 1. - A computed key in an object literal:
{ [`reactions.${emoji}`]: 1 }is typed with astringkey and does not compile. Type the object asRecord<`reactions.${string}`, number>. - A filter value that may be
undefined: it does not compile, because it matches the documents without the field, or every document withignoreUndefined. Leave the key out, or writenullor{ $exists: false }when that is what you mean. - An id as a string: a filter on an
ObjectIdfield does not take its hex string, and the model does not cast it. Parse it withmz.objectId(), or withmz.filterfor client input. - A
sortorprojectionin a string, as Mongoose takes them (Mongoose docs): they do not compile and throw aZodError. Write the object, such as{ publishedAt: -1 }. - Typing a service's filter as the driver's
Filter<T>: it does not check the paths. Take a generic constrained withFilterOf. $[id]withoutarrayFilters: it does not compile, and the server would reject it. Pass one entry per identifier.
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.