Object Modes and Unknown Keys
Object Modes and Unknown Keys
Choose the Zod object mode according to what you want to store. toMongooseSchema() applies the same unknown-key policy to the generated Mongoose schema, including ordinary nested objects and objects in arrays.
| Zod schema | Unknown input key | Mongoose document |
|---|---|---|
z.object(shape) | Zod strips it | Mongoose strips it |
z.strictObject(shape) | Zod rejects it | Mongoose rejects it |
z.looseObject(shape) | Zod accepts it | Mongoose keeps it |
z.object(shape).catchall(valueSchema) | Zod validates its value | Mongoose keeps it; the Zod validation hook checks its value |
The corresponding object methods .strict(), .loose(), and .passthrough() follow the same rules. .catchall(z.never()) is strict; any other catchall preserves unknown keys.
Store extra keys with a catchall
import mongoose from 'mongoose';
import {z} from 'zod/v4';
import {toMongooseSchema} from '@nullix/zod-mongoose';
const ProfileSchema = z.object({
name: z.string(),
city: z.string(),
}).catchall(z.string());
const Profile = mongoose.model('Profile', toMongooseSchema(ProfileSchema));
const saved = await Profile.create({name: 'Ada', city: 'Utrecht', nickname: 'A'});
const read = await Profile.findById(saved._id).lean();
console.log(read?.nickname); // 'A'
const invalid = new Profile({name: 'Ada', city: 'Utrecht'});
invalid.set('nickname', 42);
await invalid.validate();
// Rejects: nickname must be a string.
This also works on a nested object: z.object({profile: ProfileSchema}). Mongoose creates a subschema for profile and gives it the catchall policy. The package checks catchall values when the document is validated or saved.
Gotchas
Mongoose-generated fields. Mongoose adds _id to documents and subdocuments and may add a version key and timestamps after a save. During its document validation hook, the package excludes these generated fields from a Zod object that did not declare them. If your Zod shape declares one of these fields, its schema validates it. A direct call to ProfileSchema.parse(document.toObject()) does not use this adjustment; declare the fields in a separate read schema or remove them before parsing directly. Fields added by your own plugins still need to appear in a strict Zod shape.
When strict objects reject input. A z.strictObject() maps to Mongoose strict: 'throw'. An unknown root field usually throws while constructing the model. For a nested field, Mongoose can record a cast error and throw during validate() or save(). These are Mongoose errors; calling the Zod schema directly produces a Zod error.
Catchall values are dynamic fields. Mongoose keeps them but does not create an individually typed path for each key. Zod validates their values in the document validation hook; Mongoose does not cast them to the catchall type or create indexes for them. Loose and catchall objects default to minimize: false so a valid extra value of {} remains stored. Use document.set('extraKey', value) when changing a dynamic key on an existing document so Mongoose tracks the change.
Catchall types can affect declared keys in TypeScript. Zod's inferred type includes an index signature for the catchall. With z.object({age: z.number()}).catchall(z.string()), TypeScript may reject a model input containing age: 37 because the index signature expects strings. Zod still validates age as a number at runtime. If the TypeScript type matters for your model calls, make the catchall output type include the declared field types or use a separate typed input boundary.
Document saves and query updates have different validation paths. The package's Zod hook runs on document.validate() and document.save(). Operations such as updateOne() and findOneAndUpdate() do not run that document hook. Parse update input with Zod before those operations, or load and save a document when you need the full object checked. As with other Zod transforms, the parsed result of the hook is not written back to the Mongoose document.
const changes = ProfileSchema.partial().parse({nickname: 'Ada'});
await Profile.updateOne({_id: saved._id}, {$set: changes});
Explicit Mongoose options win. withMongoose(schema, {strict: ...}) and the toMongooseSchema() strict option override the inferred root policy. For a nested object, use withMongoose(child, {schema: {strict: ...}}). An override that strips fields can discard data accepted by z.looseObject() or .catchall(); an override that allows fields can defer a strict rejection until Zod validation. Setting minimize: true can remove empty dynamic objects.
Keep a subschema for nested modes. {schema: false} creates ordinary nested paths, which cannot carry their own strict, loose, or catchall policy. Conversion throws for that combination. If you only want to remove the nested _id, use withMongoose(child, {schema: {_id: false}}) instead.
Unions and intersections have their own mapping. A z.union() or z.intersection() is not itself a Zod object, even when its branches are objects. Its Mongoose mapping does not inherit an individual branch's unknown-key policy. Use a direct object schema for dynamic keys, or validate input separately when a union or intersection is required.
Object methods and field wrappers. .extend(), .safeExtend(), .pick(), .omit(), .partial(), and .required() are converted from their resulting Zod shape. .shape exposes that shape; .keyof() produces a separate enum. .required() retains the underlying Mongoose field type. A required nullable field relies on the Zod hook to reject absence because Mongoose's required option would also reject an allowed null. exactOptional() also retains the type and rejects an explicit undefined assigned to a document field; its error comes through Mongoose casting. A missing property remains valid. Parse raw input with Zod when the difference between a missing property and explicit undefined matters across query updates. The installed Zod 4.3.6 does not provide .exactPartial() or z.deepPartial().