Convert Zod schemas to MongoDB-compatible JSON Schemas effortlessly.
As your project matures, the structure of your database tends to stabilize. That's where JSON Schemas come in — they let you annotate and validate your MongoDB documents so that invalid values don't sneak in and break your app in production.
But writing JSON Schemas by hand isn't fun. As a JavaScript developer, chances are you're already using Zod to define your schemas.
Wouldn't it be great if you could just take your existing Zod schema and instantly turn it into a MongoDB-compatible JSON Schema?
That's exactly what zod-to-mongo-schema does. It takes your Zod schema and
converts it into a ready-to-use JSON Schema that can be applied directly to your
MongoDB collections for validation.
Note: This library expects Zod
^3.25.0or4.x.xas a peer dependency.
# npm
npm install zod-to-mongo-schema
# yarn
yarn add zod-to-mongo-schema
# pnpm
pnpm add zod-to-mongo-schemaimport z from "zod";
import zodToMongoSchema from "zod-to-mongo-schema";
const userSchema = z.object({
name: z.string(),
age: z.number().min(18),
isAdmin: z.boolean(),
});
const mongoSchema = zodToMongoSchema(userSchema);
console.log(JSON.stringify(mongoSchema, null, 2));{
"type": "object",
"properties": {
"name": {
"type": "string"
},
"age": {
"type": "number",
"minimum": 18
},
"isAdmin": {
"type": "boolean"
}
},
"required": ["name", "age", "isAdmin"],
"additionalProperties": false
}const userSchema = z.object({
name: z.string().meta({
title: "User Name",
description: "This is the name assigned to the user",
}),
profile: z.object({
bio: z.string().optional(),
followers: z.int().min(0),
}),
});
const mongoSchema = zodToMongoSchema(userSchema);
console.log(JSON.stringify(mongoSchema, null, 2));{
"type": "object",
"properties": {
"name": {
"title": "User Name",
"description": "This is the name assigned to the user",
"type": "string"
},
"profile": {
"type": "object",
"properties": {
"bio": {
"type": "string"
},
"followers": {
"minimum": 0,
"bsonType": "long"
}
},
"required": ["followers"],
"additionalProperties": false
}
},
"required": ["name", "profile"],
"additionalProperties": false
}If there's no direct Zod API for a BSON type, you can use z.unknown().meta():
const userSchema = z.object({
_id: z.unknown().meta({ bsonType: "objectId" }),
createdAt: z.unknown().meta({ bsonType: "date" }),
});
const mongoSchema = zodToMongoSchema(userSchema);
console.log(JSON.stringify(mongoSchema, null, 2));{
"type": "object",
"properties": {
"_id": {
"bsonType": "objectId"
},
"createdAt": {
"bsonType": "date"
}
},
"required": ["_id", "createdAt"],
"additionalProperties": false
}Only z.unknown() can be used with .meta() to specify BSON types. Using
.meta() on other Zod types will throw:
const userSchema = z.object({
_id: z.string().meta({ bsonType: "objectId" }),
});
const mongoSchema = zodToMongoSchema(userSchema);Error: `bsonType` can only be used with `z.unknown()`.When chaining methods like .and(), .or(), or .nullable() on these custom
fields, .meta({ bsonType }) must come first. Otherwise, the metadata will be
applied to the wrapper instead of the actual field, resulting in an error or
incorrect Mongo schema.
import { ObjectId } from "mongodb";
const userSchema = z.object({
_id: z.unknown().meta({ bsonType: "objectId" }).nullable(), // correct
// _id: z.unknown().nullable().meta({ bsonType: "objectId" }), // incorrect
});
const mongoSchema = zodToMongoSchema(userSchema);
console.log(JSON.stringify(mongoSchema, null, 2));{
"type": "object",
"properties": {
"_id": {
"anyOf": [
{
"bsonType": "objectId"
},
{
"type": "null"
}
]
}
},
"required": ["_id"],
"additionalProperties": false
}For runtime validation, .refine() can be applied before .meta(). This
ensures the validation logic is preserved while still including the metadata:
import { ObjectId } from "mongodb";
const userSchema = z.object({
_id: z
.unknown()
.refine((value) => ObjectId.isValid(value as any))
.meta({ bsonType: "objectId" }),
createdAt: z
.unknown()
.refine((value) => !Number.isNaN(new Date(value as any).getTime()))
.meta({ bsonType: "date" }),
});For numbers, z.number() is sufficient. It produces type: "number", which can
represent integer, decimal, double, or long BSON types.
However, if you want to be specific, use:
z.int32()for BSONintz.int()andz.uint32()for BSONlongz.float32()andz.float64()for BSONdouble.metato specify custom BSON numeric types likedecimal
const userSchema = z.object({
height: z.number(),
age: z.int32(),
totalPoints: z.int(),
precision32: z.float32(),
precision64: z.float64(),
balance: z.unknown().meta({ bsonType: "decimal" }),
});
const mongoSchema = zodToMongoSchema(userSchema);
console.log(JSON.stringify(mongoSchema, null, 2));{
"type": "object",
"properties": {
"height": {
"type": "number"
},
"age": {
"bsonType": "int"
},
"totalPoints": {
"bsonType": "long"
},
"precision32": {
"minimum": -3.4028234663852886e38,
"maximum": 3.4028234663852886e38,
"bsonType": "double"
},
"precision64": {
"bsonType": "double"
},
"balance": {
"bsonType": "decimal"
}
},
"required": [
"height",
"age",
"totalPoints",
"precision32",
"precision64",
"balance"
],
"additionalProperties": false
}When .min() or .max() is used with z.int32() or z.int(), the BSON type
is inferred based on range:
- Within the 32-bit range is
int - Above 32-bit but within 64-bit range is
long - Beyond the 64-bit range falls back to
number
const userSchema = z.object({
smallInt: z.int().min(-100).max(100),
mediumInt: z.int().min(-2_147_483_648).max(2_147_483_647),
largeInt: z.int().min(-9_000_000_000_000_000).max(9_000_000_000_000_000),
});
const mongoSchema = zodToMongoSchema(userSchema);
console.log(JSON.stringify(mongoSchema, null, 2));{
"type": "object",
"properties": {
"smallInt": {
"minimum": -100,
"maximum": 100,
"bsonType": "int"
},
"mediumInt": {
"bsonType": "int"
},
"largeInt": {
"minimum": -9000000000000000,
"maximum": 9000000000000000,
"bsonType": "long"
}
},
"required": ["smallInt", "mediumInt", "largeInt"],
"additionalProperties": false
}Zod's z.number(), z.float32(), and z.float64() all serialize to
"type": "number" in JSON Schema. This means the original intent
(float32 vs float64 vs generic number) is lost during conversion. To prevent
incorrect type inference, only exact IEEE-754 float32/float64 ranges are
treated as double. Any custom or partial numeric range simply falls back to
"number", with its range preserved. This ensures precision is never assumed
where intent is ambiguous:
const FLOAT32_MIN = -3.402_823_466_385_288_6e38;
const FLOAT32_MAX = 3.402_823_466_385_288_6e38;
const FLOAT64_MIN = -1.797_693_134_862_315_7e308;
const FLOAT64_MAX = 1.797_693_134_862_315_7e308;
const schema = z.object({
float32: z.float32(),
float32DefaultRange: z.number().min(FLOAT32_MIN).max(FLOAT32_MAX),
float64: z.float64(),
float64DefaultRange: z.number().min(FLOAT64_MIN).max(FLOAT64_MAX),
customRange1: z.float32().min(0.1).max(99.9), // Falls back to "number"
customRange2: z.float64().min(0.5), // Falls back to "number"
});
const mongoSchema = zodToMongoSchema(schema);
console.log(JSON.stringify(mongoSchema, null, 2));{
"type": "object",
"properties": {
"float32": {
"minimum": -3.4028234663852886e38,
"maximum": 3.4028234663852886e38,
"bsonType": "double"
},
"float32DefaultRange": {
"minimum": -3.4028234663852886e38,
"maximum": 3.4028234663852886e38,
"bsonType": "double"
},
"float64": {
"bsonType": "double"
},
"float64DefaultRange": {
"bsonType": "double"
},
"customRange1": {
"minimum": 0.1,
"maximum": 99.9,
"type": "number"
},
"customRange2": {
"minimum": 0.5,
"maximum": 1.7976931348623157e308,
"type": "number"
}
},
"required": [
"float32",
"float32DefaultRange",
"float64",
"float64DefaultRange",
"customRange1",
"customRange2"
],
"additionalProperties": false
}MongoDB's $jsonSchema operator does not support the following JSON Schema
keywords:
$ref$schemadefaultdefinitionsformatid
These keywords, along with unknown ones, are automatically removed during conversion unless they appear as property names:
const userSchema = z.object({
id: z.uuid(),
name: z.string().default("Anonymous"),
age: z.string().meta({ whatever: "trash" }),
});
const mongoSchema = zodToMongoSchema(userSchema);
console.log(JSON.stringify(mongoSchema, null, 2));{
"type": "object",
"properties": {
"id": {
"type": "string",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"
},
"name": {
"type": "string"
},
"age": {
"type": "number"
}
},
"required": ["id", "name", "age"],
"additionalProperties": false
}The following Zod APIs are not representable in JSON Schema and will throw an error if encountered:
z.bigint()z.uint64()z.int64()z.symbol()z.void()z.date()z.map()z.set()z.transform()z.nan()z.custom()
Note that any number of items can be added to the object passed to .meta(),
and any fields added in .meta() will override those defined in the schema:
const userSchema = z
.object({
name: z.string().meta({
title: "Username",
description: "A unique username",
example: "johndoe",
whatever: "trash",
}),
})
.meta({ additionalProperties: true });
const jsonSchema = z.toJSONSchema(userSchema);
console.log(JSON.stringify(jsonSchema, null, 2));{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"additionalProperties": true,
"type": "object",
"properties": {
"name": {
"title": "Username",
"description": "A unique username",
"example": "johndoe",
"whatever": "trash",
"type": "string"
}
},
"required": ["name"]
}This is the intended design of the .meta() API —
Zod allows arbitrary metadata.
However, zod-to-mongo-schema expects you to use it only for two purposes:
-
To specify
titleanddescriptionfields:const userSchema = z.object({ email: z.email().meta({ title: "User Email", description: "The user's registered email address", }), }); const mongoSchema = zodToMongoSchema(userSchema); console.log(JSON.stringify(mongoSchema, null, 2));
{ "type": "object", "properties": { "email": { "title": "User Email", "description": "The user's registered email address", "type": "string", "pattern": "^(?!\\.)(?!.*\\.\\.)([A-Za-z0-9_'+\\-\\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$" } }, "required": ["email"], "additionalProperties": false } -
To specify a custom BSON type with
z.unknownif the Zod API doesn't have it:const userSchema = z.object({ _id: z.unknown().meta({ bsonType: "objectId" }), createdAt: z.unknown().meta({ bsonType: "date" }), }); const mongoSchema = zodToMongoSchema(userSchema); console.log(JSON.stringify(mongoSchema, null, 2));
{ "type": "object", "properties": { "_id": { "bsonType": "objectId" }, "createdAt": { "bsonType": "date" } }, "required": ["_id", "createdAt"], "additionalProperties": false }
Of course, you can choose to break the rule and still extend it beyond those two cases:
const userSchema = z.object({
name: z.string().meta({ maxLength: 50, default: "Anonymous" }),
});
const mongoSchema = zodToMongoSchema(userSchema);
console.log(JSON.stringify(mongoSchema, null, 2));{
"type": "object",
"properties": {
"name": {
"maxLength": 50,
"type": "string"
}
},
"required": ["name"],
"additionalProperties": false
}However, you cannot specify type and bsonType simultaneously for a schema,
since that would be invalid for MongoDB. If you do, an error will be thrown:
const userSchema = z.object({
_id: z.unknown().meta({ type: "boolean", bsonType: "objectId" }),
});
const mongoSchema = zodToMongoSchema(userSchema);Error: Cannot specify both `type` and `bsonType` simultaneously.Outside those two cases, the library assumes you know better than it — so you're fully responsible for ensuring the produced JSON Schema is valid for MongoDB.
zod-to-mongo-schema encourages you to rely on your Zod schemas as much as
possible, and only step outside them for the two supported .meta() uses listed
above.
The tables below show how to express common MongoDB JSON Schema patterns using standard Zod APIs.
| MongoDB | Zod |
|---|---|
double |
z.float32(), z.float64() |
string |
z.string() |
object |
z.object() |
array |
z.array(), z.tuple() |
binData |
.meta({ bsonType: "binData" }) |
objectId |
.meta({ bsonType: "objectId" }) |
bool |
z.boolean(), z.stringbool() |
date |
.meta({ bsonType: "date" }) |
null |
z.null() |
regex |
.meta({ bsonType: "regex" }) |
javascript |
.meta({ bsonType: "javascript" }) |
int |
z.int32() |
long |
z.int(), z.uint32() |
decimal |
.meta({ bsonType: "decimal" }) |
number |
z.number() |
Note:
timestamp,minKeyandmaxKeyare BSON types not included in the list above. They were not added as they're MongoDB internal types not intended for outside usage.
To learn more about MongoDB BSON types, check out the MongoDB docs.
This table is a work in progress. If you know of a Zod API that maps to a MongoDB JSON Schema keyword but it isn't here, please open a PR for it.
| MongoDB | Zod |
|---|---|
additionalItems |
.rest() |
additionalProperties |
.catchall(), .looseObject(), .object(), .record(), .strictObject() |
allOf |
.and(), .intersection() |
anyOf |
.discriminatedUnion(), .nullable(), .nullish(), .or(), .union() |
bsonType |
.meta({ bsonType: "objectId" }) |
dependencies |
|
description |
.meta({ description: "..." }) |
enum |
.enum(), .keyOf(), .literal() |
exclusiveMaximum |
.lt(), .negative() |
exclusiveMinimum |
.gt(), .positive() |
items |
.array() |
maximum |
.lte(), .max(), .nonpositive() |
maxItems |
.length(), .max() |
maxLength |
.length(), .max() |
maxProperties |
|
minimum |
.gte(), .min(), .nonnegative() |
minItems |
.length(), .min(), nonEmpty() |
minLength |
.length(), .min(), nonEmpty() |
minProperties |
|
multipleOf |
.multipleOf() |
not |
.never() |
oneOf |
|
pattern |
.base64(), .base64url(), .cidrv4(), .cidrv6(), .cuid(), .cuid2(), .email(), .emoji(), .endsWith(), .hash(), .hex(), .hostname(), .includes(), .ipv4(), .ipv6(), .iso.duration(), .iso.date(), .iso.datetime(), .iso.time(), .lowercase(), .nanoid(), .regex(), .startsWith(), .templateLiteral(), .ulid(), .uppercase(), .uuid() |
patternProperties |
|
properties |
Implicitly created whenever you define a schema that has other schemas nested in it |
required |
.optional(), .partial(), .required() |
title |
.meta({ title: "..." }) |
type |
Implicitly created whenever you define a schema |
uniqueItems |
To learn more about MongoDB JSON Schema keywords, check out the MongoDB docs.
I wrote a detailed post about why I built this library, the challenges I faced, and the design decisions that shaped it. Read the full article.