MongoZod
Getting started

Why MongoZod

MongoZod as a Mongoose alternative for TypeScript, compared with Mongoose, Typegoose, the driver and Zod by hand and other libraries, the Mongoose issues it solves and when not to use it.

MongoZod is a Mongoose alternative for TypeScript: one Zod schema validates every write, types every query and defines the inputs of each endpoint, on the official MongoDB driver.

A project on MongoDB and TypeScript usually describes each document three times: in a Mongoose schema, in a TypeScript type, and in the Zod schemas that validate what its endpoints receive. They drift apart, and each one checks something different:

import mongoose from 'mongoose'
import * as z from 'zod'

// For TypeScript
interface Post {
  title: string
  status: 'draft' | 'published'
  tags: string[]
  createdAt: Date
  updatedAt: Date
}

// For MongoDB: the same fields again
const postSchema = new mongoose.Schema<Post>(
  {
    title: { type: String, required: true, minlength: 1 },
    status: { type: String, enum: ['draft', 'published'], default: 'draft' },
    tags: { type: [String], default: [] }
  },
  { timestamps: true }
)

const PostModel = mongoose.model<Post>('Post', postSchema)

// For the endpoint: the same fields a third time
const createPostInput = z.object({

  title: z.string()
    .min(1),

  status: z.enum(['draft', 'published'])
    .optional(),

  tags: z.string()
    .array()
    .optional()
})

MongoZod keeps one description, the Zod schema, and builds the rest from it: the model validates every write with it, the types of every query and result come from it, and the inputs of the endpoints are derived from it:

import { MongoClient, ObjectId } from 'mongodb'
import { mongozod } from 'mongozod'
import * as mz from 'mongozod/mzod'
import * as z from 'zod'

const client = new MongoClient('mongodb://localhost:27017')

const postSchema = z.object({

  _id: mz.objectId()
    .default(() => new ObjectId()),

  title: z.string()
    .min(1),

  status: z.enum(['draft', 'published'])
    .default('draft'),

  tags: z.string()
    .array()
    .default([]),

  createdAt: mz.date(),

  updatedAt: mz.date()
})

const PostsModel = mongozod({

  collection: 'posts',

  db: client.db(),

  schema: postSchema,

  timestamps: true
})

const createPostInput = postSchema.pick({
  title: true,
  status: true,
  tags: true
})

type Post = z.output<typeof postSchema>

Compared with Mongoose

Mongoose 9MongoZod
SchemaA Mongoose schema, apart from what validates your endpointsOne Zod schema for the document, its types and the inputs of your endpoints
Document typeInferred only from a schema written inline; otherwise an interface kept in sync by hand, or the classes and decorators of TypegooseThe type of the Zod schema, wherever you declare it, with no interface and no decorators
Update validationOff by default, and only on $set, $unset, $push, $addToSet, $pull and $pullAllAlways, on every operator, with refinements, and on the document an upsert would insert
Required fieldsNot checked by an update unless it $unsets themAn upsert fails when the document it would insert misses one
FiltersCast at runtime: a value that cannot be cast throws a CastErrorTyped: a value of the wrong type does not compile, and what clients send is parsed by mz
ResultsDocuments that track changes, or plain objects with lean()The driver's plain objects, typed by the projection and populate
Endpoint inputsNot coveredmongozod/mzod: MongoDB syntax from clients, limited to what each endpoint declares
IndexesCreated when the app starts, unless you turn autoIndex offCreated by sync() when you call it, or on the first operation with the opt-in autoIndex plugin
DriverMongoose's own API on top of the driverThe driver's names, options and results, and the driver itself for everything else

Both libraries can share one connection, so a project can move one model at a time. The cheatsheet maps each Mongoose feature to its equivalent, and Differences lists what works another way.

Compared with the driver and Zod by hand

The driver with a Zod schema already validates an insert: parse the document, then insert the result. The rest is what a team ends up writing again in each project:

  • Updates: an update is a set of operators on paths, not a document. MongoZod validates the value of each path with the schema of that field, applies the rules of each operator ($inc on numbers, $unset on optional fields, array operators on arrays) and, for an upsert, builds and validates the document MongoDB would insert.
  • Types: the driver types a filter loosely. MongoZod checks every filter, update, projection and sort against the schema, dotted paths included, and narrows the result of a projection.
  • Relations and computed fields: populate, virtuals and timestamps, each typed in the reads that use it.
  • Inputs: a client that sends a filter, a sort or a page needs an allow list per field, a parser for each value and a type that fits the driver. mz builds them from the schema.
  • Cross-cutting rules: plugins wrap every operation, like the official soft delete.

Compared with other libraries

Other libraries that work with MongoDB and TypeScript, and what MongoZod chooses instead:

  • Papr is also a thin layer over the driver. It defines schemas with its own helpers and validates writes with a $jsonSchema on the server. MongoZod validates each write in the app with Zod, refinements and transforms included, and can also apply a $jsonSchema generated from the schema with sync().
  • zod-mongoose writes Mongoose schemas with Zod, and Mongoose runs underneath. MongoZod works on the driver, with its names, options and results.
  • Prisma describes the data in its own schema language and generates a client. Prisma ORM 7 does not support MongoDB yet, and its docs point to 6.19 for it. MongoZod keeps the description in TypeScript, as a Zod schema, with nothing to generate.
  • TypeORM and MikroORM model entities as classes with decorators, and TypeORM calls its MongoDB support basic. MongoZod reads and writes plain objects, with no classes and no decorators.

Mongoose issues it solves

Open issues of Mongoose that MongoZod handles by design:

  • Validating updates: the required fields of an upsert (#7037), the subdocuments of a findOneAndUpdate (#5234) and every error of an update at once, not only the first (#3037).
  • Types: projections with dotted paths (#16575) and a typed populate (#16010).
  • Queries: a filter value outside an enum does not compile (#15581).
  • Discriminators: several levels, the most upvoted open issue (#2851, also #8361 and #9756), with a z.discriminatedUnion inside another. See Document types.
  • Populate: a limit per document (#15863) and a reference stored in another key (#9034).
  • Field types: GeoJSON (#6606) and arrays without repeated values (#8082, #12916), with mz.set().
  • Soft delete, as an official plugin (#7304).
  • Explicit sync(): indexes and collections are not created on their own (#15803, #13546), unless you opt in to autoIndex for the indexes.

Mongoose's types have also slowed editors down (#10349). MongoZod's CI fails when the type instantiations of a typical project go over a budget, so a release cannot make your editor slower without anyone noticing.

When not to use it

MongoZod is not the right fit, or not yet, when a project needs:

  • Documents with methods and change tracking, like Mongoose's doc.save(). MongoZod writes with explicit operations, as the driver does.
  • Filters cast at runtime from untyped values. MongoZod types them instead, and parses what clients send with mz.
  • Zod 3, Node.js before 22.15 or TypeScript before 5.5.
  • Some Mongoose features that are planned but not there yet: select: false, immutable and alias on fields, optimistic concurrency with a version field, field-level encryption, and a plugin that keeps references consistent on delete.

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.

On this page