v3.3.1
Playground
Guides

Nested Object IDs

See when Mongoose adds an _id to a nested Zod object and how to turn it off.

Nested Object IDs

When you place a z.object() inside another z.object(), toMongooseSchema() creates a Mongoose subschema for the inner object. Mongoose gives that subschema its own _id by default. This applies even when the inner Zod schema was imported from another file.

Flat options on the object passed to toMongooseSchema() configure the root. Options for a nested subschema go under schema on that nested object.

You only need withMongoose when you want to change that default. Choose based on the behavior you want:

Nested objectMongoose resultNested _id
Plain z.object()SubschemaGenerated
withMongoose(object, { schema: { _id: false } })SubschemaAbsent
withMongoose(object, { schema: false })Nested pathsAbsent

For the usual case—keep an embedded document but remove its ID—the application code is short:

address-schema.ts
import {z} from 'zod/v4';
import {withMongoose} from '@nullix/zod-mongoose';

const AddressSchema = z.object({city: z.string()});
const AddressWithoutId = withMongoose(AddressSchema.safeExtend({}), {
  schema: {_id: false, id: false},
});

const UserSchema = z.object({address: AddressWithoutId});

safeExtend({}) gives this option a separate schema instance. If every use of AddressSchema should omit IDs, attach the metadata where you define it instead.

Run the example

From the repository root, run this script. It creates Mongoose schemas and documents in memory; it does not connect to MongoDB.

bun run packages/core/examples/nested-object-ids.ts
packages/core/examples/nested-object-ids.ts
/* eslint-disable no-console */
import assert from 'node:assert/strict';
import mongoose from 'mongoose';
import {z} from 'zod/v4';
import {toMongooseSchema, withMongoose} from '../src/index.js';

const CoordinatesSchema = z.object({latitude: z.number(), longitude: z.number()});

// A plain nested z.object becomes a Mongoose subschema with its own _id.
const DefaultPlaceSchema = toMongooseSchema(z.object({location: CoordinatesSchema}));
const DefaultPlace = mongoose.model('DefaultPlaceExample', DefaultPlaceSchema);
const defaultPlace = new DefaultPlace({location: {latitude: 52.37, longitude: 4.9}});
assert.ok(defaultPlace._id instanceof mongoose.Types.ObjectId);
assert.ok(defaultPlace.get('location._id') instanceof mongoose.Types.ObjectId);

// Root options affect the root, not the automatically created subschema.
const RootWithoutId = withMongoose(z.object({location: CoordinatesSchema}), {
  _id: false,
  id: false,
});
const RootWithoutIdSchema = toMongooseSchema(RootWithoutId);
assert.equal(RootWithoutIdSchema.path('_id'), undefined);
assert.ok(RootWithoutIdSchema.path('location').schema.path('_id'));

// Keep the subschema but remove its _id (and the id virtual).
const CoordinatesWithoutId = withMongoose(CoordinatesSchema.safeExtend({}), {
  schema: {_id: false, id: false},
});
const PlaceWithoutNestedIdSchema = toMongooseSchema(z.object({location: CoordinatesWithoutId}));
const PlaceWithoutNestedId = mongoose.model('PlaceWithoutNestedIdExample', PlaceWithoutNestedIdSchema);
const placeWithoutNestedId = new PlaceWithoutNestedId({
  location: {latitude: 52.37, longitude: 4.9},
});
assert.ok(placeWithoutNestedId._id instanceof mongoose.Types.ObjectId);
assert.equal(placeWithoutNestedId.get('location._id'), undefined);
assert.equal(PlaceWithoutNestedIdSchema.path('location').schema.options.id, false);

// schema: false produces nested paths instead of a separate subschema.
const CoordinatesAsPaths = withMongoose(CoordinatesSchema.safeExtend({}), {schema: false});
const PlaceWithPathsSchema = toMongooseSchema(z.object({location: CoordinatesAsPaths}));
assert.equal(PlaceWithPathsSchema.path('location'), undefined);
assert.ok(PlaceWithPathsSchema.path('location.latitude'));

console.log('Default: root and nested object have _id');
console.log('Root _id: false: nested object still has _id');
console.log('Nested schema._id: false: only the root has _id');
console.log('schema: false: location uses nested paths');

_id: false removes the actual identifier path. id: false disables Mongoose's string id virtual on the subschema; it does not remove _id by itself. Use schema: { _id: false, id: false } when you want a subschema without either one. Use schema: false when you want ordinary nested paths instead of a subschema.

The script reads generated nested IDs with Mongoose's document.get() method because the Zod object type does not declare IDs that Mongoose adds at runtime.

For options on an imported object that you use in several places, see Reusing Object Schemas.