# 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:

```ts
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:

```ts
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](https://mongozod.dev/docs/guides/bson-types.md), an ISO date becomes a `Date` and a hex string
  an `ObjectId`.
- **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 `Error` that names the builder when
  the schema is built.
- **Nothing to remember but for filters**: a missing `after` is the first page, a missing term is
  `{}`, a missing projection is the mask, and a missing sort or populate is `undefined`. What a
  missing filter means is up to each endpoint, which decides it with Zod's methods (see
  [Defaults and required fields](#defaults-and-required-fields)).
- **Client-safe**: `mongozod/mzod` never 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](https://mongozod.dev/docs/guides/security.md).

## Importing `mz`

Import it as a namespace, next to Zod's `z`:

```ts separate
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](https://mongozod.dev/docs/guides/bson-types.md)). 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.

```ts
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:

```ts
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`, `$exists` and
  `$regex`, with `$options`, and for arrays `$all`, `$size` and `$elemMatch`.
- **Where they apply**: the ranges compare numbers, strings, dates and ids, `$regex` applies to
  strings, and `$all`, `$size` and `$elemMatch` to arrays. The editor suggests the paths and, on
  each one, its operators.
- **Equality**: a field that declares `$eq` also 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.
- **`$options`** goes only with `$regex`: MongoDB rejects it alone. It takes the flags MongoDB
  takes, `i`, `m`, `x`, `s` and `u`. The pattern itself is checked by the server, which rejects an
  invalid one with the code 51091: treat that `MongoServerError` as the client's.
- **`$regex`** takes 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 or
  [`mz.term`](https://mongozod.dev/docs/api/mzod.md#mzterm), which builds the `$regex` from escaped text (see
  [Search](https://mongozod.dev/docs/recipes/search.md)).
- **Dotted paths** into subdocuments, such as `'address.city'`, work as in a model's filter.

```ts
// @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:

```ts
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:

```ts
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](https://mongozod.dev/docs/guides/security.md)).

- **No defaults**: an update keeps what the client sent. Setting `title` doesn't reset `body` to its
  default, `''`.
- **`$unset`** takes optional fields only, and **`$inc`** numbers, integers when the field is an
  integer, or a [`mz.decimal128()`](https://mongozod.dev/docs/guides/bson-types.md#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 `arrayFilters` if needed.
- **The `_id` is immutable**: declaring it doesn't compile and throws.
- **Timestamps**: don't declare `createdAt` or `updatedAt` when the model has
  [timestamps](https://mongozod.dev/docs/guides/timestamps.md). 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](https://mongozod.dev/docs/guides/validation.md).

```ts
// @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`](https://zod.dev/codecs), with `.extend()`: the client sends the input of the codec,
and the router gets the value to store.

```ts separate
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:

```ts
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.

```ts
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:

```ts
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](https://mongozod.dev/docs/guides/virtuals.md), 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](https://mongozod.dev/docs/guides/populate.md) 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](https://mongozod.dev/docs/guides/populate.md#in-another-key)):

```ts
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](https://mongozod.dev/docs/guides/pagination.md).

## `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](https://mongozod.dev/docs/recipes/search.md).

```ts
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({})` on `mz.filter`, so the router joins filters with `$and` and
  needs no `?? {}`.
- **A filter by id** declares `_id: []`, the value only, and takes `.required({ _id: true })`.
  Without it, `{}` is a valid filter, and `findOne`, `updateOne` or `deleteOne` would act on
  whatever document comes first.
- **At least one of several fields** takes a `.refine()`.
- **A default sort** takes `.default()`, such as `mz.sort(…).default({ createdAt: -1 })`.

```ts
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:

```ts
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](https://www.mongodb.com/docs/manual/reference/operator/query/or/) 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
// @ts-expect-error: "$where" is not an operator of mz.filter
mz.filter(postSchema, { title: ['$where'] })
```

[`$where`](https://www.mongodb.com/docs/manual/reference/operator/query/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:

```ts
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:

```ts
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({})` on `mz.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 `_id` gives `{}`, 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()` on `mz.filter`**: they throw. Declare another
  `mz.filter` with the fields you want.
- **Sending the input in a query string**: `?limit=10` arrives 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 with `JSON.parse` (see [HTTP APIs](https://mongozod.dev/docs/recipes/http-apis.md)).
- **Declaring a date as `z.date()` in the document's schema**: the ISO string a client sends fails
  in `mz.filter`, `mz.update` and `mz.after`. Use [`mz.date()`](https://mongozod.dev/docs/guides/bson-types.md#dates), and
  `mz.objectId()` for ids, which turns the client's hex string into an `ObjectId`.
- **Declaring `createdAt` or `updatedAt` in `mz.update` on a model with timestamps**: the input
  compiles, but passing its output to the model doesn't, and at runtime the model throws a
  `ZodError`.
- **Spreading the client's filter over your scope**: `{ ...scope, ...input.filter }` lets the
  client replace a key of the scope. Join them with `$and`.