Endpoint inputs
Accept MongoDB filters, updates, sorts, projections, populate and search terms from clients safely, limited to what each endpoint declares.
An endpoint that lists or edits documents takes a filter, a sort or an update from its client. To
accept them safely, the builders of mongozod/mzod make that input from the document's schema: the client sends MongoDB
syntax, and each endpoint declares what it accepts. Anything else fails with a ZodError, and each
value is parsed with the schema of its field.
Given a model of posts:
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()
.min(1),
email: z.email()
})
const postSchema = z.object({
_id: mz.objectId()
.default(() => new ObjectId()),
title: z.string()
.min(1),
body: z.string()
.default(''),
summary: z.string()
.optional(),
status: z.enum(['draft', 'published'])
.default('draft'),
tags: z.string()
.array()
.default([]),
authorId: mz.objectId(),
createdAt: mz.date(),
updatedAt: mz.date()
})
const UsersModel = mongozod({
collection: 'users',
db,
schema: userSchema
})
const PostsModel = mongozod({
collection: 'posts',
db,
schema: postSchema,
timestamps: true,
indexes: (index) => [
index({ createdAt: -1, _id: -1 }),
index({ title: 'text', body: 'text' })
],
references: {
author: {
localField: 'authorId',
model: () => UsersModel
}
}
})An endpoint that lists them declares what its client can send:
const listPostsInput = z.object({
filter: mz
.filter(postSchema, {
tags: ['$in', '$all'],
createdAt: ['$gte', '$lt']
})
.default({}),
options: z
.object({
sort: mz
.sort(postSchema, {
createdAt: true,
title: true
})
.default({ createdAt: -1 }),
limit: z.number()
.int()
.min(1)
.max(100)
.default(20)
})
.prefault({})
})
// The body of a request
const input = listPostsInput.parse({
filter: { tags: { $in: ['mongodb'] }, createdAt: { $gte: '2026-01-01T00:00:00Z' } },
options: { sort: { title: 1 } }
})
// { filter: { tags: { $in: ['mongodb'] }, createdAt: { $gte: 2026-01-01T00:00:00.000Z } },
// options: { sort: { title: 1 }, limit: 20 } }
const posts = await PostsModel.find(input.filter, input.options)
listPostsInput.parse({ filter: { $where: 'sleep(1000)' } })
// ZodError: Unrecognized key: "$where"The approach
Each builder returns a Zod schema, which you nest in a z.object like any other: the input of an
HTTP handler, a tRPC procedure or a server action. What it gives you is MongoDB syntax, ready for
the model.
- An allow list per endpoint: each builder takes the fields, and for filters the operators,
that the endpoint accepts. A
$where, an operator or a field it doesn't declare fails. - Values parsed by their field: each value goes through the schema of its field, without its
default. With BSON field types, an ISO date becomes a
Dateand a hex string anObjectId. - Checked declarations: a path that doesn't exist, or an operator that doesn't apply to its
field, doesn't compile. Most of these mistakes also throw an
Errorthat names the builder when the schema is built. - Nothing to remember but for filters: a missing
afteris the first page, a missing term is{}, a missing projection is the mask, and a missing sort or populate isundefined. What a missing filter means is up to each endpoint, which decides it with Zod's methods (see Defaults and required fields). - Client-safe:
mongozod/mzodnever imports the driver, so the same input schemas run in web and React Native clients, such as to type a form.
Keys set to undefined are dropped, because the driver would send them as null. JSON never sends
them and superjson does, so a request means the same with both.
The builders take Zod and Zod Mini schemas, and a z.discriminatedUnion at the root, which looks
for each field in its types. They build classic Zod schemas.
What the builders protect against, and what stays with your application, such as authorization and the cost of a query, is in Security.
Importing mz
Import it as a namespace, next to Zod's z:
import * as mz from 'mongozod/mzod'
import * as z from 'zod'
const tagsSchema = z.object({
tags: z.string()
.array()
})
const tagsFilter = z.object({
filter: mz.filter(tagsSchema, {
tags: ['$in']
})
})mongozod/mzod has only named exports, so a bundler that tree-shakes keeps the builders and field
types a file uses and drops the rest. React Native's Metro keeps everything (see
BSON types). There is no mz object to import by name: an object would bring
every builder into the client's bundle. It doesn't re-export Zod either, so each file keeps its own
z.
import { filter, sort } from 'mongozod/mzod' works too, but names such as filter, update and
sort clash with common variables.
When the inputs of a module repeat one schema, mz.from(schema) binds the builders that take it:
mz.from(postSchema).filter({ … }) is mz.filter(postSchema, { … }), with the same checks,
suggestions and result. It loads the six builders that take a schema, so a client bundle that uses
it keeps all of them.
const postInputs = mz.from(postSchema)
const findPostsInput = z.object({
filter: postInputs
.filter({
tags: ['$in']
})
.default({}),
term: z.string()
.max(100)
.optional()
.pipe(postInputs.term({ title: true, body: true })),
options: z
.object({
sort: postInputs
.sort({
createdAt: true,
title: true
})
.default({ createdAt: -1 })
})
.prefault({})
})
const editPostInput = z.object({
filter: postInputs
.filter({
_id: []
})
.required({ _id: true }),
update: postInputs.update({
$set: ['title', 'body', 'tags']
})
})mz.filter
mz.filter(schema, fields) takes each path with the operators the client can use on it:
const postsFilter = mz.filter(postSchema, {
_id: ['$eq', '$in'],
title: ['$regex'],
status: ['$eq', '$ne'],
tags: ['$eq', '$all', '$size']
})
postsFilter.parse({
_id: { $in: ['65c3b9b4f1d2a34b5c6d7e8f'] },
title: { $regex: '^hello', $options: 'i' },
tags: 'mongodb'
})
// { _id: { $in: [ObjectId('65c3b9b4f1d2a34b5c6d7e8f')] },
// title: { $regex: '^hello', $options: 'i' }, tags: 'mongodb' }- Operators:
$eq,$ne,$in,$nin,$gt,$gte,$lt,$lte,$existsand$regex, with$options, and for arrays$all,$sizeand$elemMatch. - Where they apply: the ranges compare numbers, strings, dates and ids,
$regexapplies to strings, and$all,$sizeand$elemMatchto arrays. The editor suggests the paths and, on each one, its operators. - Equality: a field that declares
$eqalso takes the value alone, as in MongoDB. One that declares no operator,[], takes the value only, typed as the field's, so the router and a typed client can read it, such as the id of a filter by id. On an array, an equality takes an element or the whole array, and the ranges compare its elements. - An object of operators has one at least:
{}would be an equality with an empty object. $optionsgoes only with$regex: MongoDB rejects it alone. It takes the flags MongoDB takes,i,m,x,sandu. The pattern itself is checked by the server, which rejects an invalid one with the code 51091: treat thatMongoServerErroras the client's.$regextakes any pattern the client sends, and one that isn't anchored to the start reads the whole collection or index. For a search box, use$text, MongoDB Search ormz.term, which builds the$regexfrom escaped text (see Search).- Dotted paths into subdocuments, such as
'address.city', work as in a model's filter.
// @ts-expect-error: "$regex" only applies to strings
mz.filter(postSchema, { createdAt: ['$regex'] })
// @ts-expect-error: "titel" is not a path of the document
mz.filter(postSchema, { titel: ['$eq'] })$elemMatch
On an array of objects, $elemMatch takes the paths declared under the array, relative to its
elements, as in MongoDB:
const commentSchema = z.object({
_id: mz.objectId()
.default(() => new ObjectId()),
postId: mz.objectId(),
body: z.string(),
reactions: z.object({
userId: mz.objectId(),
kind: z.enum(['like', 'love'])
})
.array()
.default([])
})
const commentsFilter = mz.filter(commentSchema, {
'postId': ['$eq'],
'reactions': ['$elemMatch'],
'reactions.userId': ['$eq'],
'reactions.kind': ['$eq', '$in']
})
commentsFilter.parse({
reactions: { $elemMatch: { userId: '65c3b9b4f1d2a34b5c6d7e8f', kind: { $in: ['like', 'love'] } } }
})reactions.userId also works at the top level. On an array of values, such as
scores: ['$elemMatch', '$gte', '$lt'], $elemMatch takes the operators the field declares for
its elements. Without anything declared for the elements, it doesn't compile.
mz.update
mz.update(schema, operators) takes $set, $unset and $inc, each with the paths the client
can write:
const updatePostInput = z.object({
filter: mz
.filter(postSchema, {
_id: []
})
.required({ _id: true }),
update: mz.update(postSchema, {
$set: ['title', 'body', 'summary', 'tags', 'status'],
$unset: ['summary']
})
})
declare const session: { userId: ObjectId }
const edit = updatePostInput.parse({
filter: { _id: '65c3b9b4f1d2a34b5c6d7e8f' },
update: { $set: { title: 'Hello again' }, $unset: { summary: '' } }
})
await PostsModel.updateOne({ $and: [edit.filter, { authorId: session.userId }] }, edit.update)The client's filter picks the post, and the scope from the session decides whether this user may
edit it: without it, any user could edit any post by its _id. The builders limit what a client
can express, not which documents it may touch (see Security).
- No defaults: an update keeps what the client sent. Setting
titledoesn't resetbodyto its default,''. $unsettakes optional fields only, and$incnumbers, integers when the field is an integer, or amz.decimal128(), which takes the string of an amount.- Paths outside arrays: no indexes and no positional operators. Those name an element, which
the router decides, with
arrayFiltersif needed. - The
_idis immutable: declaring it doesn't compile and throws. - Timestamps: don't declare
createdAtorupdatedAtwhen the model has timestamps. The model sets them and rejects them in an update. - At least one operator: an update without operators fails, as in the driver.
{ $set: {} }passes, since MongoDB takes it and changes nothing.
The values are parsed with the schema of their field, refinements included, and the model validates them again when it writes. See Validation.
// @ts-expect-error: "title" is required, so it cannot be unset
mz.update(postSchema, { $unset: ['title'] })
// @ts-expect-error: "title" is not a number
mz.update(postSchema, { $inc: ['title'] })Inputs that convert
A client may write a field in another form than the one stored, such as an amount in units that is
stored in micros. When the conversion is pure, field by field, give mz.update a schema whose field
is a z.codec, with .extend(): the client sends the input of the codec,
and the router gets the value to store.
import { ObjectId } from 'mongodb'
import { mongozod } from 'mongozod'
import * as mz from 'mongozod/mzod'
import * as z from 'zod'
const priceSchema = z.object({
amountMicros: z.number()
.int(),
currency: z.string()
.length(3)
})
const productSchema = z.object({
_id: mz.objectId()
.default(() => new ObjectId()),
name: z.string(),
price: priceSchema
})
const ProductsModel = mongozod({
collection: 'products',
db,
schema: productSchema
})
/** The price as a client writes it, in units, and as it is stored, in micros. */
const priceInput = z.codec(
z.object({
amount: z.number()
.nonnegative(),
currency: z.string()
.length(3)
}),
priceSchema,
{
decode: ({ amount, currency }) => ({ amountMicros: Math.round(amount * 1_000_000), currency }),
encode: ({ amountMicros, currency }) => ({ amount: amountMicros / 1_000_000, currency })
}
)
const productInputSchema = productSchema.extend({ price: priceInput })
const updateProductInput = z.object({
filter: mz
.filter(productSchema, {
_id: []
})
.required({ _id: true }),
update: mz.update(productInputSchema, {
$set: ['name', 'price']
})
})
const { filter, update } = updateProductInput.parse({
filter: { _id: '65c3b9b4f1d2a34b5c6d7e8f' },
update: { $set: { price: { amount: 2.5, currency: 'USD' } } }
})
// update: { $set: { price: { amountMicros: 2500000, currency: 'USD' } } }
await ProductsModel.updateOne(filter, update)To answer the client in units, z.encode(productInputSchema, product) turns a stored product back.
When the conversion depends on what is stored or on other services, such as a draft that merges
with the saved document and validates rules across fields, it is a command, not an update: the
input has its own schema, and the router reads the document, merges the input, parses the whole
document with the model's schema and writes it, with $set or replaceOne.
mz.sort
mz.sort(schema, mask) takes the paths the client can sort by, as a mask like Zod's .pick(). The
client sends 1 or -1 for each:
const postsSort = mz
.sort(postSchema, {
createdAt: true,
title: true
})
.default({ createdAt: -1 })
postsSort.parse({ title: 1, createdAt: -1 })
// { title: 1, createdAt: -1 }It keeps the order in which the client sends the keys, which decides what sorts first. $meta,
and a string such as '-createdAt', fail.
mz.projection
mz.projection(schema, mask) takes the paths the client can ask for, with 1 or true to include
and 0 or false to exclude, without mixing both, except for _id. The mask is the most a client
gets: the router always receives an inclusion of declared paths.
const postsProjection = mz.projection(postSchema, {
title: true,
summary: true,
tags: true
})
postsProjection.parse({ title: 1, summary: true })
// { title: 1, summary: 1 }
postsProjection.parse({ tags: 0 })
// { title: 1, summary: 1 }
postsProjection.parse({})
// { title: 1, summary: 1, tags: 1 }MongoDB reads an exclusion such as { tags: 0 }, and {}, as the whole document, with the fields
the mask leaves out. So an exclusion means the declared paths but those, and nothing means all of
them. Excluding every declared path leaves the _id, which MongoDB returns unless the client sends
_id: 0. A mask can't declare a path and a path inside it, such as author and author.name,
because MongoDB rejects both in one projection.
A client that sends no projection gets the mask too, so its key is optional in an input. Don't give
it a .default({}): Zod returns {} without parsing it, and MongoDB reads {} as the whole
document, with the fields the mask leaves out.
The model doesn't know at compile time whether a projection that arrives at runtime includes or excludes, so the read types each projected field as optional, for every client of the endpoint, since its output has one type. A view that always needs the whole document, or a set of fields, goes in an endpoint of its own, with a projection written on the server, which types exactly:
const postCards = await PostsModel.find(
{ status: 'published' },
{ projection: { title: 1, summary: 1 } }
)
// Array<{ _id: ObjectId; title: string; summary?: string }>To let a client ask for a
virtual, pass a schema that adds it, such as
mz.projection(userSchema.extend({ displayName: z.string() }), { name: true, displayName: true }).
mz.populate
mz.populate(whitelists) takes the references a client can expand, each with the
fields it can see, as a schema picked from the referenced one. Here author is the reference
of the model above, the user whose _id authorId stores (see
In another key):
const authorPopulate = mz.populate({
author: userSchema.pick({ _id: true, name: true })
})
authorPopulate.parse({ author: true })
// { author: { projection: { _id: 1, name: 1 } } }
authorPopulate.parse({ author: { name: 1 } })
// { author: { projection: { name: 1 } } }The client sends true for the whole whitelist, never the whole document, or 1 or true for some
of its fields. {}, an exclusion, a field outside the whitelist or a reference not declared fail.
A whitelist without fields, such as one picked by role that ends up empty, throws when the builder
is declared: MongoDB reads an empty projection as the whole document, so "no field" fails closed.
The router gets the populate option of the model, with a projection. The client never sends a
filter or a sort: those belong to the endpoint.
The whitelist is the fields of the first level of its schema, which become the projection: a field
that is an object comes whole, whatever the whitelist declares inside it, and the documents read are
not parsed with the whitelist. userSchema.pick({ profile: true }) gives the whole profile, also
with a profile picked down to { label }. To hide a part of an object, set the projection of
the model's populate by paths on the server, such as { 'profile.label': 1 }, instead of taking
the populate from the client.
In the result, the populated field is typed as the id or the referenced document, with each of its fields optional, since the model doesn't know at compile time what the client asks for.
mz.after
mz.after(schema, sort) turns the last document of a page into the filter of the next one. See
Pagination.
mz.term
mz.term(schema, mask) turns the term of a search box into a filter in which each word appears in
one of the declared paths, without telling case or accents apart. Pipe the client's string into it,
and join its filter with the others in an $and. See Search.
const postsTerm = z.string()
.max(100)
.optional()
.pipe(mz.term(postSchema, { title: true, summary: true }))Defaults and required fields
Each builder but mz.filter and mz.update knows what a missing input means:
| Builder | When the client sends nothing |
|---|---|
mz.after | {}, the first page, which the router joins in an $and |
mz.term | {}, which matches every document |
mz.projection | The mask |
mz.sort | undefined, the natural order, unless you give a default |
mz.populate | undefined, which expands nothing |
A missing filter means everything in a list and has to fail in a filter by id, so the endpoint decides it with Zod's methods:
- A list takes
.default({})onmz.filter, so the router joins filters with$andand needs no?? {}. - A filter by id declares
_id: [], the value only, and takes.required({ _id: true }). Without it,{}is a valid filter, andfindOne,updateOneordeleteOnewould act on whatever document comes first. - At least one of several fields takes a
.refine(). - A default sort takes
.default(), such asmz.sort(…).default({ createdAt: -1 }).
const findPostInput = z.object({
filter: mz
.filter(postSchema, {
_id: []
})
.required({ _id: true })
})
findPostInput.parse({ filter: {} })
// ZodError: Invalid input: expected nonoptional, received undefined, at filter._id
const findByTitleOrId = mz
.filter(postSchema, {
_id: [],
title: []
})
.refine((filter) => Object.keys(filter).length > 0, 'pass _id or title')The values never get the defaults of the document's schema: the .default() of _id would turn a
missing id into a new one, which matches nothing.
mz.filter returns a z.strictObject, so .required(), .refine(), .default() and .extend()
work. .pick(), .omit() and .partial() throw: declare another mz.filter instead.
$and, $or and $nor
mz.filter takes only the declared fields. An endpoint that lets the client combine conditions adds
the logical operators with .extend(), with the same allow list inside them and a limit on their
length:
const userConditions = mz.filter(userSchema, {
name: ['$eq', '$regex'],
email: ['$eq']
})
const usersFilter = userConditions.extend({
$or: userConditions
.array()
.max(5)
.optional()
})
usersFilter.parse({ $or: [{ name: { $regex: '^Ana' } }, { email: 'ana@example.com' }] })A getter that returns the filter itself, such as get $or() { return usersFilter.array() },
nests them at every level, without a limit on how deep.
They are not accepted by default because they cost:
a $or uses indexes only when
each of its clauses has one, and scans the collection otherwise. A client could turn a filter by
_id into a full scan, and nest them without a limit. Add them where the endpoint needs them.
$where and $expr
$where and $expr are never exposed to clients. mz.filter rejects them, and they can't be
declared:
// @ts-expect-error: "$where" is not an operator of mz.filter
mz.filter(postSchema, { title: ['$where'] })$where runs JavaScript on
the server, and $expr takes aggregation expressions over any field of the document, so neither
fits an allow list. When an endpoint needs one, the router writes it.
A list and a findOne
A complete endpoint for the posts of a blog, with keyset pagination, a search term and the author.
The input mirrors the call of the model: what picks the documents, filter, after and term,
which the router joins with $and, and options that go to the driver as they are. In a project,
the inputs go in a file that imports no model, so a client can use them too:
const authorInput = mz.populate({
author: userSchema.pick({ _id: true, name: true })
})
export const listPostsSchema = z.object({
after: mz.after(postSchema, { createdAt: -1, _id: -1 }),
filter: mz
.filter(postSchema, {
_id: ['$eq', '$in'],
authorId: ['$eq'],
tags: ['$in', '$all']
})
.default({}),
term: z.string()
.max(100)
.optional()
.pipe(mz.term(postSchema, { title: true, summary: true })),
options: z
.object({
limit: z.number()
.int()
.min(1)
.max(100)
.default(20),
populate: authorInput
})
.prefault({})
})
export const findOnePostSchema = z.object({
filter: mz
.filter(postSchema, {
_id: []
})
.required({ _id: true }),
options: z
.object({
populate: authorInput
})
.prefault({})
})The router gets inputs that are already filters, so it joins them with $and and adds its own
scope, here the published posts:
export async function listPosts(input: z.output<typeof listPostsSchema>) {
return PostsModel.find(
{ $and: [input.filter, input.after, input.term, { status: 'published' }] },
{ ...input.options, sort: { createdAt: -1, _id: -1 } }
)
}
export async function findOnePost(input: z.output<typeof findOnePostSchema>) {
return PostsModel.findOne({ $and: [input.filter, { status: 'published' }] }, input.options)
}A client sends, for example,
{ "filter": { "tags": { "$in": ["mongodb"] } }, "options": { "populate": { "author": { "name": 1 } } } },
and the router gets populate: { author: { projection: { name: 1 } } }. The sort of a keyset list
is the router's, the same as the one of mz.after. For the next page, it also sends the createdAt
and _id of the last post it got. Validate the request body with listPostsSchema.parse(body), or
let your framework do it, as tRPC does with .input().
Common mistakes
.default({})onmz.projection: a client that sends no projection gets the whole document, with the fields the mask leaves out, since Zod returns a default without parsing it. Leave it as it is: a missing projection is the mask.- A filter by id without
.required({ _id: true }): a request without_idgives{}, which matches any document, so an update or a delete acts on the first one. Require the fields that identify the document. - Expecting the defaults of the document's schema: a value missing from an input stays missing. Defaults belong to inserts.
- Calling
.pick(),.omit()or.partial()onmz.filter: they throw. Declare anothermz.filterwith the fields you want. - Sending the input in a query string:
?limit=10arrives as the string'10', and the input doesn't coerce it, so it fails. Send the input as JSON, in the body of a POST or in one query parameter that the handler parses withJSON.parse(see HTTP APIs). - Declaring a date as
z.date()in the document's schema: the ISO string a client sends fails inmz.filter,mz.updateandmz.after. Usemz.date(), andmz.objectId()for ids, which turns the client's hex string into anObjectId. - Declaring
createdAtorupdatedAtinmz.updateon a model with timestamps: the input compiles, but passing its output to the model doesn't, and at runtime the model throws aZodError. - Spreading the client's filter over your scope:
{ ...scope, ...input.filter }lets the client replace a key of the scope. Join them with$and.
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.
BSON types
Zod schemas for MongoDB ObjectId, Decimal128, binary data, UUIDs, dates, sets and GeoJSON, which take them as JSON from clients.
Security
How the endpoint inputs of MongoZod keep NoSQL operator injection out when an endpoint takes a MongoDB query from a client, and what stays with your application.