@nullix/zod-mongoose
v1.0.13
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.
Documentation
See the full documentation for detailed usage examples and API reference.
Automatic Validation & Transformation Mapping
@nullix/zod-mongoose automatically maps Zod's built-in validations and transformations to their corresponding Mongoose SchemaType options.
| Zod Check | Mongoose Option | Target Types |
| :--- | :--- | :--- |
| .min(n) | minlength: n | z.string() |
| .max(n) | maxlength: n | z.string() |
| .length(n) | minlength: n, maxlength: n | z.string() |
| .regex(re) | match: re | z.string() |
| .trim() | trim: true | z.string() |
| .toLowerCase() | lowercase: true | z.string() |
| .toUpperCase() | uppercase: true | z.string() |
| .min(n) / .positive() | min: n | z.number(), z.date() |
| .max(n) / .negative() | max: n | z.number(), z.date() |
| .uuid() | type: Schema.Types.UUID | z.string() |
| .datetime() | type: Date | z.string() |
| .date() | type: Date | z.string() |
| .time() | type: String | z.string() |
Zod v4 & Mongoose 8 Support
This package is now optimized for Zod v4 and Mongoose 8.
Key features:
- Zod v4 registry: Securely store Mongoose-specific metadata alongside Zod schemas using native Zod v4 registry.
- Automatic Unwrapping: Support for
.transform(),.pipe(),.preprocess(),.refine(),.optional(),.nullable(), and.brand(). - 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 (orz.instanceof()). - Composite IDs: Full support for object-based
_idfields via{ includeId: true }metadata. - Isomorphic Support: Use
setFrontendMode(true)to allow schemas to be used in frontend environments where Mongoose is not available. Specialized types will automatically fall back to strings/Uint8Arrays while preserving Mongoose metadata for the backend. - Frontend Safe: 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. - Nuxt 4 Ready: Fully compatible with Nuxt 4 and Nitro, supporting best practices like
readValidatedBodywith Zod schemas. - 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.
Type Conversion Table
The following table shows how Zod types are mapped to Mongoose types by default.
| Zod Type | Mongoose Type | Notes |
| :--- | :--- | :--- |
| z.string() | String | Required by default unless .optional() is used. |
| z.string().uuid() | mongoose.Schema.Types.UUID | Maps to Mongoose's native UUID type. |
| z.string().datetime() | Date | Maps to Mongoose Date for automatic parsing and indexing. |
| z.string().date() | Date | Maps to Mongoose Date. |
| z.number() | Number | |
| z.boolean() | Boolean | |
| z.date() | Date | |
| z.bigint() | BigInt | Fallback to Number if BigInt is not supported. Use withMongoose with type: 'Long' for 64-bit integers. |
| z.enum() | String | Includes Mongoose enum validation. |
| z.nativeEnum() | String | Includes Mongoose enum validation. |
| z.string().trim() | String | Automatically sets trim: true. |
| z.string().toLowerCase() | String | Automatically sets lowercase: true. |
| z.string().min(5) | String | Automatically sets minlength: 5. |
| z.array() | [] | Mapped to a Mongoose array of the inner type. |
| z.set() | [] | Mapped to a Mongoose array of the value type. |
| z.tuple() | [] | Mapped to a Mongoose array of the first item's type. |
| z.record() | Map | Mapped to a Mongoose Map with of type. |
| z.map() | Map | Mapped to a Mongoose Map with of type. |
| z.object() | Nested Object | Mapped to a nested Mongoose schema or subdocument. |
| z.intersection() | Nested Object | Merges the definitions of both branches into a single object. |
| z.union() | Schema.Types.Union (primitives) or Nested Object (objects) | Mapped to Union for primitives, merged into an object for z.object() unions. Others fallback to Mixed. |
| z.xor() | mongoose.Schema.Types.Mixed | Non-inclusive union. Maps to Mixed with a custom Zod-based validator to enforce mutual exclusivity. |
| z.discriminatedUnion() | Mongoose Discriminator | Maps to native Mongoose discriminators. Common fields are automatically moved to the base schema. |
| zObjectId() | mongoose.Schema.Types.ObjectId | Specialized helper for ObjectIds. By default, it is omitted from the generated Mongoose schema to let Mongoose handle its auto-generation. |
| zBuffer() | mongoose.Schema.Types.Buffer | Specialized helper for Buffers. |
| zPopulated() | mongoose.Schema.Types.ObjectId | Helper for fields that can be either an ObjectId or a populated object. |
| z.instanceof(Buffer) | mongoose.Schema.Types.Buffer | |
| z.instanceof(ObjectId) | mongoose.Schema.Types.ObjectId | |
| z.literal() | String / Number / Boolean | Mapped to the literal's type with a Mongoose enum constraint. |
| z.any() / z.unknown() | mongoose.Schema.Types.Mixed | Fallback for unhandled types. |
Unhandled and Unsupported Zod Types
The following Zod types are currently not explicitly handled or are unsupported by nature and will fall back to Mongoose.Schema.Types.Mixed unless a custom type is provided via withMongoose.
| Zod Type | Current Status | Recommended Alternative |
| :--- | :--- | :--- |
| z.readonly() | Base Type | Automatically unwrapped, applies readOnly: true metadata. |
| z.promise() | Unsupported | Not applicable for database schemas |
| z.function() | Unsupported | Not applicable for database schemas |
| z.void() / z.never() | Unsupported | |
Note: Types like
z.branded(),z.readonly(),z.pipeline(),z.preprocess(), andz.transform()are automatically unwrapped to their underlying base type during conversion.
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 'zod/v4';
import { toMongooseSchema } from '@nullix/zod-mongoose';
const zodSchema = z.object({
username: z.string().min(3),
email: z.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 'zod/v4';
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 'zod/v4';
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 'zod/v4';
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.
Common fields (fields present in all union branches) are automatically extracted and moved to the base schema.
import { z } from 'zod/v4';
import { toMongooseSchema } from '@nullix/zod-mongoose';
const ActivityZodSchema = z.discriminatedUnion('type', [
z.object({
type: z.literal('login'),
timestamp: z.date(),
ip: z.string()
}),
z.object({
type: z.literal('post_create'),
timestamp: z.date(),
postId: zObjectId()
}),
]);
const ActivitySchema = toMongooseSchema(ActivityZodSchema);
// Mongoose will create a base schema with 'timestamp' field
// and two discriminators ('login', 'post_create') for the other fields.
const ActivityModel = mongoose.model('Activity', ActivitySchema);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 'zod/v4';
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 'zod/v4';
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-specific metadata to any Zod schema.
metadata: AMongooseMetaobject.
MongooseMeta extends Mongoose's SchemaTypeOptions<any> and SchemaOptions, allowing you to specify both field-level options (like index, unique, lowercase) and top-level schema options (like collection, versionKey, strict, _id, id) via the top-level Zod object.
// Disable _id and id at the schema level
const LogSchema = withMongoose(
z.object({ message: z.string() }),
{ _id: false, id: false }
);PopulatedSchema<T, K>
TypeScript utility type to extract the populated object type from a zPopulated 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 allzPopulatedfields 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.
zPopulated(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: zPopulated('User', UserSchema),
});zBuffer(options?)
Helper to create a Zod schema representing a Mongoose Buffer.
options: OptionalMongooseMetafor this field.
setFrontendMode(enabled)
Enable or disable frontend mode.
enabled:boolean.- In frontend mode,
zObjectIdfalls back to a regex-validated string, andzBufferfalls back toUint8Array. - This allows schemas to be shared between frontend and backend without requiring Mongoose on the client.
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
The following features from older versions of mongoose-zod (Mongoose 7 / Zod 3) 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.
