@nullix/zod-mongoose
v3.6.0
Published
Create Mongoose schemas using Zod v4.
Readme
@nullix/zod-mongoose
A library which allows to author mongoose ("a MongoDB object modeling tool") schemas using zod ("a TypeScript-first schema declaration and validation library").
Purpose
Declaring mongoose schemas in TypeScript environment has always been tricky in terms of getting the most out of type safety:
- You either have to first declare an interface representing a document in MongoDB and then create a schema corresponding to that interface (you get no type safety at all - even the offical mongoose documentation says that "you as the developer are responsible for ensuring that your document interface lines up with your Mongoose schema")
- Or reverse things by using
mongoose.InferSchemaType<typeof schema>which is far from ideal (impossible to narrow types, doesn't support TS enums, doesn't know about virtuals, has problems with fields namedtype, ...) - Finally, you can use typegoose which is based on legacy decorators proposal and generally poorly infers types.
This library aims to solve many of the aforementioned problems utilizing zod as a schema authoring tool.
1. Install
pnpm add @nullix/zod-mongoosewith dependencies:
pnpm add @nullix/zod-mongoose zod/v4 mongooseDocumentation
See the full documentation for detailed usage examples and API reference.
Validation and conversion
Built-in checks such as .min() and .regex() become Mongoose validators; string setters such as .trim() normalize on assignment. Field and object refinements also run during document validation. See the conversion reference for the complete mapping.
Zod v4 & Mongoose 9 Support
This package is now optimized for Zod v4 and Mongoose 9.
Key features:
- Zod v4 registry: Securely store Mongoose-specific metadata alongside Zod schemas using native Zod v4 registry.
- Output-aware conversion: Pipes, preprocessors, and codecs use their output schema. Bare transforms use
Mixed; parse input before storage. Optional and nullable wrappers retain their underlying types. - Unions: Primitive Zod unions (
string,number,boolean,date,bigint) are mapped to MongooseSchema.Types.Union. Object unions are automatically merged into a single schema object where all properties are optional, providing a flexible way to handle polymorphic data. Other complex unions fallback toMixed. - Native BigInt: Maps Zod
bigintto native MongooseBigInt(orNumberas fallback). If you need MongooseLong(64-bit integer), you can specify it viawithMongoose(z.bigint(), { type: 'Long' }). - Specialized Types: Direct support for
BufferandObjectIdviazObjectId()andzBuffer()helpers. - Composite IDs: Full support for object-based
_idfields via{ includeId: true }metadata. - Isomorphic Support: The package treats
mongooseas an optional peer dependency. Core Zod schema definition and metadata helpers (withMongoose,zObjectId, etc.) are safe to use in the browser without installingmongoose. Specialized types likeObjectIdandBufferautomatically fall back to browser-compatible representations (strings and Uint8Arrays) while preserving Mongoose metadata for the backend. - Automatic Browser Detection: Utilizing
package.jsonexports, the library automatically serves a frontend-optimized bundle when imported in browser environments, eliminating the need for manual configuration. - Nuxt 4 Ready: Use the core helpers in Nuxt and Nitro; conditional exports select the browser-safe client implementation automatically.
- Hookable: Extensible conversion process using
unjs/hookable. Developers can hook into 15+ points (e.g.,schema:object:before,schema:union:before). - Populated Helper:
PopulatedSchema<T>utility for perfect TypeScript inference of populated documents. - Mongoose Inference:
InferDocument<T>includes the generated_id;InferInput<T>describes incoming Zod data;InferQueryFilter<T>derives type-safe MongoDB query filters. - Strict Model:
toStrictModel<T>for advanced, fluent type-safe population (see Strict Model documentation).
zPoint() and zPolygon() provide GeoJSON values with native coordinate storage and validation. See the helper reference.
Values at the storage boundary
- Arrays: use
.default([])when omission should produce an empty list. Otherwise required arrays reject omission and optional arrays remain omitted. - Transforms: parse input before constructing or assigning a document. Add
.pipe(outputSchema)for automatic storage typing and output validation. Document validation does not rerun input callbacks; bare transforms useMixed. See Validate Documents. - Casting: direct construction retains Mongoose casting. Parse with Zod first when raw input must satisfy Zod's rules before casting.
Unknown-key modes also apply to nested subschemas and arrays. A typed catchall validates dynamic values on document.validate() and save(); query updates do not run that document hook. For nested strict, loose, and catchall objects, use a subschema rather than {schema: false}. Mongoose-generated IDs, version keys, and timestamps are excluded from the package's Zod document validation unless the Zod object declares them. See the object modes guide for examples and option overrides.
Installation
Install the package:
pnpm add @nullix/zod-mongoosePeer Dependencies
This package requires zod (^4.x). Note that zod imports should be from zod/v4 to ensure compatibility.
mongoose (^8.x) is an optional peer dependency. It is only required on the backend when calling toMongooseSchema(). You do not need to install mongoose on the frontend.
Usage
Basic Conversion
import { z } from '@nullix/zod-mongoose';
import { toMongooseSchema } from '@nullix/zod-mongoose';
const zodSchema = z.object({
username: z.string().min(3),
email: z.string().email(),
age: z.number().optional(),
});
// Basic conversion
const mongooseSchema = toMongooseSchema(zodSchema);Plugins
You can pass Mongoose plugins directly to toMongooseSchema. This is the recommended way to extend your schemas with third-party or custom plugins.
import { z } from '@nullix/zod-mongoose';
import { toMongooseSchema } from '@nullix/zod-mongoose';
import mongooseLeanVirtuals from 'mongoose-lean-virtuals';
const zodSchema = z.object({
title: z.string(),
});
// Apply plugins during conversion
const mongooseSchema = toMongooseSchema(zodSchema, {
plugins: [mongooseLeanVirtuals]
});
// The resulting schema now has the plugin applied
const Post = mongoose.model('Post', mongooseSchema);
const doc = await Post.findOne().lean({ virtuals: true });Hook: schema:created
For more advanced extensions, use the schema:created hook to modify the mongoose.Schema instance immediately after it is created.
import { hooks } from '@nullix/zod-mongoose';
hooks.hook('schema:created', ({ schema, zodSchema }) => {
// Add a virtual field to all schemas that have a 'title'
if ('title' in zodSchema.shape) {
schema.virtual('slug').get(function() {
return this.title.toLowerCase().replace(/ /g, '-');
});
}
});Adding Mongoose Metadata
Use withMongoose to add Mongoose-specific field options like unique, index, or required.
import { z } from '@nullix/zod-mongoose';
import { toMongooseSchema, withMongoose } from '@nullix/zod-mongoose';
const zodSchema = z.object({
username: withMongoose(z.string(), {
unique: true,
index: true,
lowercase: true
}),
// Default values set with zod's .default() are respected
roles: z.array(z.string()).default(['user']),
});
const mongooseSchema = toMongooseSchema(zodSchema);Timestamps
The genTimestampsSchema helper simplifies creating Mongoose-compatible Zod schemas with timestamp support.
import { z } from '@nullix/zod-mongoose';
import { toMongooseSchema, genTimestampsSchema, withMongoose } from '@nullix/zod-mongoose';
const userSchema = withMongoose(
z.object({
name: z.string(),
...genTimestampsSchema(),
}),
{ timestamps: true }
);
const mongooseSchema = toMongooseSchema(userSchema);
// Mongoose schema will have { timestamps: true } automatically.Discriminators
1. Native Mongoose Discriminators (Recommended)
When using z.discriminatedUnion(), the library automatically maps it to native Mongoose discriminators. This provides the most robust way to handle polymorphic data in Mongoose, including proper indexing and query support.
Top-level discriminated unions require modelName or collection. Mongoose registers discriminator model names globally, so the model name prevents collisions with standalone models. It affects only the Mongoose model name; the discriminator value stored in MongoDB remains the Zod literal value.
Common fields (fields present in all union branches) are automatically extracted and moved to the base schema.
import mongoose from 'mongoose';
import { z } from 'zod/v4';
import { toMongooseSchema, zObjectId } from '@nullix/zod-mongoose';
const ClickedEventZodSchema = z.object({
type: z.literal('clicked'),
timestamp: z.date(),
target: z.string(),
});
const ViewedEventZodSchema = z.object({
type: z.literal('viewed'),
timestamp: z.date(),
postId: zObjectId(),
});
const ActivityZodSchema = z.discriminatedUnion('type', [
ClickedEventZodSchema,
ViewedEventZodSchema,
]);
const ActivitySchema = toMongooseSchema(ActivityZodSchema, {
modelName: 'Activity',
});
// Mongoose will create a base schema with 'timestamp' field
// and two discriminators ('clicked', 'viewed') for the other fields.
const ActivityModel = mongoose.model('Activity', ActivitySchema);The discriminator key is type; its values (clicked and viewed) are stored in MongoDB. The base model name is Activity, while the generated Mongoose discriminator model names are Activity_clicked and Activity_viewed. zod-mongoose uses ${modelName}_${discriminatorValue} as the model name and passes the original value through { value: discriminatorValue }. If modelName is omitted, the name is derived from collection by singularizing it. Omitting both for a top-level discriminated union throws a helpful error.
Accessing discriminator models
toMongooseSchema() returns the base mongoose.Schema, not a model. After compiling it into a model, Mongoose exposes the compiled discriminator models through BaseModel.discriminators:
const ActivityModel = mongoose.model('Activity', ActivitySchema);
const ClickedEventModel = ActivityModel.discriminators?.Activity_clicked as
mongoose.Model<z.infer<typeof ClickedEventZodSchema>>;
const event = await ClickedEventModel.findById(id);The discriminator model uses the same collection and automatically filters on type: 'clicked'. The registered model name is Activity_clicked; the stored discriminator value remains clicked.
2. Manual Discriminators
If you prefer to define discriminators manually on a base model, you can define the discriminatorKey in the base schema's metadata.
import { z } from '@nullix/zod-mongoose';
import { toMongooseSchema, withMongoose } from '@nullix/zod-mongoose';
const baseSchema = withMongoose(
z.object({
name: z.string(),
// Include the discriminator key in the Zod schema for type-safe access
type: z.string().optional(),
}),
{ discriminatorKey: 'type' }
);
const BaseModel = mongoose.model('Base', toMongooseSchema(baseSchema));
const carSchema = z.object({
licensePlate: z.string(),
});
const CarModel = BaseModel.discriminator('Car', toMongooseSchema(carSchema));Non-Inclusive Unions (XOR)
Use z.xor() for unions where exactly one option must match. This is mapped to Schema.Types.Mixed with a custom Zod validator that enforces mutual exclusivity at the database level.
const paymentSchema = z.xor([
z.object({ type: z.literal('card'), cardNumber: z.string() }),
z.object({ type: z.literal('bank'), accountNumber: z.string() }),
]);
const mongooseSchema = toMongooseSchema(z.object({ payment: paymentSchema }));ObjectIds and _id Handling
By default, zObjectId() creates a required Zod field (unless .optional() is used) but omits the field from the generated Mongoose schema. This allows Mongoose to manage the automatic generation and internal lifecycle of the _id field without conflicts.
1. Standard Usage (Recommended)
For full document schemas, include the _id field using zObjectId().
const UserZodSchema = z.object({
_id: zObjectId(), // Required for Zod validation, omitted from Mongoose schema
name: z.string(),
});2. Input/Create Schemas
When validating data for new records (e.g., in a POST request), you should use an Input Schema that omits the _id field. This ensures that the client cannot provide an ID that Mongoose should be generating.
const UserInputSchema = UserZodSchema.omit({ _id: true });
// Now UserInputSchema.parse({}) will succeed without an _id.3. Explicitly Defining _id in Mongoose
If you need to explicitly define the _id field in the Mongoose schema (e.g., to add an index, a custom getter, or to disable auto-generation), use the includeId: true flag.
const CustomIdSchema = z.object({
_id: zObjectId({ includeId: true, index: true }),
});4. Composite IDs
Mongoose supports composite IDs (using an object as the _id). You can define this in Zod by using an object for the _id field and setting includeId: true in the metadata of the _id field (or the parent object).
const CompositeIdSchema = z.object({
pk: z.string(),
sk: z.string(),
});
const UserSchema = z.object({
_id: withMongoose(CompositeIdSchema, { includeId: true }),
name: z.string(),
});
const mongooseSchema = toMongooseSchema(UserSchema);
// Resulting Mongoose schema will have _id: { pk: String, sk: String }Buffers and ObjectIds
For specialized Mongoose types, use the provided zObjectId() and zBuffer() helpers. They also accept optional metadata.
import { z } from '@nullix/zod-mongoose';
import { toMongooseSchema, zObjectId, zBuffer } from '@nullix/zod-mongoose';
const schema = z.object({
avatar: zBuffer({ required: true }),
ownerId: zObjectId({ index: true }),
// Arrays are also supported
tags: z.array(zObjectId()),
});
const mongooseSchema = toMongooseSchema(schema);You can also use z.instanceof(Buffer) or z.instanceof(mongoose.Types.ObjectId) directly, which will be correctly inferred.
API Reference
toMongooseSchema(zodSchema, options?)
Converts a Zod schema to a Mongoose schema instance (new mongoose.Schema(...)).
zodSchema: A Zod object or any Zod type.options: Optional MongooseSchemaOptions.
extractMongooseDef(zodSchema)
Converts a Zod schema to a Mongoose schema definition object (the POJO used as the first argument to new mongoose.Schema(...)). This is useful if you want to manually create the Mongoose schema or combine it with other definitions.
zodSchema: A Zod object or any Zod type.
withMongoose(zodSchema, metadata)
Attaches Mongoose metadata to a Zod schema. For object schemas, use different placement for root and nested schema options:
| Where the object is used | Remove its generated _id and id virtual |
| --- | --- |
| Passed to toMongooseSchema() | withMongoose(object, { _id: false, id: false }) |
| Nested inside another object | withMongoose(object, { schema: { _id: false, id: false } }) |
_id: false removes the identifier path; id: false only disables the string id virtual. Field options such as index and unique go directly in the metadata. See Nested Object IDs for the full behavior.
InferDocument<T> / InferInput<T> / InferQueryFilter<T>
Use InferDocument for values read from Mongoose, InferInput for data submitted to Zod, and InferQueryFilter for MongoDB query filters. InferDocument adds Mongoose's generated _id; InferInput is equivalent to z.input<T>, so it preserves coercion and preprocessing input types; InferQueryFilter derives the type-safe QueryFilter<InferDocument<T>> for queries and repository layers.
import { z } from 'zod/v4';
import type { InferDocument, InferInput, InferQueryFilter } from '@nullix/zod-mongoose';
const UserSchema = z.object({
name: z.string(),
age: z.number(),
});
type UserDocument = InferDocument<typeof UserSchema>;
type CreateUserInput = InferInput<typeof UserSchema>;
type UserFilter = InferQueryFilter<typeof UserSchema>;
const user: UserDocument = {
_id: new mongoose.Types.ObjectId(),
name: 'John Doe',
age: 30,
};
const filter: UserFilter = {
age: { $gte: 18 },
};InferMongoose, OutputMongoose, and InputMongoose remain available in v3.1 for backwards compatibility. Migrate to InferDocument and InferInput; the legacy aliases will be removed in v4.
PopulatedSchema<T, K>
TypeScript utility type to extract the populated object type from a zRef union within a larger type. This is useful for typing Mongoose results after calling .populate().
T: The Zod-inferred type (e.g.,z.infer<typeof PostSchema>).K: The key(s) to populate (optional). If omitted, it will try to populate allzReffields in the object.
import { PopulatedSchema } from '@nullix/zod-mongoose';
// Populate only the 'author' field
type PostWithAuthor = PopulatedSchema<z.infer<typeof PostSchema>, 'author'>;
// Populate all possible fields
type FullPost = PopulatedSchema<z.infer<typeof PostSchema>>;zObjectId(options?)
Helper to create a Zod schema representing a Mongoose ObjectId.
options: OptionalMongooseMetafor this field.
zRef(ref, schema, options?)
Helper for fields that can be either an ObjectId (unpopulated) or a populated object.
ref: The name of the Mongoose model being referenced.schema: The Zod schema representing the populated object.options: OptionalMongooseMetafor this field.
const UserSchema = z.object({ name: z.string() });
const PostSchema = z.object({
author: zRef('User', UserSchema),
});zBuffer(options?)
Helper to create a Zod schema representing a Mongoose Buffer.
options: OptionalMongooseMetafor this field.
When validating a Mongoose document, zBuffer() also accepts the BSON Binary value produced by document.toObject() and converts it back to a Node.js Buffer.
Isomorphic Support
@nullix/zod-mongoose is designed to be isomorphic, meaning you can use the same Zod schemas on both the frontend (browser) and backend (Node.js).
Automatic Frontend Mode
The library automatically detects the environment using package.json conditional exports. When you import from @nullix/zod-mongoose in a browser environment (detected by bundlers like Vite, Webpack, or Nuxt), it uses a specialized frontend bundle that:
- Does not depend on
mongoose. - Maps
zObjectId()to a regex-validated string. - Maps
zBuffer()toUint8Array. - Preserves all Mongoose metadata so the same schema can be used to generate a Mongoose schema on the backend.
Legacy Manual Configuration
setFrontendMode() remains available in v3.1 but is deprecated and logs a warning when called. Conditional exports now select the correct implementation automatically.
import { setFrontendMode } from '@nullix/zod-mongoose';
// Force frontend mode (e.g. in tests or specialized environments)
setFrontendMode(true);Migration: Remove this call. In Nuxt, continue importing shared schema helpers from
@nullix/zod-mongoose; its conditional exports select the correct bundle.
genTimestampsSchema(createdAtField?, updatedAtField?)
Returns a plain object (Zod shape) with timestamp fields. This allows for easy spreading into z.object().
- Default fields are
createdAtandupdatedAt. - Pass
nullto disable a specific field.
const schema = z.object({
...genTimestampsSchema(),
name: z.string(),
});Hooks
@nullix/zod-mongoose uses unjs/hookable to provide an extensible conversion process. Developers can register hooks to modify the Mongoose definition at any point of the conversion.
Registering Hooks
import { hooks } from '@nullix/zod-mongoose';
// Modify every string field to be uppercase
hooks.hook('converter:node', (context) => {
if (context.type === 'string') {
context.mongooseProp.uppercase = true;
}
});
// Add a custom property after any validation mapping
hooks.hook('validation:mappers', (context) => {
context.mongooseProp.customField = 'my-value';
});Available Hooks
The following hook points are available:
converter:before: Called before starting the conversion.converter:start: Called at the start of eachextractMongooseDefcall (recursive).converter:unwrapped: Called after unwrapping a Zod schema and extracting metadata.converter:node: Called for each Zod type node being processed.converter:after: Called after a node's conversion is complete.schema:object:before: Called before processing az.object().schema:object:field: Called for each field in az.object().schema:object:after: Called after processing az.object().schema:array:before: Called before processing az.array(),z.set(), orz.tuple().schema:array:after: Called after processing an array-like type.schema:record:before: Called before processing az.record()orz.map().schema:record:after: Called after processing a record/map type.schema:union:before: Called before processing az.union()orz.discriminatedUnion(). Provides actxobject withisSimpleUnionwhich can be modified to force the use ofSchema.Types.Union.schema:union:after: Called after processing a union.registry:get:before: Called before retrieving metadata from the registry.registry:get: Called when retrieving metadata from the registry.registry:add: Called before adding metadata to the registry.registry:added: Called after adding metadata to the registry.validation:mappers: Called after mapping Zod validations to Mongoose options.schema:created: Called after amongoose.Schemainstance is created (fortoMongooseSchema).
Deprecated inspirations
This project was once inspired by https://github.com/Harm-Nullix/mongoose-zod.
Later on, the whole core got replaced, but if you are migrating from mongoose-zod here is what you need to know about features that are no longer supported or have changed:
mongooseZodCustomType(): This was previously used to directly define a Mongoose type on a Zod schema. In the new version, usewithMongoose(z.any(), { type: mongoose.Schema.Types.YourType })or specialized helpers likezObjectId()andzBuffer().toZodMongooseSchema(): Previously, this returned aZodMongoosewrapper used to generate the Mongoose schema. This function has been replaced bytoMongooseSchema()which returns a fullmongoose.Schemainstance, orextractMongooseDef()which returns the raw Mongoose schema definition (POJO).toMongooseSchema(): In the old version, this was a method on theZodMongooseinstance. Now it's a standalone function that accepts a Zod schema and returns amongoose.Schemaobject.ZodMongooseclass: Replaced by standard Zod types with registry-stored metadata.setup({ z }): No longer required. The library uses thezod/v4registry directly and does not modify the Zod prototype..mongoose(),.mongooseTypeOptions(),.mongooseSchemaOptions(): These prototype extensions are deprecated. UsewithMongoose()instead.- Automatic Plugin Loading: Optional peer dependencies like
mongoose-lean-*are no longer automatically attached. Plugins should be applied to the Mongoose schema manually.
💖 Support the Project
If you find @nullix/zod-mongoose helpful, consider supporting its development:
- 🩵 Sponsor on GitHub
- ⭐ Star the repository on GitHub
- 💬 Share it with your developer community
