import { Validator } from "@seriousme/openapi-schema-validator"; import { describe, expect, test, vi } from "vitest"; import * as z from "zod"; // import * as zCore from "zod/v4/core"; const openAPI30Validator = new Validator(); /** @see https://github.com/colinhacks/zod/issues/5147 */ const validateOpenAPI30Schema = async (zodJSONSchema: Record): Promise => { const res = await openAPI30Validator.validate({ openapi: "3.0.0", info: { title: "SampleApi", description: "Sample backend service", version: "1.0.0", }, components: { schemas: { test: zodJSONSchema } }, paths: {}, }); if (!res.valid) { // `console.error` should make `vitest` trow an unhandled error printing the validation messages in consoles console.error( `OpenAPI schema is not valid against ${openAPI30Validator.version}`, JSON.stringify(res.errors, null, 2) ); } return true; }; describe("toJSONSchema", () => { test("primitive types", () => { expect(z.toJSONSchema(z.string())).toMatchInlineSnapshot(` { "$schema": "https://json-schema.org/draft/2020-12/schema", "type": "string", } `); expect(z.toJSONSchema(z.number())).toMatchInlineSnapshot(` { "$schema": "https://json-schema.org/draft/2020-12/schema", "type": "number", } `); expect(z.toJSONSchema(z.boolean())).toMatchInlineSnapshot(` { "$schema": "https://json-schema.org/draft/2020-12/schema", "type": "boolean", } `); expect(z.toJSONSchema(z.null())).toMatchInlineSnapshot(` { "$schema": "https://json-schema.org/draft/2020-12/schema", "type": "null", } `); expect(z.toJSONSchema(z.undefined(), { unrepresentable: "any" })).toMatchInlineSnapshot(` { "$schema": "https://json-schema.org/draft/2020-12/schema", } `); expect(z.toJSONSchema(z.any())).toMatchInlineSnapshot(` { "$schema": "https://json-schema.org/draft/2020-12/schema", } `); expect(z.toJSONSchema(z.unknown())).toMatchInlineSnapshot(` { "$schema": "https://json-schema.org/draft/2020-12/schema", } `); expect(z.toJSONSchema(z.never())).toMatchInlineSnapshot(` { "$schema": "https://json-schema.org/draft/2020-12/schema", "not": {}, } `); expect(z.toJSONSchema(z.email())).toMatchInlineSnapshot(` { "$schema": "https://json-schema.org/draft/2020-12/schema", "format": "email", "pattern": "^(?:[A-Za-z0-9_'+\\-]+\\.)*[A-Za-z0-9_'+\\-]*[A-Za-z0-9_+-]@(?:[A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$", "type": "string", } `); expect(z.toJSONSchema(z.iso.datetime())).toMatchInlineSnapshot(` { "$schema": "https://json-schema.org/draft/2020-12/schema", "format": "date-time", "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$", "type": "string", } `); expect(z.toJSONSchema(z.iso.date())).toMatchInlineSnapshot(` { "$schema": "https://json-schema.org/draft/2020-12/schema", "format": "date", "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))$", "type": "string", } `); // only shapes keeping both an offset and seconds may advertise `date-time`, composed onto a string as well as constructed expect(z.toJSONSchema(z.iso.datetime()).format).toEqual("date-time"); expect(z.toJSONSchema(z.iso.datetime({ offset: true })).format).toEqual("date-time"); expect(z.toJSONSchema(z.iso.datetime({ precision: 0 })).format).toEqual("date-time"); expect(z.toJSONSchema(z.iso.datetime({ local: true })).format).toEqual(undefined); expect(z.toJSONSchema(z.iso.datetime({ precision: -1 })).format).toEqual(undefined); expect(z.toJSONSchema(z.string().check(z.iso.datetime({ local: true }))).format).toEqual(undefined); expect(z.toJSONSchema(z.string().check(z.iso.datetime())).format).toEqual("date-time"); // the pattern still describes what the schema accepts expect(z.toJSONSchema(z.iso.datetime({ local: true })).pattern).toBeDefined(); expect(z.toJSONSchema(z.iso.time())).toMatchInlineSnapshot(` { "$schema": "https://json-schema.org/draft/2020-12/schema", "pattern": "^(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?$", "type": "string", } `); expect(z.toJSONSchema(z.iso.time({ precision: -1 }))).toMatchInlineSnapshot(` { "$schema": "https://json-schema.org/draft/2020-12/schema", "pattern": "^(?:[01]\\d|2[0-3]):[0-5]\\d$", "type": "string", } `); expect(z.toJSONSchema(z.iso.time({ precision: 0 }))).toMatchInlineSnapshot(` { "$schema": "https://json-schema.org/draft/2020-12/schema", "pattern": "^(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d$", "type": "string", } `); expect(z.toJSONSchema(z.iso.time({ precision: 3 }))).toMatchInlineSnapshot(` { "$schema": "https://json-schema.org/draft/2020-12/schema", "pattern": "^(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d\\.\\d{3}$", "type": "string", } `); expect(z.toJSONSchema(z.iso.duration())).toMatchInlineSnapshot(` { "$schema": "https://json-schema.org/draft/2020-12/schema", "format": "duration", "pattern": "^P(?:(\\d+W)|(?!.*W)(?=\\d|T\\d)(\\d+Y)?(\\d+M)?(\\d+D)?(T(?=\\d)(\\d+H)?(\\d+M)?(\\d+([.,]\\d+)?S)?)?)$", "type": "string", } `); expect(z.toJSONSchema(z.ipv4())).toMatchInlineSnapshot(` { "$schema": "https://json-schema.org/draft/2020-12/schema", "format": "ipv4", "pattern": "^(?:(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\\.){3}(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])$", "type": "string", } `); expect(z.toJSONSchema(z.ipv6())).toMatchInlineSnapshot(` { "$schema": "https://json-schema.org/draft/2020-12/schema", "format": "ipv6", "pattern": "^(([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:))$", "type": "string", } `); expect(z.toJSONSchema(z.mac())).toMatchInlineSnapshot(` { "$schema": "https://json-schema.org/draft/2020-12/schema", "format": "mac", "pattern": "^(?:[0-9A-F]{2}:){5}[0-9A-F]{2}$|^(?:[0-9a-f]{2}:){5}[0-9a-f]{2}$", "type": "string", } `); expect(z.toJSONSchema(z.mac({ delimiter: ":" }))).toMatchInlineSnapshot(` { "$schema": "https://json-schema.org/draft/2020-12/schema", "format": "mac", "pattern": "^(?:[0-9A-F]{2}:){5}[0-9A-F]{2}$|^(?:[0-9a-f]{2}:){5}[0-9a-f]{2}$", "type": "string", } `); expect(z.toJSONSchema(z.mac({ delimiter: "-" }))).toMatchInlineSnapshot(` { "$schema": "https://json-schema.org/draft/2020-12/schema", "format": "mac", "pattern": "^(?:[0-9A-F]{2}-){5}[0-9A-F]{2}$|^(?:[0-9a-f]{2}-){5}[0-9a-f]{2}$", "type": "string", } `); expect(z.toJSONSchema(z.uuid())).toMatchInlineSnapshot(` { "$schema": "https://json-schema.org/draft/2020-12/schema", "format": "uuid", "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)$", "type": "string", } `); expect(z.toJSONSchema(z.guid())).toMatchInlineSnapshot(` { "$schema": "https://json-schema.org/draft/2020-12/schema", "format": "uuid", "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})$", "type": "string", } `); expect(z.toJSONSchema(z.url())).toMatchInlineSnapshot(` { "$schema": "https://json-schema.org/draft/2020-12/schema", "format": "uri", "type": "string", } `); expect(z.toJSONSchema(z.base64())).toMatchInlineSnapshot(` { "$schema": "https://json-schema.org/draft/2020-12/schema", "contentEncoding": "base64", "format": "base64", "pattern": "^$|^(?:[0-9a-zA-Z+/]{4})*(?:(?:[0-9a-zA-Z+/]{2}==)|(?:[0-9a-zA-Z+/]{3}=))?$", "type": "string", } `); expect(z.toJSONSchema(z.base64url())).toMatchInlineSnapshot(` { "$schema": "https://json-schema.org/draft/2020-12/schema", "contentEncoding": "base64url", "format": "base64url", "pattern": "^(?:[A-Za-z0-9_-]{4})*(?:[A-Za-z0-9_-]{2,3})?$", "type": "string", } `); expect(z.toJSONSchema(z.cuid())).toMatchInlineSnapshot(` { "$schema": "https://json-schema.org/draft/2020-12/schema", "format": "cuid", "pattern": "^[cC][0-9a-z]{6,}$", "type": "string", } `); // expect(z.toJSONSchema(z.regex(/asdf/))).toMatchInlineSnapshot(); expect(z.toJSONSchema(z.emoji())).toMatchInlineSnapshot(` { "$schema": "https://json-schema.org/draft/2020-12/schema", "format": "emoji", "pattern": "^(?=[\\s\\S]*[\\p{Extended_Pictographic}\\p{Regional_Indicator}\\u20E3])[\\p{Extended_Pictographic}\\p{Emoji_Component}]+$", "type": "string", } `); expect(z.toJSONSchema(z.nanoid())).toMatchInlineSnapshot(` { "$schema": "https://json-schema.org/draft/2020-12/schema", "format": "nanoid", "pattern": "^[a-zA-Z0-9_-]{21}$", "type": "string", } `); expect(z.toJSONSchema(z.nanoid({ length: 64 }))).toMatchInlineSnapshot(` { "$schema": "https://json-schema.org/draft/2020-12/schema", "format": "nanoid", "pattern": "^[a-zA-Z0-9_-]{64}$", "type": "string", } `); expect(z.toJSONSchema(z.cuid2())).toMatchInlineSnapshot(` { "$schema": "https://json-schema.org/draft/2020-12/schema", "format": "cuid2", "pattern": "^[0-9a-z]+$", "type": "string", } `); expect(z.toJSONSchema(z.ulid())).toMatchInlineSnapshot(` { "$schema": "https://json-schema.org/draft/2020-12/schema", "format": "ulid", "pattern": "^[0-7][0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{25}$", "type": "string", } `); // expect(z.toJSONSchema(z.cidr())).toMatchInlineSnapshot(); expect(z.toJSONSchema(z.number())).toMatchInlineSnapshot(` { "$schema": "https://json-schema.org/draft/2020-12/schema", "type": "number", } `); expect(z.toJSONSchema(z.int())).toMatchInlineSnapshot(` { "$schema": "https://json-schema.org/draft/2020-12/schema", "maximum": 9007199254740991, "minimum": -9007199254740991, "type": "integer", } `); expect(z.toJSONSchema(z.int32())).toMatchInlineSnapshot(` { "$schema": "https://json-schema.org/draft/2020-12/schema", "maximum": 2147483647, "minimum": -2147483648, "type": "integer", } `); expect(z.toJSONSchema(z.float32())).toMatchInlineSnapshot(` { "$schema": "https://json-schema.org/draft/2020-12/schema", "maximum": 3.4028234663852886e+38, "minimum": -3.4028234663852886e+38, "type": "number", } `); expect(z.toJSONSchema(z.float64())).toMatchInlineSnapshot(` { "$schema": "https://json-schema.org/draft/2020-12/schema", "maximum": 1.7976931348623157e+308, "minimum": -1.7976931348623157e+308, "type": "number", } `); expect(z.toJSONSchema(z.jwt())).toMatchInlineSnapshot(` { "$schema": "https://json-schema.org/draft/2020-12/schema", "format": "jwt", "type": "string", } `); }); test("unsupported schema types", () => { expect(() => z.toJSONSchema(z.bigint())).toThrow("BigInt cannot be represented in JSON Schema"); expect(() => z.toJSONSchema(z.int64())).toThrow("BigInt cannot be represented in JSON Schema"); expect(() => z.toJSONSchema(z.symbol())).toThrow("Symbols cannot be represented in JSON Schema"); expect(() => z.toJSONSchema(z.void())).toThrow("Void cannot be represented in JSON Schema"); expect(() => z.toJSONSchema(z.undefined())).toThrow("Undefined cannot be represented in JSON Schema"); expect(() => z.toJSONSchema(z.date())).toThrow("Date cannot be represented in JSON Schema"); expect(() => z.toJSONSchema(z.map(z.string(), z.number()))).toThrow("Map cannot be represented in JSON Schema"); expect(() => z.toJSONSchema(z.set(z.string()))).toThrow("Set cannot be represented in JSON Schema"); expect(() => z.toJSONSchema(z.custom(() => true))).toThrow("Custom types cannot be represented in JSON Schema"); // Transform const transformSchema = z.string().transform((val) => Number.parseInt(val)); expect(() => z.toJSONSchema(transformSchema)).toThrow("Transforms cannot be represented in JSON Schema"); // Static catch values const staticCatchSchema = z.string().catch(() => "sup"); expect(z.toJSONSchema(staticCatchSchema)).toMatchInlineSnapshot(` { "$schema": "https://json-schema.org/draft/2020-12/schema", "default": "sup", "type": "string", } `); // Dynamic catch values const dynamicCatchSchema = z.string().catch((ctx) => `${ctx.issues.length}`); expect(() => z.toJSONSchema(dynamicCatchSchema)).toThrow("Dynamic catch values are not supported in JSON Schema"); expect(z.toJSONSchema(dynamicCatchSchema, { unrepresentable: "any" })).toMatchInlineSnapshot(` { "$schema": "https://json-schema.org/draft/2020-12/schema", "type": "string", } `); }); test("string formats", () => { expect(z.toJSONSchema(z.string().email())).toMatchInlineSnapshot(` { "$schema": "https://json-schema.org/draft/2020-12/schema", "format": "email", "pattern": "^(?:[A-Za-z0-9_'+\\-]+\\.)*[A-Za-z0-9_'+\\-]*[A-Za-z0-9_+-]@(?:[A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$", "type": "string", } `); expect(z.toJSONSchema(z.string().uuid())).toMatchInlineSnapshot(` { "$schema": "https://json-schema.org/draft/2020-12/schema", "format": "uuid", "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)$", "type": "string", } `); expect(z.toJSONSchema(z.iso.datetime())).toMatchInlineSnapshot(` { "$schema": "https://json-schema.org/draft/2020-12/schema", "format": "date-time", "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$", "type": "string", } `); expect(z.toJSONSchema(z.iso.date())).toMatchInlineSnapshot(` { "$schema": "https://json-schema.org/draft/2020-12/schema", "format": "date", "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))$", "type": "string", } `); expect(z.toJSONSchema(z.iso.time())).toMatchInlineSnapshot(` { "$schema": "https://json-schema.org/draft/2020-12/schema", "pattern": "^(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?$", "type": "string", } `); expect(z.toJSONSchema(z.iso.duration())).toMatchInlineSnapshot(` { "$schema": "https://json-schema.org/draft/2020-12/schema", "format": "duration", "pattern": "^P(?:(\\d+W)|(?!.*W)(?=\\d|T\\d)(\\d+Y)?(\\d+M)?(\\d+D)?(T(?=\\d)(\\d+H)?(\\d+M)?(\\d+([.,]\\d+)?S)?)?)$", "type": "string", } `); // expect(z.toJSONSchema(z.string().ip())).toMatchInlineSnapshot(` // { // "pattern": /\\(\\^\\(\\?:\\(\\?:25\\[0-5\\]\\|2\\[0-4\\]\\[0-9\\]\\|1\\[0-9\\]\\[0-9\\]\\|\\[1-9\\]\\[0-9\\]\\|\\[0-9\\]\\)\\\\\\.\\)\\{3\\}\\(\\?:25\\[0-5\\]\\|2\\[0-4\\]\\[0-9\\]\\|1\\[0-9\\]\\[0-9\\]\\|\\[1-9\\]\\[0-9\\]\\|\\[0-9\\]\\)\\$\\)\\|\\(\\^\\(\\(\\[a-fA-F0-9\\]\\{1,4\\}:\\)\\{7\\}\\|::\\(\\[a-fA-F0-9\\]\\{1,4\\}:\\)\\{0,6\\}\\|\\(\\[a-fA-F0-9\\]\\{1,4\\}:\\)\\{1\\}:\\(\\[a-fA-F0-9\\]\\{1,4\\}:\\)\\{0,5\\}\\|\\(\\[a-fA-F0-9\\]\\{1,4\\}:\\)\\{2\\}:\\(\\[a-fA-F0-9\\]\\{1,4\\}:\\)\\{0,4\\}\\|\\(\\[a-fA-F0-9\\]\\{1,4\\}:\\)\\{3\\}:\\(\\[a-fA-F0-9\\]\\{1,4\\}:\\)\\{0,3\\}\\|\\(\\[a-fA-F0-9\\]\\{1,4\\}:\\)\\{4\\}:\\(\\[a-fA-F0-9\\]\\{1,4\\}:\\)\\{0,2\\}\\|\\(\\[a-fA-F0-9\\]\\{1,4\\}:\\)\\{5\\}:\\(\\[a-fA-F0-9\\]\\{1,4\\}:\\)\\{0,1\\}\\)\\(\\[a-fA-F0-9\\]\\{1,4\\}\\|\\(\\(\\(25\\[0-5\\]\\)\\|\\(2\\[0-4\\]\\[0-9\\]\\)\\|\\(1\\[0-9\\]\\{2\\}\\)\\|\\(\\[0-9\\]\\{1,2\\}\\)\\)\\\\\\.\\)\\{3\\}\\(\\(25\\[0-5\\]\\)\\|\\(2\\[0-4\\]\\[0-9\\]\\)\\|\\(1\\[0-9\\]\\{2\\}\\)\\|\\(\\[0-9\\]\\{1,2\\}\\)\\)\\)\\$\\)/, // "type": "string", // } // `); expect(z.toJSONSchema(z.ipv4())).toMatchInlineSnapshot(` { "$schema": "https://json-schema.org/draft/2020-12/schema", "format": "ipv4", "pattern": "^(?:(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\\.){3}(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])$", "type": "string", } `); expect(z.toJSONSchema(z.ipv6())).toMatchInlineSnapshot(` { "$schema": "https://json-schema.org/draft/2020-12/schema", "format": "ipv6", "pattern": "^(([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:))$", "type": "string", } `); expect(z.toJSONSchema(z.mac())).toMatchInlineSnapshot(` { "$schema": "https://json-schema.org/draft/2020-12/schema", "format": "mac", "pattern": "^(?:[0-9A-F]{2}:){5}[0-9A-F]{2}$|^(?:[0-9a-f]{2}:){5}[0-9a-f]{2}$", "type": "string", } `); expect(z.toJSONSchema(z.mac({ delimiter: ":" }))).toMatchInlineSnapshot(` { "$schema": "https://json-schema.org/draft/2020-12/schema", "format": "mac", "pattern": "^(?:[0-9A-F]{2}:){5}[0-9A-F]{2}$|^(?:[0-9a-f]{2}:){5}[0-9a-f]{2}$", "type": "string", } `); expect(z.toJSONSchema(z.mac({ delimiter: "-" }))).toMatchInlineSnapshot(` { "$schema": "https://json-schema.org/draft/2020-12/schema", "format": "mac", "pattern": "^(?:[0-9A-F]{2}-){5}[0-9A-F]{2}$|^(?:[0-9a-f]{2}-){5}[0-9a-f]{2}$", "type": "string", } `); expect(z.toJSONSchema(z.base64())).toMatchInlineSnapshot(` { "$schema": "https://json-schema.org/draft/2020-12/schema", "contentEncoding": "base64", "format": "base64", "pattern": "^$|^(?:[0-9a-zA-Z+/]{4})*(?:(?:[0-9a-zA-Z+/]{2}==)|(?:[0-9a-zA-Z+/]{3}=))?$", "type": "string", } `); expect(z.toJSONSchema(z.url())).toMatchInlineSnapshot(` { "$schema": "https://json-schema.org/draft/2020-12/schema", "format": "uri", "type": "string", } `); expect(z.toJSONSchema(z.guid())).toMatchInlineSnapshot(` { "$schema": "https://json-schema.org/draft/2020-12/schema", "format": "uuid", "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})$", "type": "string", } `); expect(z.toJSONSchema(z.string().regex(/asdf/))).toMatchInlineSnapshot(` { "$schema": "https://json-schema.org/draft/2020-12/schema", "pattern": "asdf", "type": "string", } `); }); test("base64url pattern agrees with parse", () => { const schema = z.base64url(); const pattern = new RegExp(z.toJSONSchema(schema).pattern!); // lengths 1 (mod 4) carry no whole byte; the emitted pattern must reject them like parse does for (const s of ["", "A", "AA", "AAA", "AAAA", "AAAAA"]) { expect(pattern.test(s)).toBe(schema.safeParse(s).success); } }); test("base64 pattern agrees with parse", () => { const schema = z.base64(); const pattern = new RegExp(z.toJSONSchema(schema).pattern!); // the runtime def.pattern is lax; the emitted pattern must still carry the block structure parse enforces for (const s of ["", "A", "AA", "AAA", "AAAA", "AAAAA", "AA==", "AAA=", "A===", "=", "AAAA=="]) { expect(pattern.test(s)).toBe(schema.safeParse(s).success); } }); test("looseRecord with a format key emits the exact pattern", () => { // recordProcessor reads bag.patterns directly; it must apply the same lax-to-exact swap as stringProcessor const json = z.toJSONSchema(z.looseRecord(z.base64url(), z.number())); expect(Object.keys(json.patternProperties!)).toEqual([z.regexes.base64url.source]); }); test("string patterns", () => { expect( z.toJSONSchema( z .string() .startsWith("hello") .includes("cruel") .includes("dark", { position: 10 }) .endsWith("world") .regex(/stuff/) ) ).toMatchInlineSnapshot(` { "$schema": "https://json-schema.org/draft/2020-12/schema", "allOf": [ { "pattern": "^hello.*", }, { "pattern": "cruel", }, { "pattern": "^.{10,}dark", }, { "pattern": ".*world$", }, { "pattern": "stuff", }, ], "type": "string", } `); expect( z.toJSONSchema( z .string() .startsWith("hello") .includes("cruel") .includes("dark", { position: 10 }) .endsWith("world") .regex(/stuff/), { target: "draft-7", } ) ).toMatchInlineSnapshot(` { "$schema": "http://json-schema.org/draft-07/schema#", "allOf": [ { "pattern": "^hello.*", "type": "string", }, { "pattern": "cruel", "type": "string", }, { "pattern": "^.{10,}dark", "type": "string", }, { "pattern": ".*world$", "type": "string", }, { "pattern": "stuff", "type": "string", }, ], "type": "string", } `); }); test("includes with position emits a pattern matching runtime semantics", () => { // String.prototype.includes(sub, position) matches `sub` at `position` OR // LATER, so the JSON Schema pattern must allow >= position leading chars. const schema = z.string().includes("foo", { position: 2 }); const json = z.toJSONSchema(schema) as { pattern?: string }; expect(json.pattern).toBe("^.{2,}foo"); const re = new RegExp(json.pattern!); for (const input of ["xxfoo", "xxxfoo", "xfoo", "ab"]) { expect(re.test(input)).toBe(schema.safeParse(input).success); } }); test("number constraints", () => { expect(z.toJSONSchema(z.number().min(5).max(10))).toMatchInlineSnapshot( ` { "$schema": "https://json-schema.org/draft/2020-12/schema", "maximum": 10, "minimum": 5, "type": "number", } ` ); expect(z.toJSONSchema(z.number().gt(5).gt(10))).toMatchInlineSnapshot(` { "$schema": "https://json-schema.org/draft/2020-12/schema", "exclusiveMinimum": 10, "type": "number", } `); expect(z.toJSONSchema(z.number().gt(5).gte(10))).toMatchInlineSnapshot(` { "$schema": "https://json-schema.org/draft/2020-12/schema", "minimum": 10, "type": "number", } `); expect(z.toJSONSchema(z.number().lt(5).lt(3))).toMatchInlineSnapshot(` { "$schema": "https://json-schema.org/draft/2020-12/schema", "exclusiveMaximum": 3, "type": "number", } `); expect(z.toJSONSchema(z.number().lt(5).lt(3).lte(2))).toMatchInlineSnapshot(` { "$schema": "https://json-schema.org/draft/2020-12/schema", "maximum": 2, "type": "number", } `); expect(z.toJSONSchema(z.number().lt(5).lte(3))).toMatchInlineSnapshot(` { "$schema": "https://json-schema.org/draft/2020-12/schema", "maximum": 3, "type": "number", } `); expect(z.toJSONSchema(z.number().gt(5).lt(10))).toMatchInlineSnapshot(` { "$schema": "https://json-schema.org/draft/2020-12/schema", "exclusiveMaximum": 10, "exclusiveMinimum": 5, "type": "number", } `); expect(z.toJSONSchema(z.number().gte(5).lte(10))).toMatchInlineSnapshot(` { "$schema": "https://json-schema.org/draft/2020-12/schema", "maximum": 10, "minimum": 5, "type": "number", } `); expect(z.toJSONSchema(z.number().positive())).toMatchInlineSnapshot(` { "$schema": "https://json-schema.org/draft/2020-12/schema", "exclusiveMinimum": 0, "type": "number", } `); expect(z.toJSONSchema(z.number().negative())).toMatchInlineSnapshot(` { "$schema": "https://json-schema.org/draft/2020-12/schema", "exclusiveMaximum": 0, "type": "number", } `); expect(z.toJSONSchema(z.number().nonpositive())).toMatchInlineSnapshot(` { "$schema": "https://json-schema.org/draft/2020-12/schema", "maximum": 0, "type": "number", } `); expect(z.toJSONSchema(z.number().nonnegative())).toMatchInlineSnapshot(` { "$schema": "https://json-schema.org/draft/2020-12/schema", "minimum": 0, "type": "number", } `); }); test("number constraints draft-4", () => { expect(z.toJSONSchema(z.number().gt(5).lt(10), { target: "draft-4" })).toMatchInlineSnapshot(` { "$schema": "http://json-schema.org/draft-04/schema#", "exclusiveMaximum": true, "exclusiveMinimum": true, "maximum": 10, "minimum": 5, "type": "number", } `); }); test("number constraints intersection draft-04", () => { // When both minimum (from .int()) and exclusiveMinimum (from .positive()) exist, the more restrictive constraint should be used expect(z.toJSONSchema(z.number().int().positive().lte(65535), { target: "draft-04" })).toMatchInlineSnapshot(` { "$schema": "http://json-schema.org/draft-04/schema#", "exclusiveMinimum": true, "maximum": 65535, "minimum": 0, "type": "integer", } `); // Same for openapi-3.0 expect(z.toJSONSchema(z.number().int().positive().lte(65535), { target: "openapi-3.0" })).toMatchInlineSnapshot(` { "exclusiveMinimum": true, "maximum": 65535, "minimum": 0, "type": "integer", } `); // When inclusive minimum is more restrictive than exclusive minimum expect(z.toJSONSchema(z.number().gt(3).gte(10), { target: "draft-04" })).toMatchInlineSnapshot(` { "$schema": "http://json-schema.org/draft-04/schema#", "minimum": 10, "type": "number", } `); // Same logic for maximum constraints expect(z.toJSONSchema(z.number().int().negative(), { target: "draft-04" })).toMatchInlineSnapshot(` { "$schema": "http://json-schema.org/draft-04/schema#", "exclusiveMaximum": true, "maximum": 0, "minimum": -9007199254740991, "type": "integer", } `); }); test("number constraints order independence with int()", () => { // .int() applied last should not overwrite the tighter bounds .min()/.max() already stored expect(z.toJSONSchema(z.number().int().min(0).max(23))).toMatchInlineSnapshot(` { "$schema": "https://json-schema.org/draft/2020-12/schema", "maximum": 23, "minimum": 0, "type": "integer", } `); expect(z.toJSONSchema(z.number().min(0).max(23).int())).toMatchInlineSnapshot(` { "$schema": "https://json-schema.org/draft/2020-12/schema", "maximum": 23, "minimum": 0, "type": "integer", } `); }); test("chained multipleOf divisors emit the whole conjunction", () => { // runtime rejects 4 (not a multiple of 3), so the emitted schema must too expect(z.toJSONSchema(z.number().multipleOf(2).multipleOf(3))).toMatchInlineSnapshot(` { "$schema": "https://json-schema.org/draft/2020-12/schema", "allOf": [ { "multipleOf": 3, }, ], "multipleOf": 2, "type": "number", } `); }); test("length() does not overwrite a tighter earlier bound", () => { // min(8) + length(5) matches nothing at runtime; the emitted bounds stay honest about that expect(z.toJSONSchema(z.string().min(8).length(5))).toMatchInlineSnapshot(` { "$schema": "https://json-schema.org/draft/2020-12/schema", "maxLength": 5, "minLength": 8, "type": "string", } `); }); test("an integer format keeps the emitted type integer whatever format lands last", () => { // runtime rejects 1.5 in both orders, so both must emit type integer const after = z.number().int().check(z.float32()); expect(after.safeParse(1.5).success).toBe(false); expect(z.toJSONSchema(after).type).toBe("integer"); expect(z.toJSONSchema(z.number().check(z.float32()).int()).type).toBe("integer"); }); test("disjoint mime checks emit a false schema, not an unconstrained file", () => { const file = z.file().mime("image/png").mime("text/plain"); expect(z.toJSONSchema(file)).toMatchInlineSnapshot(` { "$schema": "https://json-schema.org/draft/2020-12/schema", "contentEncoding": "binary", "format": "binary", "not": {}, "type": "string", } `); }); test("a custom mime narrowing via onattach intersects with built-in mime checks", () => { const check = { _zod: { def: { check: "custom_mime" }, onattach: [ (inst: any) => { inst._zod.bag.mime = ["text/plain"]; }, ], check: () => {}, }, }; const file = z .file() .mime(["image/png", "text/plain"]) .check(check as any); expect(z.toJSONSchema(file).contentMediaType).toBe("text/plain"); }); test("a custom check contributing via onattach still lands", () => { // third-party checks have no converter handler; their bag writes are merged in as a fallback const check = { _zod: { def: { check: "custom_range" }, onattach: [ (inst: any) => { const bag = inst._zod.bag; if (bag.minimum === undefined || 5 > bag.minimum) bag.minimum = 5; }, ], check: () => {}, }, }; expect(z.toJSONSchema(z.number().check(check as any))).toMatchInlineSnapshot(` { "$schema": "https://json-schema.org/draft/2020-12/schema", "minimum": 5, "type": "number", } `); }); test("target normalization draft-04 and draft-07", () => { // Test that both old (draft-4, draft-7) and new (draft-04, draft-07) target formats work // Test draft-04 / draft-4 expect(z.toJSONSchema(z.number().gt(5), { target: "draft-04" })).toMatchInlineSnapshot(` { "$schema": "http://json-schema.org/draft-04/schema#", "exclusiveMinimum": true, "minimum": 5, "type": "number", } `); expect(z.toJSONSchema(z.number().gt(5), { target: "draft-4" })).toMatchInlineSnapshot(` { "$schema": "http://json-schema.org/draft-04/schema#", "exclusiveMinimum": true, "minimum": 5, "type": "number", } `); // Test draft-07 / draft-7 expect(z.toJSONSchema(z.number().gt(5), { target: "draft-07" })).toMatchInlineSnapshot(` { "$schema": "http://json-schema.org/draft-07/schema#", "exclusiveMinimum": 5, "type": "number", } `); expect(z.toJSONSchema(z.number().gt(5), { target: "draft-7" })).toMatchInlineSnapshot(` { "$schema": "http://json-schema.org/draft-07/schema#", "exclusiveMinimum": 5, "type": "number", } `); }); test("nullable openapi-3.0", () => { const schema = z.string().nullable(); const jsonSchema = z.toJSONSchema(schema, { target: "openapi-3.0" }); validateOpenAPI30Schema(jsonSchema); expect(jsonSchema).toMatchInlineSnapshot(` { "nullable": true, "type": "string", } `); }); test("union with null openapi-3.0", () => { const schema = z.union([z.string(), z.null()]); const jsonSchema = z.toJSONSchema(schema, { target: "openapi-3.0" }); validateOpenAPI30Schema(jsonSchema); expect(jsonSchema).toMatchInlineSnapshot(` { "anyOf": [ { "type": "string", }, { "enum": [ null, ], "nullable": true, "type": "string", }, ], } `); }); test("number with exclusive min-max openapi-3.0", () => { const schema = z.number().lt(100).gt(1); const jsonSchema = z.toJSONSchema(schema, { target: "openapi-3.0" }); validateOpenAPI30Schema(jsonSchema); expect(jsonSchema).toMatchInlineSnapshot(` { "exclusiveMaximum": true, "exclusiveMinimum": true, "maximum": 100, "minimum": 1, "type": "number", } `); }); test("arrays", () => { expect(z.toJSONSchema(z.array(z.string()))).toMatchInlineSnapshot(` { "$schema": "https://json-schema.org/draft/2020-12/schema", "items": { "type": "string", }, "type": "array", } `); }); test("unions", () => { const schema = z.union([z.string(), z.number()]); expect(z.toJSONSchema(schema)).toMatchInlineSnapshot(` { "$schema": "https://json-schema.org/draft/2020-12/schema", "type": [ "string", "number", ], } `); }); test("nullable compacts to a type array", () => { expect(z.toJSONSchema(z.string().nullable())).toMatchInlineSnapshot(` { "$schema": "https://json-schema.org/draft/2020-12/schema", "type": [ "string", "null", ], } `); // a nested union folds into the outer type array rather than leaving a mixed shape expect(z.toJSONSchema(z.union([z.string(), z.number()]).nullable())).toMatchInlineSnapshot(` { "$schema": "https://json-schema.org/draft/2020-12/schema", "type": [ "string", "number", "null", ], } `); // the null branch is bare but the object branch is not, so this stays an anyOf expect(z.toJSONSchema(z.object({ a: z.string() }).nullable())).toMatchInlineSnapshot(` { "$schema": "https://json-schema.org/draft/2020-12/schema", "anyOf": [ { "additionalProperties": false, "properties": { "a": { "type": "string", }, }, "required": [ "a", ], "type": "object", }, { "type": "null", }, ], } `); }); test("duplicate branches dedupe, single branch stays a bare type", () => { // a `type` array must have unique members, so two branches that erase to the same bare type collapse const refined = z.union([z.string().refine((s) => s.length > 2), z.string().refine((s) => s.length < 9)]); expect(z.toJSONSchema(refined)).toMatchInlineSnapshot(` { "$schema": "https://json-schema.org/draft/2020-12/schema", "type": "string", } `); expect(z.toJSONSchema(z.union([z.string()]))).toMatchInlineSnapshot(` { "$schema": "https://json-schema.org/draft/2020-12/schema", "type": "string", } `); }); test("compaction runs after overrides and ref extraction", () => { // an override decorates the string branch, so it is no longer a bare type and must survive in anyOf const decorated = z.toJSONSchema(z.union([z.string(), z.number()]), { override(ctx) { if (ctx.zodSchema._zod.def.type === "string") ctx.jsonSchema.whatever = "sup"; }, }); expect(decorated).toMatchInlineSnapshot(` { "$schema": "https://json-schema.org/draft/2020-12/schema", "anyOf": [ { "type": "string", "whatever": "sup", }, { "type": "number", }, ], } `); // a branch extracted into $defs is a $ref, not a bare type, so `reused` is not silently bypassed const shared = z.string(); expect( z.toJSONSchema(z.object({ a: shared, b: z.union([shared, z.null()]) }), { reused: "ref" }) ).toMatchInlineSnapshot(` { "$defs": { "__schema0": { "type": "string", }, }, "$schema": "https://json-schema.org/draft/2020-12/schema", "additionalProperties": false, "properties": { "a": { "$ref": "#/$defs/__schema0", }, "b": { "anyOf": [ { "$ref": "#/$defs/__schema0", }, { "type": "null", }, ], }, }, "required": [ "a", "b", ], "type": "object", } `); }); test("openapi-3.0 keeps a single-string type", () => { // OpenAPI 3.0 has no type arrays; nullability is spelled with `nullable` expect(z.toJSONSchema(z.string().nullable(), { target: "openapi-3.0" })).toMatchInlineSnapshot(` { "nullable": true, "type": "string", } `); }); test("union with constrained branch is not compacted", () => { const schema = z.union([z.string().min(1), z.number()]); expect(z.toJSONSchema(schema)).toMatchInlineSnapshot(` { "$schema": "https://json-schema.org/draft/2020-12/schema", "anyOf": [ { "minLength": 1, "type": "string", }, { "type": "number", }, ], } `); }); test("union of literals is not compacted", () => { const schema = z.union([z.literal("a"), z.literal("b")]); expect(z.toJSONSchema(schema)).toMatchInlineSnapshot(` { "$schema": "https://json-schema.org/draft/2020-12/schema", "anyOf": [ { "const": "a", "type": "string", }, { "const": "b", "type": "string", }, ], } `); }); test("discriminated unions", () => { const schema = z.discriminatedUnion("type", [ z.object({ type: z.literal("success"), data: z.string() }), z.object({ type: z.literal("error"), message: z.string() }), ]); expect(z.toJSONSchema(schema)).toMatchInlineSnapshot(` { "$schema": "https://json-schema.org/draft/2020-12/schema", "oneOf": [ { "additionalProperties": false, "properties": { "data": { "type": "string", }, "type": { "const": "success", "type": "string", }, }, "required": [ "type", "data", ], "type": "object", }, { "additionalProperties": false, "properties": { "message": { "type": "string", }, "type": { "const": "error", "type": "string", }, }, "required": [ "type", "message", ], "type": "object", }, ], } `); }); test("intersections", () => { const schema = z.intersection(z.object({ name: z.string() }), z.object({ age: z.number() })); expect(z.toJSONSchema(schema)).toMatchInlineSnapshot(` { "$schema": "https://json-schema.org/draft/2020-12/schema", "additionalProperties": false, "properties": { "age": { "type": "number", }, "name": { "type": "string", }, }, "required": [ "name", "age", ], "type": "object", } `); }); test("record", () => { const schema = z.record(z.string(), z.boolean()); expect(z.toJSONSchema(schema)).toMatchInlineSnapshot(` { "$schema": "https://json-schema.org/draft/2020-12/schema", "additionalProperties": { "type": "boolean", }, "propertyNames": { "type": "string", }, "type": "object", } `); }); test("record openapi-3.0", () => { const schema = z.record(z.string(), z.boolean()); const jsonSchema = z.toJSONSchema(schema, { target: "openapi-3.0" }); validateOpenAPI30Schema(jsonSchema); expect(jsonSchema).toMatchInlineSnapshot(` { "additionalProperties": { "type": "boolean", }, "type": "object", } `); }); test("record with enum keys adds required", () => { const schema = z.record(z.enum(["key1", "key2"]), z.number()); expect(z.toJSONSchema(schema)).toMatchInlineSnapshot(` { "$schema": "https://json-schema.org/draft/2020-12/schema", "additionalProperties": { "type": "number", }, "propertyNames": { "enum": [ "key1", "key2", ], "type": "string", }, "required": [ "key1", "key2", ], "type": "object", } `); }); test("record with enum keys drops required for an optional-in value under io: input", () => { const schema = z.record(z.enum(["key1", "key2"]), z.number().default(0)); expect(z.toJSONSchema(schema, { io: "input" }).required).toBeUndefined(); expect(z.toJSONSchema(schema).required).toEqual(["key1", "key2"]); expect(z.toJSONSchema(z.record(z.enum(["key1", "key2"]), z.number()), { io: "input" }).required).toEqual([ "key1", "key2", ]); // A caught value keeps required, matching the input type and z.object(). expect(z.toJSONSchema(z.record(z.enum(["key1", "key2"]), z.number().catch(0)), { io: "input" }).required).toEqual([ "key1", "key2", ]); }); test("record stringifies numeric enum keys for propertyNames and required", () => { enum NumberEnum { Zero = 0, One = 1, } const schema = z.record(z.enum(NumberEnum), z.string()); expect(z.toJSONSchema(schema)).toMatchInlineSnapshot(` { "$schema": "https://json-schema.org/draft/2020-12/schema", "additionalProperties": { "type": "string", }, "propertyNames": { "enum": [ "0", "1", ], "type": "string", }, "required": [ "0", "1", ], "type": "object", } `); }); test("record with a numeric key emits propertyNames over the numeric-string form", () => { expect(z.toJSONSchema(z.record(z.number(), z.boolean())).propertyNames).toEqual({ type: "string", pattern: "^-?\\d+(?:\\.\\d+)?$", }); // range checks can't apply to a key, so only the integer shape survives expect(z.toJSONSchema(z.record(z.int32(), z.boolean())).propertyNames).toEqual({ type: "string", pattern: "^-?\\d+$", }); expect(z.toJSONSchema(z.record(z.literal([1, 2]), z.boolean()))).toMatchObject({ propertyNames: { type: "string", enum: ["1", "2"] }, required: ["1", "2"], }); expect(z.toJSONSchema(z.record(z.literal(1), z.boolean())).propertyNames).toEqual({ type: "string", const: "1", }); }); test("record key rewrite reaches through wrappers and union branches", () => { const numericString = { type: "string", pattern: "^-?\\d+(?:\\.\\d+)?$" }; // a wrapper only carries its inner type once the refs are flattened, so this is decided after the record itself is emitted expect( z.toJSONSchema( z.record( z.lazy(() => z.number()), z.boolean() ) ).propertyNames ).toEqual(numericString); expect(z.toJSONSchema(z.record(z.number().pipe(z.number()), z.boolean())).propertyNames).toEqual(numericString); expect(z.toJSONSchema(z.record(z.number().readonly(), z.boolean())).propertyNames).toMatchObject(numericString); expect(z.toJSONSchema(z.record(z.union([z.literal("Tuna"), z.literal(21)]), z.string()))).toMatchObject({ propertyNames: { anyOf: [ { type: "string", const: "Tuna" }, { type: "string", const: "21" }, ], }, required: ["Tuna", "21"], }); }); test("record with a numeric key inlines an extracted key, and leaves a string one referenced", () => { expect(z.toJSONSchema(z.record(z.number().meta({ id: "Num" }), z.boolean())).propertyNames).toEqual({ type: "string", pattern: "^-?\\d+(?:\\.\\d+)?$", }); expect(z.toJSONSchema(z.record(z.string().meta({ id: "Str" }), z.boolean())).propertyNames).toEqual({ $ref: "#/$defs/Str", }); // the value position still wants the number form, so the two cannot share one def const key = z.number().meta({ id: "Shared" }); expect(z.toJSONSchema(z.object({ a: key, b: z.record(key, z.string()) }))).toMatchObject({ properties: { a: { $ref: "#/$defs/Shared" }, b: { propertyNames: { type: "string" } } }, $defs: { Shared: { type: "number" } }, }); }); test("record key rewrite reaches a wrapped record", () => { // the flatten copies a record's properties onto its wrapper by reference, so the rewrite has to find every copy expect(z.toJSONSchema(z.record(z.number(), z.boolean()).optional()).propertyNames).toEqual({ type: "string", pattern: "^-?\\d+(?:\\.\\d+)?$", }); expect(z.toJSONSchema(z.object({ a: z.record(z.literal([1, 2]), z.boolean()).optional() }))).toMatchObject({ properties: { a: { propertyNames: { type: "string", enum: ["1", "2"] }, required: ["1", "2"] } }, }); }); test("record with a recursive key converts without looping", () => { const numeric: any = z.lazy(() => z.union([z.number(), numeric])); expect(z.toJSONSchema(z.record(numeric, z.boolean())).propertyNames).toMatchObject({ anyOf: [{ type: "string", pattern: "^-?\\d+(?:\\.\\d+)?$" }, { $ref: "#/$defs/__schema0" }], }); // a key with nothing to re-express keeps the reference it had const stringy: any = z.lazy(() => z.union([z.string(), stringy])); expect(z.toJSONSchema(z.record(stringy, z.boolean())).propertyNames).toEqual({ $ref: "#/$defs/__schema0" }); expect( z.toJSONSchema(z.record(z.union([z.literal("a"), z.literal("b")]).meta({ id: "Keys" }), z.boolean())) .propertyNames ).toEqual({ $ref: "#/$defs/Keys" }); }); test("record with a heterogeneous key stringifies only its numeric members", () => { // a mixed key carries no `type`, so the numeric members are caught by value rather than by type expect(z.toJSONSchema(z.record(z.literal(["a", 1]), z.boolean()))).toMatchObject({ propertyNames: { enum: ["a", "1"] }, required: ["a", "1"], }); // a member no key can spell is left as it was, since the parser only ever retries a key as a number expect(z.toJSONSchema(z.record(z.literal(["a", true]) as any, z.boolean())).propertyNames).toEqual({ enum: ["a", true], }); }); test("record stringifies required for every target", () => { const schema = z.record(z.literal([1, 2]), z.boolean()); for (const target of ["draft-2020-12", "draft-7", "draft-4", "openapi-3.0"] as const) { expect(z.toJSONSchema(schema, { target }).required).toEqual(["1", "2"]); } }); test("strict record with regex key uses propertyNames", () => { const schema = z.record(z.string().regex(/^label:[a-z]{2}$/), z.string()); expect(z.toJSONSchema(schema)).toMatchInlineSnapshot(` { "$schema": "https://json-schema.org/draft/2020-12/schema", "additionalProperties": { "type": "string", }, "propertyNames": { "pattern": "^label:[a-z]{2}$", "type": "string", }, "type": "object", } `); }); test("looseRecord with regex key uses patternProperties", () => { const schema = z.looseRecord(z.string().regex(/^label:[a-z]{2}$/), z.string()); expect(z.toJSONSchema(schema)).toMatchInlineSnapshot(` { "$schema": "https://json-schema.org/draft/2020-12/schema", "patternProperties": { "^label:[a-z]{2}$": { "type": "string", }, }, "type": "object", } `); }); test("looseRecord with multiple regex patterns uses patternProperties", () => { const schema = z.looseRecord( z .string() .regex(/^prefix_/) .regex(/_suffix$/), z.number() ); expect(z.toJSONSchema(schema)).toMatchInlineSnapshot(` { "$schema": "https://json-schema.org/draft/2020-12/schema", "patternProperties": { "^prefix_": { "type": "number", }, "_suffix$": { "type": "number", }, }, "type": "object", } `); }); test("looseRecord without regex key uses propertyNames", () => { // looseRecord with plain string key should still use propertyNames const schema = z.looseRecord(z.string(), z.boolean()); expect(z.toJSONSchema(schema)).toMatchInlineSnapshot(` { "$schema": "https://json-schema.org/draft/2020-12/schema", "additionalProperties": { "type": "boolean", }, "propertyNames": { "type": "string", }, "type": "object", } `); }); test("intersection of object with looseRecord uses patternProperties", () => { const zLabeled = z.object({ label: z.string() }); const zLocalizedLabeled = z.looseRecord(z.string().regex(/^label:[a-z]{2}$/), z.string()); const schema = zLabeled.and(zLocalizedLabeled); expect(z.toJSONSchema(schema)).toMatchInlineSnapshot(` { "$schema": "https://json-schema.org/draft/2020-12/schema", "allOf": [ { "additionalProperties": false, "properties": { "label": { "type": "string", }, }, "required": [ "label", ], "type": "object", }, { "patternProperties": { "^label:[a-z]{2}$": { "type": "string", }, }, "type": "object", }, ], } `); }); test("tuple", () => { const schema = z.tuple([z.string(), z.number()]); expect(z.toJSONSchema(schema)).toMatchInlineSnapshot(` { "$schema": "https://json-schema.org/draft/2020-12/schema", "items": false, "maxItems": 2, "minItems": 2, "prefixItems": [ { "type": "string", }, { "type": "number", }, ], "type": "array", } `); }); test("tuple with rest", () => { const schema = z.tuple([z.string(), z.number()]).rest(z.boolean()); expect(z.toJSONSchema(schema)).toMatchInlineSnapshot(` { "$schema": "https://json-schema.org/draft/2020-12/schema", "items": { "type": "boolean", }, "minItems": 2, "prefixItems": [ { "type": "string", }, { "type": "number", }, ], "type": "array", } `); }); test("tuple openapi-3.0", () => { const schema = z.tuple([z.string(), z.number()]); const jsonSchema = z.toJSONSchema(schema, { target: "openapi-3.0" }); validateOpenAPI30Schema(jsonSchema); expect(jsonSchema).toMatchInlineSnapshot(` { "items": { "anyOf": [ { "type": "string", }, { "type": "number", }, ], }, "maxItems": 2, "minItems": 2, "type": "array", } `); }); test("tuple with rest openapi-3.0", () => { const schema = z.tuple([z.string(), z.number()]).rest(z.boolean()); const jsonSchema = z.toJSONSchema(schema, { target: "openapi-3.0" }); validateOpenAPI30Schema(jsonSchema); expect(jsonSchema).toMatchInlineSnapshot(` { "items": { "anyOf": [ { "type": "string", }, { "type": "number", }, { "type": "boolean", }, ], }, "minItems": 2, "type": "array", } `); }); test("tuple with null openapi-3.0", () => { const schema = z.tuple([z.string(), z.number(), z.null()]); const jsonSchema = z.toJSONSchema(schema, { target: "openapi-3.0" }); validateOpenAPI30Schema(jsonSchema); expect(jsonSchema).toMatchInlineSnapshot(` { "items": { "anyOf": [ { "type": "string", }, { "type": "number", }, { "enum": [ null, ], "nullable": true, "type": "string", }, ], }, "maxItems": 3, "minItems": 3, "type": "array", } `); }); test("tuple draft-7", () => { const schema = z.tuple([z.string(), z.number()]); expect(z.toJSONSchema(schema, { target: "draft-7", io: "input" })).toMatchInlineSnapshot(` { "$schema": "http://json-schema.org/draft-07/schema#", "additionalItems": false, "items": [ { "type": "string", }, { "type": "number", }, ], "maxItems": 2, "minItems": 2, "type": "array", } `); }); test("tuple with rest draft-7", () => { const schema = z.tuple([z.string(), z.number()]).rest(z.boolean()); expect(z.toJSONSchema(schema, { target: "draft-7", io: "input" })).toMatchInlineSnapshot(` { "$schema": "http://json-schema.org/draft-07/schema#", "additionalItems": { "type": "boolean", }, "items": [ { "type": "string", }, { "type": "number", }, ], "minItems": 2, "type": "array", } `); }); test("tuple with rest draft-7 - issue #5151 regression test", () => { // This test addresses issue #5151: tuple with rest elements and ids in draft-7 had incorrect internal path handling affecting complex scenarios const primarySchema = z.string().meta({ id: "primary" }); const restSchema = z.number().meta({ id: "rest" }); const testSchema = z.tuple([primarySchema], restSchema); // Test both final output structure AND internal path handling const capturedPaths: string[] = []; const result = z.toJSONSchema(testSchema, { target: "draft-7", override: (ctx) => capturedPaths.push(ctx.path.join("/")), }); // Verify correct draft-7 structure with metadata extraction expect(result).toMatchInlineSnapshot(` { "$schema": "http://json-schema.org/draft-07/schema#", "additionalItems": { "$ref": "#/definitions/rest", }, "definitions": { "primary": { "type": "string", }, "rest": { "type": "number", }, }, "items": [ { "$ref": "#/definitions/primary", }, ], "minItems": 1, "type": "array", } `); // Verify internal paths are correct (this was the actual bug) expect(capturedPaths).toContain("items/0"); // prefix items should use "items" path expect(capturedPaths).toContain("additionalItems"); // rest should use "additionalItems" path expect(capturedPaths).not.toContain("prefixItems/0"); // should not use draft-2020-12 paths // Structural validations expect(Array.isArray(result.items)).toBe(true); expect(result.additionalItems).toBeDefined(); }); test("closed tuple length constraints - issue #6193", () => { const schema = z.tuple([z.string(), z.string(), z.object({ lang: z.string() })]); expect(schema.safeParse(["a", "b", { lang: "en" }, "extra"]).success).toBe(false); const draft2020 = z.toJSONSchema(schema, { target: "draft-2020-12" }); expect(draft2020.items).toBe(false); expect(draft2020.minItems).toBe(3); expect(draft2020.maxItems).toBe(3); const draft7 = z.toJSONSchema(schema, { target: "draft-7", io: "input" }); expect(draft7.additionalItems).toBe(false); expect(draft7.minItems).toBe(3); expect(draft7.maxItems).toBe(3); const openapi = z.toJSONSchema(schema, { target: "openapi-3.0" }); validateOpenAPI30Schema(openapi); expect(openapi.minItems).toBe(3); expect(openapi.maxItems).toBe(3); }); test("closed tuple with optional trailing items", () => { const schema = z.tuple([z.string(), z.number().optional()]); const draft2020 = z.toJSONSchema(schema, { target: "draft-2020-12" }); expect(draft2020.items).toBe(false); expect(draft2020.minItems).toBe(1); expect(draft2020.maxItems).toBe(2); const draft7 = z.toJSONSchema(schema, { target: "draft-7", io: "input" }); expect(draft7.additionalItems).toBe(false); expect(draft7.minItems).toBe(1); expect(draft7.maxItems).toBe(2); }); test("empty closed tuple rejects extra elements", () => { const schema = z.tuple([]); const draft2020 = z.toJSONSchema(schema, { target: "draft-2020-12" }); expect(draft2020.items).toBe(false); expect(draft2020.maxItems).toBe(0); const draft7 = z.toJSONSchema(schema, { target: "draft-7", io: "input" }); expect(draft7.additionalItems).toBe(false); expect(draft7.maxItems).toBe(0); const openapi = z.toJSONSchema(schema, { target: "openapi-3.0" }); validateOpenAPI30Schema(openapi); expect(openapi.maxItems).toBe(0); }); test("closed tuple length respects io direction with defaults", () => { const schema = z.tuple([z.string(), z.string().default("x")]); const input = z.toJSONSchema(schema, { target: "draft-2020-12", io: "input" }); expect(input.items).toBe(false); expect(input.minItems).toBe(1); expect(input.maxItems).toBe(2); const output = z.toJSONSchema(schema, { target: "draft-2020-12", io: "output" }); expect(output.items).toBe(false); expect(output.minItems).toBe(2); expect(output.maxItems).toBe(2); }); test("closed tuple length resolves input optionality past transform and catch", () => { const minItems = (schema: z.core.$ZodType, io: "input" | "output") => z.toJSONSchema(schema, { target: "draft-2020-12", io }).minItems; // Both let the parser observe an absent slot, but declare a required input. const pre = z.tuple([z.string(), z.preprocess((v) => v, z.string())]); expect(minItems(pre, "input")).toBe(2); expect(minItems(pre, "output")).toBe(2); const caught = z.tuple([z.string(), z.string().catch("x")]); expect(minItems(caught, "input")).toBe(2); expect(minItems(caught, "output")).toBe(2); // Resolution passes through the wrapper rather than stopping at it. expect(minItems(z.tuple([z.string(), z.string().optional().catch("x")]), "input")).toBe(1); }); test("promise", () => { const schema = z.promise(z.string()); expect(z.toJSONSchema(schema)).toMatchInlineSnapshot(` { "$schema": "https://json-schema.org/draft/2020-12/schema", "type": "string", } `); }); test("lazy", () => { const schema = z.lazy(() => z.string()); expect(z.toJSONSchema(schema)).toMatchInlineSnapshot(` { "$schema": "https://json-schema.org/draft/2020-12/schema", "type": "string", } `); }); // enum test("enum", () => { const a = z.enum(["a", "b", "c"]); expect(z.toJSONSchema(a)).toMatchInlineSnapshot(` { "$schema": "https://json-schema.org/draft/2020-12/schema", "enum": [ "a", "b", "c", ], "type": "string", } `); enum B { A = 0, B = 1, C = 2, } const b = z.enum(B); expect(z.toJSONSchema(b)).toMatchInlineSnapshot(` { "$schema": "https://json-schema.org/draft/2020-12/schema", "enum": [ 0, 1, 2, ], "type": "number", } `); }); // literal test("literal", () => { const a = z.literal("hello"); expect(z.toJSONSchema(a)).toMatchInlineSnapshot(` { "$schema": "https://json-schema.org/draft/2020-12/schema", "const": "hello", "type": "string", } `); const b = z.literal(7); expect(z.toJSONSchema(b)).toMatchInlineSnapshot(` { "$schema": "https://json-schema.org/draft/2020-12/schema", "const": 7, "type": "number", } `); const c = z.literal(["hello", undefined, null, 5, BigInt(1324)]); expect(() => z.toJSONSchema(c)).toThrow(); const d = z.literal(["hello", null, 5]); expect(z.toJSONSchema(d)).toMatchInlineSnapshot(` { "$schema": "https://json-schema.org/draft/2020-12/schema", "enum": [ "hello", null, 5, ], } `); const e = z.literal(["hello", "zod", "v4"]); expect(z.toJSONSchema(e)).toMatchInlineSnapshot(` { "$schema": "https://json-schema.org/draft/2020-12/schema", "enum": [ "hello", "zod", "v4", ], "type": "string", } `); }); test("literal draft-4", () => { const a = z.literal("hello"); expect(z.toJSONSchema(a, { target: "draft-4" })).toMatchInlineSnapshot(` { "$schema": "http://json-schema.org/draft-04/schema#", "enum": [ "hello", ], "type": "string", } `); }); // pipe test("pipe", () => { const schema = z .string() .transform((val) => Number.parseInt(val)) .pipe(z.number()); expect(z.toJSONSchema(schema)).toMatchInlineSnapshot(` { "$schema": "https://json-schema.org/draft/2020-12/schema", "type": "number", } `); }); test("simple objects", () => { const schema = z.object({ name: z.string(), age: z.number(), }); expect(z.toJSONSchema(schema)).toMatchInlineSnapshot( ` { "$schema": "https://json-schema.org/draft/2020-12/schema", "additionalProperties": false, "properties": { "age": { "type": "number", }, "name": { "type": "string", }, }, "required": [ "name", "age", ], "type": "object", } ` ); }); test("additionalproperties in z.object", () => { const a = z.object({ name: z.string(), }); expect(z.toJSONSchema(a)).toMatchInlineSnapshot(` { "$schema": "https://json-schema.org/draft/2020-12/schema", "additionalProperties": false, "properties": { "name": { "type": "string", }, }, "required": [ "name", ], "type": "object", } `); expect(z.toJSONSchema(a, { io: "input" })).toMatchInlineSnapshot(` { "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "name": { "type": "string", }, }, "required": [ "name", ], "type": "object", } `); expect( z.toJSONSchema(a, { io: "input", override(ctx) { const def = ctx.zodSchema._zod.def; if (def.type === "object" && !def.catchall) { (ctx.jsonSchema as z.core.JSONSchema.ObjectSchema).additionalProperties = false; } }, }) ).toMatchInlineSnapshot(` { "$schema": "https://json-schema.org/draft/2020-12/schema", "additionalProperties": false, "properties": { "name": { "type": "string", }, }, "required": [ "name", ], "type": "object", } `); }); test("catchall objects", () => { const a = z.strictObject({ name: z.string(), age: z.number(), }); expect(z.toJSONSchema(a)).toMatchInlineSnapshot(` { "$schema": "https://json-schema.org/draft/2020-12/schema", "additionalProperties": false, "properties": { "age": { "type": "number", }, "name": { "type": "string", }, }, "required": [ "name", "age", ], "type": "object", } `); const b = z .object({ name: z.string(), }) .catchall(z.string()); expect(z.toJSONSchema(b)).toMatchInlineSnapshot(` { "$schema": "https://json-schema.org/draft/2020-12/schema", "additionalProperties": { "type": "string", }, "properties": { "name": { "type": "string", }, }, "required": [ "name", ], "type": "object", } `); const c = z.looseObject({ name: z.string(), }); expect(z.toJSONSchema(c)).toMatchInlineSnapshot(` { "$schema": "https://json-schema.org/draft/2020-12/schema", "additionalProperties": {}, "properties": { "name": { "type": "string", }, }, "required": [ "name", ], "type": "object", } `); }); test("optional fields - object", () => { const schema = z.object({ required: z.string(), optional: z.string().optional(), nonoptional: z.string().optional().nonoptional(), }); const result = z.toJSONSchema(schema); expect(result).toMatchInlineSnapshot(` { "$schema": "https://json-schema.org/draft/2020-12/schema", "additionalProperties": false, "properties": { "nonoptional": { "type": "string", }, "optional": { "type": "string", }, "required": { "type": "string", }, }, "required": [ "required", "nonoptional", ], "type": "object", } `); }); test("recursive object", () => { interface Category { name: string; subcategories: Category[]; } const categorySchema: z.ZodType = z.object({ name: z.string(), subcategories: z.array(z.lazy(() => categorySchema)), }); const result = z.toJSONSchema(categorySchema); expect(result).toMatchInlineSnapshot(` { "$schema": "https://json-schema.org/draft/2020-12/schema", "additionalProperties": false, "properties": { "name": { "type": "string", }, "subcategories": { "items": { "$ref": "#", }, "type": "array", }, }, "required": [ "name", "subcategories", ], "type": "object", } `); }); test("simple interface", () => { const userSchema = z.object({ name: z.string(), age: z.number().optional(), }); const result = z.toJSONSchema(userSchema); expect(result).toMatchInlineSnapshot(` { "$schema": "https://json-schema.org/draft/2020-12/schema", "additionalProperties": false, "properties": { "age": { "type": "number", }, "name": { "type": "string", }, }, "required": [ "name", ], "type": "object", } `); }); test("catchall interface", () => { const a = z.strictObject({ name: z.string(), age: z.number(), }); expect(z.toJSONSchema(a)).toMatchInlineSnapshot(` { "$schema": "https://json-schema.org/draft/2020-12/schema", "additionalProperties": false, "properties": { "age": { "type": "number", }, "name": { "type": "string", }, }, "required": [ "name", "age", ], "type": "object", } `); const b = z .object({ name: z.string(), }) .catchall(z.string()); expect(z.toJSONSchema(b)).toMatchInlineSnapshot(` { "$schema": "https://json-schema.org/draft/2020-12/schema", "additionalProperties": { "type": "string", }, "properties": { "name": { "type": "string", }, }, "required": [ "name", ], "type": "object", } `); const c = z.looseObject({ name: z.string(), }); expect(z.toJSONSchema(c)).toMatchInlineSnapshot(` { "$schema": "https://json-schema.org/draft/2020-12/schema", "additionalProperties": {}, "properties": { "name": { "type": "string", }, }, "required": [ "name", ], "type": "object", } `); }); test("recursive interface schemas", () => { const TreeNodeSchema = z.object({ id: z.string(), get children() { return TreeNodeSchema; }, }); const result = z.toJSONSchema(TreeNodeSchema); // Should have definitions for recursive schema expect(JSON.stringify(result, null, 2)).toMatchInlineSnapshot( ` "{ "$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object", "properties": { "id": { "type": "string" }, "children": { "$ref": "#" } }, "required": [ "id", "children" ], "additionalProperties": false }" ` ); }); test("mutually recursive interface schemas", () => { const FolderSchema = z.object({ name: z.string(), get files() { return z.array(FileSchema); }, }); const FileSchema = z.object({ name: z.string(), get parent() { return FolderSchema; }, }); const result = z.toJSONSchema(FolderSchema); // Should have definitions for both schemas expect(JSON.stringify(result, null, 2)).toMatchInlineSnapshot( ` "{ "$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object", "properties": { "name": { "type": "string" }, "files": { "type": "array", "items": { "type": "object", "properties": { "name": { "type": "string" }, "parent": { "$ref": "#" } }, "required": [ "name", "parent" ], "additionalProperties": false } } }, "required": [ "name", "files" ], "additionalProperties": false }" ` ); }); }); test("override", () => { const schema = z.toJSONSchema(z.string(), { override: (ctx) => { ctx.zodSchema; ctx.jsonSchema; ctx.jsonSchema.whatever = "sup"; }, }); expect(schema).toMatchInlineSnapshot(` { "$schema": "https://json-schema.org/draft/2020-12/schema", "type": "string", "whatever": "sup", } `); }); test("override: do not run on references", () => { let overrideCount = 0; const schema = z .union([z.string().date(), z.string().datetime(), z.string().datetime({ local: true })]) .meta({ a: true }) .transform((str) => new Date(str)) .meta({ b: true }) .pipe(z.date()) .meta({ c: true }) .brand("dateIn"); z.toJSONSchema(schema, { unrepresentable: "any", io: "input", override(_) { overrideCount++; }, }); expect(overrideCount).toBe(12); }); test("override with refs", () => { const a = z.string().optional(); const result = z.toJSONSchema(a, { override(ctx) { if (ctx.zodSchema._zod.def.type === "string") { ctx.jsonSchema.type = "STRING" as "string"; } }, }); expect(result).toMatchInlineSnapshot(` { "$schema": "https://json-schema.org/draft/2020-12/schema", "type": "STRING", } `); }); test("override execution order", () => { const schema = z.union([z.string(), z.number()]); let unionSchema!: any; z.toJSONSchema(schema, { override(ctx) { if (ctx.zodSchema._zod.def.type === "union") { unionSchema = ctx.jsonSchema; } }, }); expect(unionSchema).toMatchInlineSnapshot(` { "type": [ "string", "number", ], } `); }); test("override with path", () => { const userSchema = z.object({ name: z.string(), age: z.number(), }); const capturedPaths: (string | number)[][] = []; z.toJSONSchema(userSchema, { override(ctx) { capturedPaths.push(ctx.path); }, }); expect(capturedPaths).toMatchInlineSnapshot(` [ [ "properties", "age", ], [ "properties", "name", ], [], ] `); }); test("pipe", () => { const mySchema = z .string() .transform((val) => val.length) .pipe(z.number()); // ZodPipe const a = z.toJSONSchema(mySchema); expect(a).toMatchInlineSnapshot(` { "$schema": "https://json-schema.org/draft/2020-12/schema", "type": "number", } `); // => { type: "number" } const b = z.toJSONSchema(mySchema, { io: "input" }); expect(b).toMatchInlineSnapshot(` { "$schema": "https://json-schema.org/draft/2020-12/schema", "type": "string", } `); // => { type: "string" } }); test("passthrough schemas", () => { const Internal = z.object({ num: z.number(), str: z.string(), }); //.meta({ id: "Internal" }); const External = z.object({ a: Internal, b: Internal.optional(), c: z.lazy(() => Internal), d: z.promise(Internal), e: z.pipe(Internal, Internal), }); const result = z.toJSONSchema(External, { reused: "ref", }); expect(result).toMatchInlineSnapshot(` { "$defs": { "__schema0": { "additionalProperties": false, "properties": { "num": { "type": "number", }, "str": { "type": "string", }, }, "required": [ "num", "str", ], "type": "object", }, }, "$schema": "https://json-schema.org/draft/2020-12/schema", "additionalProperties": false, "properties": { "a": { "$ref": "#/$defs/__schema0", }, "b": { "$ref": "#/$defs/__schema0", }, "c": { "$ref": "#/$defs/__schema0", }, "d": { "$ref": "#/$defs/__schema0", }, "e": { "$ref": "#/$defs/__schema0", }, }, "required": [ "a", "c", "d", "e", ], "type": "object", } `); }); test("extract schemas with id", () => { const name = z.string().meta({ id: "name" }); const result = z.toJSONSchema( z.object({ first_name: name, last_name: name.nullable(), middle_name: name.optional(), age: z.number().meta({ id: "age" }), }) ); expect(result).toMatchInlineSnapshot(` { "$defs": { "age": { "type": "number", }, "name": { "type": "string", }, }, "$schema": "https://json-schema.org/draft/2020-12/schema", "additionalProperties": false, "properties": { "age": { "$ref": "#/$defs/age", }, "first_name": { "$ref": "#/$defs/name", }, "last_name": { "anyOf": [ { "$ref": "#/$defs/name", }, { "type": "null", }, ], }, "middle_name": { "$ref": "#/$defs/name", }, }, "required": [ "first_name", "last_name", "age", ], "type": "object", } `); }); test("escapes JSON Pointer reserved characters in $ref but not in $defs key", () => { const User = z.object({ name: z.string() }).meta({ id: "Shared/User~" }); const result = z.toJSONSchema(z.object({ User })); // the $ref pointer escapes `/` -> `~1` and `~` -> `~0` (RFC 6901), // while the $defs key keeps the original id expect((result.properties!.User as any).$ref).toBe("#/$defs/Shared~1User~0"); expect(Object.keys(result.$defs!)).toEqual(["Shared/User~"]); }); test("escapes JSON Pointer reserved characters in the root $ref", () => { const User = z.object({ name: z.string() }).meta({ id: "Shared/User~" }); const result = z.toJSONSchema(User); expect(result.$ref).toBe("#/$defs/Shared~1User~0"); expect(Object.keys(result.$defs!)).toEqual(["Shared/User~"]); }); test("a multipleOf divisor JSON Schema cannot express goes through `unrepresentable`", () => { // the keyword must be strictly greater than zero, and NaN/Infinity do not survive JSON at all for (const divisor of [0, Number.NaN, Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY]) { expect(() => z.toJSONSchema(z.number().multipleOf(divisor))).toThrow(/cannot be represented in JSON Schema/); expect(z.toJSONSchema(z.number().multipleOf(divisor), { unrepresentable: "any" })).toMatchObject({ type: "number", }); expect(z.toJSONSchema(z.number().multipleOf(divisor), { unrepresentable: "any" })).not.toHaveProperty("multipleOf"); } // a negative divisor accepts exactly what its absolute value accepts, so it still maps expect(z.number().multipleOf(-5).safeParse(10).success).toEqual(true); expect(z.number().multipleOf(-5).safeParse(13).success).toEqual(false); expect(z.toJSONSchema(z.number().multipleOf(-5))).toMatchObject({ multipleOf: 5 }); expect(z.toJSONSchema(z.number().multipleOf(0.1))).toMatchObject({ multipleOf: 0.1 }); }); test("unrepresentable default values go through `unrepresentable`", () => { // a bigint default has no reliable JSON encoding, so it is dropped rather than approximated expect(z.toJSONSchema(z.bigint().default(0n), { unrepresentable: "any" })).toMatchInlineSnapshot(` { "$schema": "https://json-schema.org/draft/2020-12/schema", } `); expect(z.toJSONSchema(z.bigint().prefault(2n), { io: "input", unrepresentable: "any" })).toMatchInlineSnapshot(` { "$schema": "https://json-schema.org/draft/2020-12/schema", } `); // including when nested inside an otherwise representable default expect( z.toJSONSchema(z.object({ a: z.bigint() }).default({ a: 1n }), { unrepresentable: "any" }) ).toMatchInlineSnapshot(` { "$schema": "https://json-schema.org/draft/2020-12/schema", "additionalProperties": false, "properties": { "a": {}, }, "required": [ "a", ], "type": "object", } `); // under the default strict mode the inner type throws first; a representable inner type surfaces the default's own error rather than a raw `JSON.stringify` TypeError expect(() => z.toJSONSchema(z.bigint().default(0n))).toThrow("BigInt cannot be represented in JSON Schema"); expect(() => z.toJSONSchema(z.unknown().default(1n))).toThrow("BigInt defaults cannot be represented in JSON Schema"); // representable defaults are untouched expect(z.toJSONSchema(z.object({ a: z.number() }).default({ a: 2 }))).toMatchInlineSnapshot(` { "$schema": "https://json-schema.org/draft/2020-12/schema", "additionalProperties": false, "default": { "a": 2, }, "properties": { "a": { "type": "number", }, }, "required": [ "a", ], "type": "object", } `); }); test("an `unrepresentable` handler can represent a bigint default", () => { // one handler covers both the type and its default, so no `unrepresentable: "any"` is needed and every other unrepresentable type still throws expect( z.toJSONSchema(z.object({ startAt: z.coerce.bigint().optional().default(0n) }), { io: "input", unrepresentable: ({ zodSchema }) => { const def = zodSchema._zod.def; if (def.type === "bigint") return { type: "integer", format: "int64" }; if (def.type === "default") return { default: String(def.defaultValue) }; return "throw"; }, }) ).toMatchInlineSnapshot(` { "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "startAt": { "default": "0", "format": "int64", "type": "integer", }, }, "type": "object", } `); // a handler may also drop just the default while still representing the type expect( z.toJSONSchema(z.bigint().default(0n), { unrepresentable: ({ zodSchema }) => zodSchema._zod.def.type === "bigint" ? { type: "integer", format: "int64" } : "any", }) ).toMatchInlineSnapshot(` { "$schema": "https://json-schema.org/draft/2020-12/schema", "format": "int64", "type": "integer", } `); }); test("unrepresentable literal values are ignored", () => { const a = z.toJSONSchema(z.literal(["hello", null, 5, BigInt(1324), undefined]), { unrepresentable: "any" }); expect(a).toMatchInlineSnapshot(` { "$schema": "https://json-schema.org/draft/2020-12/schema", "enum": [ "hello", null, 5, 1324, ], } `); const b = z.toJSONSchema(z.literal([undefined, null, 5, BigInt(1324)]), { unrepresentable: "any", }); expect(b).toMatchInlineSnapshot(` { "$schema": "https://json-schema.org/draft/2020-12/schema", "enum": [ null, 5, 1324, ], } `); const c = z.toJSONSchema(z.literal([undefined]), { unrepresentable: "any", }); expect(c).toMatchInlineSnapshot(` { "$schema": "https://json-schema.org/draft/2020-12/schema", } `); }); test("describe with id", () => { const jobId = z.string().meta({ id: "jobId" }); const a = z.toJSONSchema( z.object({ current: jobId.describe("Current job"), previous: jobId.describe("Previous job"), }) ); expect(a).toMatchInlineSnapshot(` { "$defs": { "jobId": { "type": "string", }, }, "$schema": "https://json-schema.org/draft/2020-12/schema", "additionalProperties": false, "properties": { "current": { "$ref": "#/$defs/jobId", "description": "Current job", }, "previous": { "$ref": "#/$defs/jobId", "description": "Previous job", }, }, "required": [ "current", "previous", ], "type": "object", } `); }); test("id is stripped from $defs entries (draft-2020-12)", () => { // The `id` in `.meta()` is a registration tag — it determines the $defs key but should not leak into the definition body, where it is redundant. const inner = z.string().meta({ id: "Inner" }); const result = z.toJSONSchema(z.object({ a: inner, b: inner })); expect(result.$defs?.Inner).toEqual({ type: "string" }); expect((result.$defs?.Inner as any).id).toBeUndefined(); }); test("id is stripped from definitions entries (draft-04)", () => { // In draft-04, `id` is a reserved keyword that sets a base URI for the subschema. Leaking Zod's registration tag here is semantically wrong, so ensure it is stripped. const inner = z.string().meta({ id: "Inner" }); const result = z.toJSONSchema(z.object({ a: inner, b: inner }), { target: "draft-04" }) as any; expect(result.definitions?.Inner).toEqual({ type: "string" }); expect(result.definitions?.Inner?.id).toBeUndefined(); }); test("id is stripped from root schema", () => { // The registration tag should not appear on the root either. const A = z.object({ name: z.string() }).meta({ id: "A" }); const result = z.toJSONSchema(A); expect((result as any).id).toBeUndefined(); }); test("root schema with id is hoisted into $defs", () => { const A = z.object({ name: z.string() }).meta({ id: "A" }); const result = z.toJSONSchema(A); expect(result).toEqual({ $schema: "https://json-schema.org/draft/2020-12/schema", $ref: "#/$defs/A", $defs: { A: { type: "object", properties: { name: { type: "string", }, }, required: ["name"], additionalProperties: false, }, }, }); }); test("root schema with id uses definitions on legacy targets", () => { const A = z.object({ name: z.string() }).meta({ id: "A" }); const result = z.toJSONSchema(A, { target: "draft-07" }); expect(result).toEqual({ $schema: "http://json-schema.org/draft-07/schema#", $ref: "#/definitions/A", definitions: { A: { type: "object", properties: { name: { type: "string", }, }, required: ["name"], additionalProperties: false, }, }, }); }); test("id is observable in override callback", () => { // The strip happens after override callbacks run, so userland override code can still read `jsonSchema.id` if it wants to. const inner = z.string().meta({ id: "Inner" }); const seenIds: Array = []; z.toJSONSchema(z.object({ a: inner }), { override: ({ jsonSchema }) => { if (jsonSchema.id !== undefined) seenIds.push(jsonSchema.id as string); }, }); expect(seenIds).toContain("Inner"); }); test("describe with id on wrapper", () => { // Test that $ref propagation works when processor sets a different ref (readonly -> innerType) but parent was extracted due to having an id const roJobId = z.string().readonly().meta({ id: "roJobId" }); const a = z.toJSONSchema( z.object({ current: roJobId.describe("Current readonly job"), previous: roJobId.describe("Previous readonly job"), }) ); expect(a).toMatchInlineSnapshot(` { "$defs": { "roJobId": { "readOnly": true, "type": "string", }, }, "$schema": "https://json-schema.org/draft/2020-12/schema", "additionalProperties": false, "properties": { "current": { "$ref": "#/$defs/roJobId", "description": "Current readonly job", }, "previous": { "$ref": "#/$defs/roJobId", "description": "Previous readonly job", }, }, "required": [ "current", "previous", ], "type": "object", } `); }); test("overwrite id", () => { const jobId = z.string().meta({ id: "aaa" }); const a = z.toJSONSchema( z.object({ current: jobId, previous: jobId.meta({ id: "bbb" }), }) ); expect(a).toMatchInlineSnapshot(` { "$defs": { "aaa": { "type": "string", }, "bbb": { "$ref": "#/$defs/aaa", }, }, "$schema": "https://json-schema.org/draft/2020-12/schema", "additionalProperties": false, "properties": { "current": { "$ref": "#/$defs/aaa", }, "previous": { "$ref": "#/$defs/bbb", }, }, "required": [ "current", "previous", ], "type": "object", } `); const b = z.toJSONSchema( z.object({ current: jobId, previous: jobId.meta({ id: "ccc" }), }), { reused: "ref", } ); expect(b).toMatchInlineSnapshot(` { "$defs": { "aaa": { "type": "string", }, "ccc": { "$ref": "#/$defs/aaa", }, }, "$schema": "https://json-schema.org/draft/2020-12/schema", "additionalProperties": false, "properties": { "current": { "$ref": "#/$defs/aaa", }, "previous": { "$ref": "#/$defs/ccc", }, }, "required": [ "current", "previous", ], "type": "object", } `); }); test("overwrite descriptions", () => { const field = z.string().describe("a").describe("b").describe("c"); const a = z.toJSONSchema( z.object({ d: field.describe("d"), e: field.describe("e"), }) ); expect(a).toMatchInlineSnapshot(` { "$schema": "https://json-schema.org/draft/2020-12/schema", "additionalProperties": false, "properties": { "d": { "description": "d", "type": "string", }, "e": { "description": "e", "type": "string", }, }, "required": [ "d", "e", ], "type": "object", } `); const b = z.toJSONSchema( z.object({ d: field.describe("d"), e: field.describe("e"), }), { reused: "ref", } ); expect(b).toMatchInlineSnapshot(` { "$defs": { "__schema0": { "description": "c", "type": "string", }, }, "$schema": "https://json-schema.org/draft/2020-12/schema", "additionalProperties": false, "properties": { "d": { "$ref": "#/$defs/__schema0", "description": "d", }, "e": { "$ref": "#/$defs/__schema0", "description": "e", }, }, "required": [ "d", "e", ], "type": "object", } `); }); test("top-level readonly", () => { const A = z .object({ name: z.string(), get b() { return B; }, }) .readonly() .meta({ id: "A" }); // z.globalRegistry.add(A, { id: "A" }); // .meta({ id: "A" }); const B = z .object({ name: z.string(), get a() { return A; }, }) .readonly() .meta({ id: "B" }); // z.globalRegistry.add(B, { id: "B" }); // .meta({ id: "B" }); const result = z.toJSONSchema(A); expect(result).toEqual({ $schema: "https://json-schema.org/draft/2020-12/schema", $ref: "#/$defs/A", $defs: { A: { additionalProperties: false, properties: { b: { $ref: "#/$defs/B", }, name: { type: "string", }, }, readOnly: true, required: ["name", "b"], type: "object", }, B: { additionalProperties: false, properties: { a: { $ref: "#/$defs/A", }, name: { type: "string", }, }, readOnly: true, required: ["name", "a"], type: "object", }, }, }); }); test("basic registry", () => { const myRegistry = z.registry<{ id: string }>(); const User = z.object({ name: z.string(), get posts() { return z.array(Post); }, }); const Post = z.object({ title: z.string(), content: z.string(), get author() { return User; }, }); myRegistry.add(User, { id: "User" }); myRegistry.add(Post, { id: "Post" }); const result = z.toJSONSchema(myRegistry, { uri: (id) => `https://example.com/${id}.json`, }); expect(result).toMatchInlineSnapshot(` { "schemas": { "Post": { "$id": "https://example.com/Post.json", "$schema": "https://json-schema.org/draft/2020-12/schema", "additionalProperties": false, "properties": { "author": { "$ref": "https://example.com/User.json", }, "content": { "type": "string", }, "title": { "type": "string", }, }, "required": [ "title", "content", "author", ], "type": "object", }, "User": { "$id": "https://example.com/User.json", "$schema": "https://json-schema.org/draft/2020-12/schema", "additionalProperties": false, "properties": { "name": { "type": "string", }, "posts": { "items": { "$ref": "https://example.com/Post.json", }, "type": "array", }, }, "required": [ "name", "posts", ], "type": "object", }, }, } `); }); function countMapScanWork(convert: () => T): { result: T; work: number } { const entries = Map.prototype.entries; const values = Map.prototype.values; let work = 0; const entriesSpy = vi.spyOn(Map.prototype, "entries").mockImplementation(function (this: Map) { work += this.size; return entries.call(this); } as typeof Map.prototype.entries); const valuesSpy = vi.spyOn(Map.prototype, "values").mockImplementation(function (this: Map) { work += this.size; return values.call(this); } as typeof Map.prototype.values); try { return { result: convert(), work }; } finally { entriesSpy.mockRestore(); valuesSpy.mockRestore(); } } test("large registry conversion performs linear map-scan work", () => { const count = 64; const convert = (size: number, withIntersection: boolean) => { const registry = z.registry<{ id: string }>(); for (let i = 0; i < size; i++) { registry.add( z.object({ id: z.string(), name: z.string(), count: z.number(), nested: z.object({ a: z.boolean() }) }), { id: `Type${i}` } ); } if (withIntersection) registry.add(z.object({ a: z.string() }).and(z.object({ b: z.string() })), { id: "Inter" }); return countMapScanWork(() => z.toJSONSchema(registry, { uri: (id) => `https://example.com/${id}.json` })); }; const plain = convert(count, false); const plainDouble = convert(count * 2, false); expect(Object.keys(plain.result.schemas)).toHaveLength(count); expect(plain.result.schemas.Type0).toMatchObject({ $id: "https://example.com/Type0.json", type: "object", properties: { nested: { type: "object" } }, }); expect(plain.result.schemas[`Type${count - 1}`]!.$id).toBe(`https://example.com/Type${count - 1}.json`); expect(plain.work).toBeGreaterThan(0); expect(plainDouble.work).toBeLessThanOrEqual(plain.work * 2); const folded = convert(count, true); const foldedDouble = convert(count * 2, true); expect(folded.result.schemas.Inter).toMatchObject({ type: "object", properties: { a: {}, b: {} } }); expect(folded.work).toBeGreaterThan(0); expect(foldedDouble.work).toBeLessThanOrEqual(folded.work * 2); }); test("a registry of records with numeric keys performs linear map-scan work", () => { const count = 64; const convert = (size: number, key: (i: number) => z.core.$ZodType) => { const registry = z.registry<{ id: string }>(); for (let i = 0; i < size; i++) { registry.add(z.object({ m: z.record(key(i) as z.core.$ZodRecordKey, z.boolean()) }), { id: `Type${i}` }); } return countMapScanWork(() => z.toJSONSchema(registry, { uri: (id) => `https://example.com/${id}.json` })); }; const numeric = convert(count, () => z.number()); const numericDouble = convert(count * 2, () => z.number()); expect(numeric.result.schemas.Type0).toMatchObject({ properties: { m: { propertyNames: { type: "string", pattern: "^-?\\d+(?:\\.\\d+)?$" } } }, }); expect(numeric.work).toBeGreaterThan(0); expect(numericDouble.work).toBeLessThanOrEqual(numeric.work * 2); const extracted = convert(count, (i) => z.number().meta({ id: `Key${i}` })); const extractedDouble = convert(count * 2, (i) => z.number().meta({ id: `Key${i}` })); expect(extracted.result.schemas.Type0).toMatchObject({ properties: { m: { propertyNames: { type: "string", pattern: "^-?\\d+(?:\\.\\d+)?$" } } }, }); expect(extracted.work).toBeGreaterThan(0); expect(extractedDouble.work).toBeLessThanOrEqual(extracted.work * 2); }); test("registry extracts unregistered subschemas into __shared", () => { const registry = z.registry<{ id: string }>(); const address = z.object({ street: z.string() }).meta({ id: "Address" }); registry.add(z.object({ home: address, work: address }), { id: "Person" }); registry.add(z.object({ hq: address }), { id: "Company" }); const { schemas } = z.toJSONSchema(registry, { uri: (id) => `https://example.com/${id}.json` }); expect(schemas.__shared).toMatchInlineSnapshot(` { "$defs": { "Address": { "additionalProperties": false, "properties": { "street": { "type": "string", }, }, "required": [ "street", ], "type": "object", }, }, } `); expect(schemas.Person).toMatchInlineSnapshot(` { "$id": "https://example.com/Person.json", "$schema": "https://json-schema.org/draft/2020-12/schema", "additionalProperties": false, "properties": { "home": { "$ref": "https://example.com/__shared.json#/$defs/Address", }, "work": { "$ref": "https://example.com/__shared.json#/$defs/Address", }, }, "required": [ "home", "work", ], "type": "object", } `); // Company is emitted after Person, so it only resolves if the shared $defs built on the first finalize are still reachable — the pass that writes them no longer runs per schema. expect(schemas.Company!.properties!.hq).toEqual({ $ref: "https://example.com/__shared.json#/$defs/Address", }); }); test("registry extracts reused subschemas into __shared without an id", () => { const registry = z.registry<{ id: string }>(); const shared = z.object({ q: z.string() }); registry.add(z.object({ a: shared, b: shared }), { id: "First" }); registry.add(z.object({ c: shared }), { id: "Second" }); const { schemas } = z.toJSONSchema(registry, { uri: (id) => `${id}.json`, reused: "ref" }); // The id is counter-generated, so this pins that ctx.counter is consumed exactly once. expect(Object.keys(schemas.__shared!.$defs!)).toEqual(["schema0"]); expect(schemas.First!.properties!.a).toEqual({ $ref: "__shared.json#/$defs/schema0" }); expect(schemas.Second!.properties!.c).toEqual({ $ref: "__shared.json#/$defs/schema0" }); }); test("JSONSchemaGenerator re-runs shared passes when emit params change", () => { const shared = z.object({ s: z.string() }); const a = z.object({ x: shared, y: shared }); const registry = z.registry<{ id: string }>(); registry.add(a, { id: "A" }); const gen = new z.core.JSONSchemaGenerator({ target: "draft-2020-12" }); gen.process(a); const defs: Record = {}; const external = { registry, uri: (id: string) => `${id}.json`, defs }; gen.emit(a, { external, reused: "inline" }); // Same `external`, different `reused` — the second emit must still extract `shared`. const second: any = gen.emit(a, { external, reused: "ref" }); expect(Object.keys(defs)).toEqual(["schema0"]); expect(second.properties.x).toEqual({ $ref: "__shared.json#/$defs/schema0" }); expect(second.properties.y).toEqual({ $ref: "__shared.json#/$defs/schema0" }); }); test("JSONSchemaGenerator still throws on cycles when a later emit asks for it", () => { const Node: any = z.object({ v: z.string(), get next() { return Node; }, }); const registry = z.registry<{ id: string }>(); registry.add(Node, { id: "Node" }); const gen = new z.core.JSONSchemaGenerator({ target: "draft-2020-12" }); gen.process(Node); const external = { registry, uri: (id: string) => `${id}.json`, defs: {} }; gen.emit(Node, { external, cycles: "ref" }); expect(() => gen.emit(Node, { external, cycles: "throw" })).toThrow(/Cycle detected/); }); test("JSONSchemaGenerator re-runs shared passes on a no-params emit", () => { const shared = z.object({ s: z.string() }); const a = z.object({ x: shared }); const registry = z.registry<{ id: string }>(); registry.add(a, { id: "A" }); const gen = new z.core.JSONSchemaGenerator({ target: "draft-2020-12" }); gen.process(a); const defs: Record = {}; gen.emit(a, { external: { registry, uri: (id: string) => `${id}.json`, defs }, reused: "ref" }); // Re-processing an already-seen schema bumps `seen.count`, which `extractDefs` branches on under `reused: "ref"` — and it returns early, so it cannot clear the guards itself. gen.process(shared); const second: any = gen.emit(a); expect(Object.keys(defs)).toEqual(["schema0"]); expect(second.properties.x).toEqual({ $ref: "__shared.json#/$defs/schema0" }); }); test("_ref", () => { // const a = z.promise(z.string().describe("a")); const a = z.toJSONSchema(z.promise(z.string().describe("a"))); expect(a).toMatchInlineSnapshot(` { "$schema": "https://json-schema.org/draft/2020-12/schema", "description": "a", "type": "string", } `); const b = z.toJSONSchema(z.lazy(() => z.string().describe("a"))); expect(b).toMatchInlineSnapshot(` { "$schema": "https://json-schema.org/draft/2020-12/schema", "description": "a", "type": "string", } `); const c = z.toJSONSchema(z.optional(z.string().describe("a"))); expect(c).toMatchInlineSnapshot(` { "$schema": "https://json-schema.org/draft/2020-12/schema", "description": "a", "type": "string", } `); const d = z.toJSONSchema(z.string().meta({ id: "foo" }).describe("bar").optional()); expect(d).toMatchInlineSnapshot(` { "$defs": { "foo": { "type": "string", }, }, "$ref": "#/$defs/foo", "$schema": "https://json-schema.org/draft/2020-12/schema", "description": "bar", } `); }); test("defaults/prefaults", () => { const a = z .string() .transform((val) => val.length) .pipe(z.number()); const b = a.prefault("hello"); const c = a.default(1234); // a expect(z.toJSONSchema(a)).toMatchInlineSnapshot(` { "$schema": "https://json-schema.org/draft/2020-12/schema", "type": "number", } `); expect(z.toJSONSchema(a, { io: "input" })).toMatchInlineSnapshot(` { "$schema": "https://json-schema.org/draft/2020-12/schema", "type": "string", } `); // b expect(z.toJSONSchema(b)).toMatchInlineSnapshot(` { "$schema": "https://json-schema.org/draft/2020-12/schema", "type": "number", } `); expect(z.toJSONSchema(b, { io: "input" })).toMatchInlineSnapshot(` { "$schema": "https://json-schema.org/draft/2020-12/schema", "default": "hello", "type": "string", } `); // c expect(z.toJSONSchema(c)).toMatchInlineSnapshot(` { "$schema": "https://json-schema.org/draft/2020-12/schema", "default": 1234, "type": "number", } `); expect(z.toJSONSchema(c, { io: "input" })).toMatchInlineSnapshot(` { "$schema": "https://json-schema.org/draft/2020-12/schema", "type": "string", } `); }); test("catch on a transforming schema", () => { const a = z .string() .transform((val) => val.length) .pipe(z.number()) .catch(0); expect(z.toJSONSchema(a)).toMatchInlineSnapshot(` { "$schema": "https://json-schema.org/draft/2020-12/schema", "default": 0, "type": "number", } `); // catch values are output-typed, so they are not valid input expect(z.toJSONSchema(a, { io: "input" })).toMatchInlineSnapshot(` { "$schema": "https://json-schema.org/draft/2020-12/schema", "type": "string", } `); // the catch no longer hides the inner transform from ancestors, so their output-typed metadata is stripped too — matching a bare nested transform expect(z.toJSONSchema(z.object({ a }).meta({ examples: [{ a: 1 }] }), { io: "input" })).toMatchInlineSnapshot(` { "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "a": { "type": "string", }, }, "required": [ "a", ], "type": "object", } `); }); test("falsy prefaults (false, 0, empty string)", () => { // boolean prefault false const a = z.boolean().prefault(false); expect(z.toJSONSchema(a, { io: "input" })).toMatchInlineSnapshot(` { "$schema": "https://json-schema.org/draft/2020-12/schema", "default": false, "type": "boolean", } `); // number prefault 0 const b = z.number().prefault(0); expect(z.toJSONSchema(b, { io: "input" })).toMatchInlineSnapshot(` { "$schema": "https://json-schema.org/draft/2020-12/schema", "default": 0, "type": "number", } `); // string prefault empty string const c = z.string().prefault(""); expect(z.toJSONSchema(c, { io: "input" })).toMatchInlineSnapshot(` { "$schema": "https://json-schema.org/draft/2020-12/schema", "default": "", "type": "string", } `); }); test("input type", () => { const schema = z.object({ a: z.string(), b: z.string().optional(), c: z.string().default("hello"), d: z.string().nullable(), e: z.string().prefault("hello"), f: z.string().catch("hello"), g: z.never(), h: z.union([z.string(), z.number().default(2)]), i: z.union([z.string(), z.string().optional()]), }); expect(z.toJSONSchema(schema, { io: "input" })).toMatchInlineSnapshot(` { "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "a": { "type": "string", }, "b": { "type": "string", }, "c": { "default": "hello", "type": "string", }, "d": { "type": [ "string", "null", ], }, "e": { "default": "hello", "type": "string", }, "f": { "default": "hello", "type": "string", }, "g": { "not": {}, }, "h": { "anyOf": [ { "type": "string", }, { "default": 2, "type": "number", }, ], }, "i": { "type": "string", }, }, "required": [ "a", "d", "f", "g", ], "type": "object", } `); expect(z.toJSONSchema(schema, { io: "output" })).toMatchInlineSnapshot(` { "$schema": "https://json-schema.org/draft/2020-12/schema", "additionalProperties": false, "properties": { "a": { "type": "string", }, "b": { "type": "string", }, "c": { "default": "hello", "type": "string", }, "d": { "type": [ "string", "null", ], }, "e": { "type": "string", }, "f": { "default": "hello", "type": "string", }, "g": { "not": {}, }, "h": { "anyOf": [ { "type": "string", }, { "default": 2, "type": "number", }, ], }, "i": { "type": "string", }, }, "required": [ "a", "c", "d", "e", "f", "g", "h", ], "type": "object", } `); }); test("examples on pipe", () => { const schema = z .string() .meta({ examples: ["test"] }) .transform(Number) // .pipe(z.transform(Number).meta({ examples: [4] })) .meta({ examples: [4] }); const i = z.toJSONSchema(schema, { io: "input", unrepresentable: "any" }); expect(i).toMatchInlineSnapshot(` { "$schema": "https://json-schema.org/draft/2020-12/schema", "examples": [ "test", ], "type": "string", } `); const o = z.toJSONSchema(schema, { io: "output", unrepresentable: "any" }); expect(o).toMatchInlineSnapshot(` { "$schema": "https://json-schema.org/draft/2020-12/schema", "examples": [ 4, ], } `); }); // test("number checks", () => { // expect(z.toJSONSchema(z.number().int())).toMatchInlineSnapshot(` // { // "maximum": 9007199254740991, // "minimum": -9007199254740991, // "type": "integer", // } // `); // expect(z.toJSONSchema(z.int())).toMatchInlineSnapshot(` // { // "maximum": 9007199254740991, // "minimum": -9007199254740991, // "type": "integer", // } // `); // expect(z.toJSONSchema(z.int().positive())).toMatchInlineSnapshot(` // { // "exclusiveMinimum": 0, // "maximum": 9007199254740991, // "minimum": -9007199254740991, // "type": "integer", // } // `); // expect(z.toJSONSchema(z.int().nonnegative())).toMatchInlineSnapshot(` // { // "maximum": 9007199254740991, // "minimum": 0, // "type": "integer", // } // `); // expect(z.toJSONSchema(z.int().gt(0))).toMatchInlineSnapshot(` // { // "exclusiveMinimum": 0, // "maximum": 9007199254740991, // "minimum": -9007199254740991, // "type": "integer", // } // `); // expect(z.toJSONSchema(z.int().gte(0))).toMatchInlineSnapshot(` // { // "maximum": 9007199254740991, // "minimum": 0, // "type": "integer", // } // `); // }); test("use output type for preprocess", () => { const a = z.preprocess((val) => String(val), z.string()); expect(z.toJSONSchema(a, { io: "input" })).toMatchInlineSnapshot(` { "$schema": "https://json-schema.org/draft/2020-12/schema", "type": "string", } `); }); test("object property with preprocess stays required in input JSON schema", () => { const schema = z.object({ noPreprocess: z.string(), withPreprocess: z.preprocess((v) => v, z.string()), optionalPreprocess: z.preprocess((v) => v, z.string().optional()), }); // A preprocessed property is only optional when its inner schema is — the // transform wrapper must not make a required property optional in the input // JSON schema (matches runtime: `schema.parse({})` rejects `withPreprocess`). expect(z.toJSONSchema(schema, { io: "input" })).toMatchInlineSnapshot(` { "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "noPreprocess": { "type": "string", }, "optionalPreprocess": { "type": "string", }, "withPreprocess": { "type": "string", }, }, "required": [ "noPreprocess", "withPreprocess", ], "type": "object", } `); }); test("input JSON schema resolves requiredness past transform and catch wrappers", () => { const required = (schema: z.ZodObject) => (z.toJSONSchema(schema, { io: "input" }) as z.core.JSONSchema.ObjectSchema).required; const id = (v: unknown) => v; // catch observes an absent key at runtime, but its declared input type stays required (#5003) expect(required(z.object({ a: z.string().catch("x") }))).toEqual(["a"]); expect(required(z.object({ a: z.string().optional().catch("x") }))).toBeUndefined(); // nested preprocess and the legacy transform-pipe form both resolve through to the inner schema expect(required(z.object({ a: z.preprocess(id, z.preprocess(id, z.string())) }))).toEqual(["a"]); expect(required(z.object({ a: z.transform((v: unknown) => String(v)).pipe(z.string()) }))).toEqual(["a"]); }); test("object required keys preserve shape order and direction", () => { const symbol = Symbol("symbol"); const shape: z.ZodRawShape = { 10: z.string(), 2: z.string().optional(), plain: z.string(), optional: z.string().optional(), defaulted: z.string().default("x"), caught: z.string().catch("x"), [symbol]: z.string(), }; Object.defineProperty(shape, "__proto__", { value: z.string(), enumerable: true }); Object.defineProperty(shape, "hidden", { value: z.string(), enumerable: false }); const input = z.toJSONSchema(z.object(shape), { io: "input", unrepresentable: "any" }); const output = z.toJSONSchema(z.object(shape), { io: "output", unrepresentable: "any" }); expect(Object.keys(input.properties!)).toEqual(["2", "10", "plain", "optional", "defaulted", "caught", "__proto__"]); expect(input.required).toEqual(["10", "plain", "caught", "__proto__"]); expect(output.required).toEqual(["10", "plain", "defaulted", "caught", "__proto__"]); expect(Object.prototype.hasOwnProperty.call(input.properties!, "__proto__")).toBe(true); expect(input.properties).not.toHaveProperty("hidden"); expect(Object.getOwnPropertySymbols(input.properties!)).toEqual([]); }); test("strip output-side examples from input JSON schema for codec", () => { const codec = z .codec(z.string(), z.number(), { decode: (s) => Number(s), encode: (n) => String(n) }) .meta({ examples: [42] }); expect(z.toJSONSchema(codec, { io: "input" })).toMatchInlineSnapshot(` { "$schema": "https://json-schema.org/draft/2020-12/schema", "type": "string", } `); expect(z.toJSONSchema(codec, { io: "output" })).toMatchInlineSnapshot(` { "$schema": "https://json-schema.org/draft/2020-12/schema", "examples": [ 42, ], "type": "number", } `); }); // test("isTransforming", () => { // const tx = z.core.isTransforming; // expect(tx(z.string())).toEqual(false); // expect(tx(z.string().transform((val) => val))).toEqual(true); // expect(tx(z.string().pipe(z.string()))).toEqual(false); // expect( // tx( // z // .string() // .transform((val) => val) // .pipe(z.string()) // ) // ).toEqual(true); // const a = z.transform((val) => val); // expect(tx(z.transform((val) => val))).toEqual(true); // expect(tx(a.optional())).toEqual(true); // const b = z.string().optional(); // expect(tx(b)).toEqual(false); // const c = z.string().prefault("hello"); // expect(tx(c)).toEqual(false); // const d = z.string().default("hello"); // expect(tx(d)).toEqual(false); // }); test("flatten simple intersections", () => { const FirstSchema = z.object({ testNum: z.number(), }); const SecondSchema = z.object({ testStr: z.string(), }); const ThirdSchema = z.object({ testBool: z.boolean(), }); const HelloSchema = FirstSchema.and(SecondSchema).and(ThirdSchema).describe("123"); // Zod 3 // console.log(JSON.stringify(zodToJsonSchema(HelloSchema), null, 2)); // Zod 4 const result = z.toJSONSchema(HelloSchema, { target: "draft-7" }); expect(result).toMatchInlineSnapshot(` { "$schema": "http://json-schema.org/draft-07/schema#", "additionalProperties": false, "description": "123", "properties": { "testBool": { "type": "boolean", }, "testNum": { "type": "number", }, "testStr": { "type": "string", }, }, "required": [ "testNum", "testStr", "testBool", ], "type": "object", } `); }); test("z.file()", () => { const a = z.file(); expect(z.toJSONSchema(a)).toMatchInlineSnapshot(` { "$schema": "https://json-schema.org/draft/2020-12/schema", "contentEncoding": "binary", "format": "binary", "type": "string", } `); const b = z.file().mime("image/png").min(1000).max(10000); expect(z.toJSONSchema(b)).toMatchInlineSnapshot(` { "$schema": "https://json-schema.org/draft/2020-12/schema", "contentEncoding": "binary", "contentMediaType": "image/png", "format": "binary", "maxLength": 10000, "minLength": 1000, "type": "string", } `); const c = z.file().mime(["image/png", "image/jpg"]).min(1000).max(10000); expect(z.toJSONSchema(c)).toMatchInlineSnapshot(` { "$schema": "https://json-schema.org/draft/2020-12/schema", "anyOf": [ { "contentMediaType": "image/png", }, { "contentMediaType": "image/jpg", }, ], "contentEncoding": "binary", "format": "binary", "maxLength": 10000, "minLength": 1000, "type": "string", } `); }); test("custom toJSONSchema", () => { const schema = z.instanceof(Date); schema._zod.toJSONSchema = () => ({ type: "string", format: "date-time", }); expect(z.toJSONSchema(schema)).toMatchInlineSnapshot(` { "$schema": "https://json-schema.org/draft/2020-12/schema", "format": "date-time", "type": "string", } `); }); test("cycle detection - root", () => { const schema = z.object({ name: z.string(), get subcategories() { return z.array(schema); }, }); expect(() => z.toJSONSchema(schema, { cycles: "throw" })).toThrowErrorMatchingInlineSnapshot(` [Error: Cycle detected: #/properties/subcategories/items/ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.] `); }); test("cycle detection - mutual recursion", () => { const A = z.object({ name: z.string(), get subcategories() { return z.array(B); }, }); const B = z.object({ name: z.string(), get subcategories() { return z.array(A); }, }); expect(() => z.toJSONSchema(A, { cycles: "throw" })).toThrowErrorMatchingInlineSnapshot(` [Error: Cycle detected: #/properties/subcategories/items/properties/subcategories/items/ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.] `); }); test("recursive lazy with describe does not stack overflow", () => { const NodeSchema: z.ZodType = z.lazy(() => z .object({ value: z.string().describe("node value"), children: z.array(NodeSchema.describe("child node")).optional().describe("child list"), }) .describe("tree node") ); const result = z.toJSONSchema(NodeSchema, { cycles: "ref", reused: "ref" }); expect(result).toBeDefined(); expect(result.$defs).toBeDefined(); }); test("__proto__ shape key is emitted as an own property", () => { const schema = z.object({ ["__proto__"]: z.literal("admin"), role: z.string() }); const result = z.toJSONSchema(schema, { io: "input" }); expect(result.required).toEqual(["__proto__", "role"]); // every required key needs a matching entry in properties for (const key of result.required!) { expect(Object.prototype.hasOwnProperty.call(result.properties, key)).toBe(true); } expect(JSON.parse(JSON.stringify(result)).properties.__proto__).toEqual({ type: "string", const: "admin" }); }); test("__proto__ registry id is emitted as an own entry", () => { const myRegistry = z.registry<{ id: string }>(); myRegistry.add(z.object({ a: z.string() }), { id: "__proto__" }); myRegistry.add(z.object({ b: z.string() }), { id: "normal" }); expect(Object.keys(z.toJSONSchema(myRegistry).schemas)).toEqual(["__proto__", "normal"]); }); test("__proto__ def id emits a resolvable $ref", () => { const myRegistry = z.registry<{ id: string }>(); const Inner = z.object({ x: z.string() }); myRegistry.add(Inner, { id: "__proto__" }); const json = JSON.parse(JSON.stringify(z.toJSONSchema(z.object({ a: Inner, b: Inner }), { metadata: myRegistry }))); expect(json.properties.a.$ref).toBe("#/$defs/__proto__"); expect(json.$defs.__proto__).toBeDefined(); }); test("__proto__ patternProperties key is emitted as an own property", () => { const result = z.toJSONSchema(z.looseRecord(z.string().regex(/__proto__/), z.string())); expect(Object.getPrototypeOf(result.patternProperties)).toBe(Object.prototype); expect(Object.prototype.hasOwnProperty.call(result.patternProperties, "__proto__")).toBe(true); expect(result.patternProperties?.__proto__).toEqual({ type: "string" }); }); test("__proto__ metadata survives direct and wrapper metadata merges", () => { for (const schema of [z.string(), z.readonly(z.string())]) { const registry = z.registry>(); registry.add(schema, Object.fromEntries([["__proto__", { marker: true }]])); const result: any = z.toJSONSchema(schema, { metadata: registry }); expect(Object.getPrototypeOf(result)).toBe(Object.prototype); expect(Object.prototype.hasOwnProperty.call(result, "__proto__")).toBe(true); expect(result.__proto__).toEqual({ marker: true }); expect(JSON.parse(JSON.stringify(result)).__proto__).toEqual({ marker: true }); } }); test("partialRecord does not require finite keys", () => { const result = z.toJSONSchema(z.partialRecord(z.enum(["__proto__", "b"]), z.string())); expect(result.required).toBeUndefined(); expect(result.propertyNames).toEqual({ type: "string", enum: ["__proto__", "b"] }); expect(result.additionalProperties).toEqual({ type: "string" }); }); describe("unrepresentable callback", () => { test("is consulted for every unrepresentable type", () => { const seen: string[] = []; const collect: z.core.UnrepresentableHandler = ({ zodSchema }) => { seen.push(zodSchema._zod.def.type); return "any"; }; const schemas = [ z.bigint(), z.symbol(), z.undefined(), z.void(), z.date(), z.nan(), z.custom(), z.map(z.string(), z.string()), z.set(z.string()), z.transform((x: unknown) => x), z.literal([undefined]), z.literal([1n]), z.string().catch(() => { throw new Error("dynamic"); }), // z.function() is not a ZodType, but its processor is reachable through the same path z.function() as unknown as z.ZodType, ]; for (const schema of schemas) z.toJSONSchema(schema, { unrepresentable: collect }); expect(seen).toEqual([ "bigint", "symbol", "undefined", "void", "date", "nan", "custom", "map", "set", "transform", "literal", "literal", "catch", "function", ]); }); test("returned JSON Schema replaces the unrepresentable node", () => { // the motivating case: represent dates, keep throwing for everything else const params: z.core.ToJSONSchemaParams = { unrepresentable: ({ zodSchema }) => zodSchema._zod.def.type === "date" ? { type: "string", format: "date-time" } : "throw", }; expect(z.toJSONSchema(z.object({ when: z.date() }), params)).toMatchInlineSnapshot(` { "$schema": "https://json-schema.org/draft/2020-12/schema", "additionalProperties": false, "properties": { "when": { "format": "date-time", "type": "string", }, }, "required": [ "when", ], "type": "object", } `); expect(() => z.toJSONSchema(z.object({ id: z.bigint() }), params)).toThrow( "BigInt cannot be represented in JSON Schema" ); }); test("`throw` and `undefined` returns produce the default error", () => { expect(() => z.toJSONSchema(z.date(), { unrepresentable: () => "throw" })).toThrow( "Date cannot be represented in JSON Schema" ); expect(() => z.toJSONSchema(z.date(), { unrepresentable: () => undefined })).toThrow( "Date cannot be represented in JSON Schema" ); }); test("`any` return matches the string option", () => { expect(z.toJSONSchema(z.date(), { unrepresentable: () => "any" })).toEqual( z.toJSONSchema(z.date(), { unrepresentable: "any" }) ); }); test("`message` distinguishes sites that share a schema", () => { const seen: string[] = []; expect( z.toJSONSchema(z.literal([undefined, 1n, "a"]), { unrepresentable: ({ zodSchema, message }) => { seen.push(`${zodSchema._zod.def.type}: ${message}`); return "any"; }, }) ).toMatchInlineSnapshot(` { "$schema": "https://json-schema.org/draft/2020-12/schema", "enum": [ 1, "a", ], } `); // same `zodSchema`, different message -- the only way to tell the two literal sites apart expect(seen).toEqual([ "literal: Literal `undefined` cannot be represented in JSON Schema", "literal: BigInt literals cannot be represented in JSON Schema", ]); }); test("errors thrown by the callback propagate", () => { expect(() => z.toJSONSchema(z.object({ when: z.date() }), { unrepresentable: ({ zodSchema, path }) => { throw new Error(`${zodSchema._zod.def.type} at /${path.join("/")}`); }, }) ).toThrow("date at /properties/when"); }); test("runs before `override`", () => { const order: string[] = []; expect( z.toJSONSchema(z.date(), { unrepresentable: () => { order.push("unrepresentable"); return { type: "string" }; }, override: (ctx) => { order.push("override"); if (ctx.jsonSchema.type === "string") ctx.jsonSchema.format = "date-time"; }, }) ).toMatchInlineSnapshot(` { "$schema": "https://json-schema.org/draft/2020-12/schema", "format": "date-time", "type": "string", } `); expect(order).toEqual(["unrepresentable", "override"]); }); test("a returned schema replaces the whole literal", () => { expect( z.toJSONSchema(z.literal(["a", 1n]), { unrepresentable: () => ({ type: "string", pattern: "^\\d+$" }), }) ).toMatchInlineSnapshot(` { "$schema": "https://json-schema.org/draft/2020-12/schema", "pattern": "^\\d+$", "type": "string", } `); // "any" keeps the existing per-value behavior: undefined dropped, bigint coerced expect(z.toJSONSchema(z.literal(["a", 1n, undefined]), { unrepresentable: () => "any" })).toEqual( z.toJSONSchema(z.literal(["a", 1n, undefined]), { unrepresentable: "any" }) ); }); const dateToString: z.core.ToJSONSchemaParams["unrepresentable"] = ({ zodSchema }) => zodSchema._zod.def.type === "date" ? { type: "string", format: "date-time" } : "throw"; test("emits a valid OpenAPI 3.0 schema", async () => { const jsonSchema = z.toJSONSchema(z.object({ start: z.date() }), { target: "openapi-3.0", unrepresentable: dateToString, }); expect(jsonSchema).toMatchInlineSnapshot(` { "additionalProperties": false, "properties": { "start": { "format": "date-time", "type": "string", }, }, "required": [ "start", ], "type": "object", } `); await expect(validateOpenAPI30Schema(jsonSchema)).resolves.toBe(true); }); test("the returned schema survives extraction into $defs", () => { const When = z.date().meta({ id: "When" }); expect( z.toJSONSchema(z.object({ start: When, end: When }), { unrepresentable: dateToString }) ).toMatchInlineSnapshot(` { "$defs": { "When": { "format": "date-time", "type": "string", }, }, "$schema": "https://json-schema.org/draft/2020-12/schema", "additionalProperties": false, "properties": { "end": { "$ref": "#/$defs/When", }, "start": { "$ref": "#/$defs/When", }, }, "required": [ "start", "end", ], "type": "object", } `); }); test("applies to dynamic catch values", () => { const schema = z.string().catch(() => { throw new Error("dynamic"); }); expect( z.toJSONSchema(schema, { unrepresentable: () => ({ default: "fallback" }), }) ).toMatchInlineSnapshot(` { "$schema": "https://json-schema.org/draft/2020-12/schema", "default": "fallback", "type": "string", } `); }); }); describe("intersection folding", () => { const TARGETS = ["draft-2020-12", "draft-07", "draft-04", "openapi-3.0"] as const; const body = (schema: z.ZodType, params?: Parameters[1]) => { const { $schema, ...rest } = z.toJSONSchema(schema, params) as Record; return rest; }; test("two objects become one object", () => { expect(body(z.object({ name: z.string() }).and(z.object({ age: z.number() })))).toMatchInlineSnapshot(` { "additionalProperties": false, "properties": { "age": { "type": "number", }, "name": { "type": "string", }, }, "required": [ "name", "age", ], "type": "object", } `); }); test("the folded shape is identical on every target", () => { const schema = z.object({ name: z.string() }).and(z.object({ age: z.number() })); const bodies = TARGETS.map((target) => body(schema, { target })); for (const emitted of bodies) expect(emitted).toEqual(bodies[0]); }); test("closed only when every member is closed", () => { expect(body(z.strictObject({ a: z.string() }).and(z.strictObject({ b: z.number() })))).toMatchObject({ additionalProperties: false, }); // A loose member keeps the intersection open, which is what the parser does: it merges both results, so the loose side's extra keys survive. expect(body(z.looseObject({ a: z.string() }).and(z.object({ b: z.number() })))).not.toHaveProperty( "additionalProperties" ); }); test("chained and nested intersections fold flat", () => { const chained = z .object({ a: z.string() }) .and(z.object({ b: z.string() })) .and(z.object({ c: z.string() })); expect(body(chained)).toMatchObject({ properties: { a: {}, b: {}, c: {} }, required: ["a", "b", "c"] }); const nested = z.object({ a: z.string() }).and(z.object({ b: z.string() }).and(z.object({ c: z.string() }))); expect(body(nested)).toEqual(body(chained)); }); test("required is the union, and an optional key stays optional", () => { expect(body(z.object({ a: z.string().optional() }).and(z.object({ b: z.string() })))).toMatchObject({ properties: { a: { type: "string" }, b: { type: "string" } }, required: ["b"], }); }); test("a key both members declare has to satisfy both", () => { // identical declarations collapse expect(body(z.object({ k: z.string() }).and(z.object({ k: z.string() })))).toMatchObject({ properties: { k: { type: "string" } }, }); // object declarations fold, matching the parser's deep merge expect( body(z.object({ k: z.object({ a: z.string() }) }).and(z.object({ k: z.object({ b: z.string() }) }))) ).toMatchObject({ properties: { k: { type: "object", properties: { a: {}, b: {} }, required: ["a", "b"] } }, }); // anything else stays an intersection one level down expect(body(z.object({ k: z.string() }).and(z.object({ k: z.number() })))).toMatchObject({ properties: { k: { allOf: [{ type: "string" }, { type: "number" }] } }, }); }); test("an intersection distributes over a union", () => { const schema = z .object({ name: z.string() }) .and( z.discriminatedUnion("type", [ z.object({ type: z.literal("a"), value: z.string() }), z.object({ type: z.literal("b"), count: z.number() }), ]) ); expect(body(schema)).toMatchObject({ oneOf: [ { type: "object", properties: { name: {}, type: {}, value: {} }, additionalProperties: false }, { type: "object", properties: { name: {}, type: {}, count: {} }, additionalProperties: false }, ], }); const inclusive = z .object({ name: z.string() }) .and(z.union([z.object({ v: z.string() }), z.object({ c: z.number() })])); expect(body(inclusive)).toMatchObject({ anyOf: [{ properties: { name: {}, v: {} } }, { properties: { name: {}, c: {} } }], }); }); test("input conversion has nothing to close", () => { expect(body(z.object({ a: z.string() }).and(z.object({ b: z.string() })), { io: "input" })).toEqual({ type: "object", properties: { a: { type: "string" }, b: { type: "string" } }, required: ["a", "b"], }); }); test("folds through a property and through z.lazy", () => { expect(body(z.object({ inner: z.object({ a: z.string() }).and(z.object({ b: z.string() })) }))).toMatchObject({ properties: { inner: { type: "object", properties: { a: {}, b: {} } } }, }); const self: z.ZodType = z.lazy(() => z.object({ a: z.string() }).and(z.object({ next: z.optional(self) }))); expect(body(self)).toMatchObject({ type: "object", properties: { a: {}, next: { $ref: "#" } } }); }); test("metadata on the intersection itself survives", () => { expect( body( z .object({ a: z.string() }) .and(z.object({ b: z.string() })) .meta({ title: "T" }) ) ).toMatchObject({ title: "T", type: "object", properties: { a: {}, b: {} }, }); }); test("a catchall constrains the sibling's keys too", () => { // The catchall member does not declare `b`, so its `additionalProperties` is what it demands of `b`. Folding `b` into `properties` has to carry that demand across, or the key would escape it. const schema = z .object({ a: z.string() }) .catchall(z.number()) .and(z.object({ b: z.string() })); expect(body(schema)).toEqual({ type: "object", properties: { a: { type: "string" }, b: { allOf: [{ type: "number" }, { type: "string" }] } }, required: ["a", "b"], additionalProperties: { type: "number" }, }); }); test("two catchalls both constrain the keys neither declares", () => { const schema = z .object({}) .catchall(z.number()) .and(z.object({}).catchall(z.number().min(0))); expect(body(schema)).toEqual({ type: "object", properties: {}, additionalProperties: { allOf: [{ type: "number" }, { type: "number", minimum: 0 }] }, }); expect(schema.safeParse({ zz: 5 }).success).toBe(true); expect(schema.safeParse({ zz: -1 }).success).toBe(false); }); test("the emitted openness agrees with the parser for every pair of object modes", () => { const modes = { strip: (shape: z.ZodRawShape) => z.object(shape), strict: (shape: z.ZodRawShape) => z.strictObject(shape), loose: (shape: z.ZodRawShape) => z.looseObject(shape), catchall: (shape: z.ZodRawShape) => z.object(shape).catchall(z.number()), }; const grid: Record = {}; for (const [leftName, left] of Object.entries(modes)) { for (const [rightName, right] of Object.entries(modes)) { const schema = left({ a: z.string() }).and(right({ b: z.number() })); const emitted = body(schema) as { additionalProperties?: unknown }; grid[`${leftName} & ${rightName}`] = emitted.additionalProperties ?? "open"; // Whatever the emitted keyword says, it has to agree with what the parser does with a key neither member declares. const parsed = schema.safeParse({ a: "s", b: 1, zz: 7 }); const keepsUnknown = parsed.success && Object.prototype.hasOwnProperty.call(parsed.data, "zz"); if (keepsUnknown) expect(emitted.additionalProperties).not.toBe(false); } } expect(grid).toMatchInlineSnapshot(` { "catchall & catchall": { "type": "number", }, "catchall & loose": { "type": "number", }, "catchall & strict": { "type": "number", }, "catchall & strip": { "type": "number", }, "loose & catchall": { "type": "number", }, "loose & loose": "open", "loose & strict": "open", "loose & strip": "open", "strict & catchall": { "type": "number", }, "strict & loose": "open", "strict & strict": false, "strict & strip": false, "strip & catchall": { "type": "number", }, "strip & loose": "open", "strip & strict": false, "strip & strip": false, } `); }); test("a __proto__ key stays an own property", () => { const folded = body(z.object({ ["__proto__"]: z.string() }).and(z.object({ b: z.number() }))) as any; expect(Object.prototype.hasOwnProperty.call(folded.properties, "__proto__")).toBe(true); expect(folded.required).toEqual(["__proto__", "b"]); }); }); describe("intersection folding declines", () => { // Every case here keeps the `allOf` it produces today. The fold only understands the four object keywords, so a member carrying anything else is left alone rather than having a constraint dropped or an annotation re-scoped. const allOf = (schema: z.ZodType, params?: Parameters[1]) => (z.toJSONSchema(schema, params) as any).allOf; test("a member that is a reference keeps its reference", () => { const named = z.object({ a: z.string() }).meta({ id: "Named" }); expect(allOf(named.and(z.object({ b: z.string() })))[0]).toEqual({ $ref: "#/$defs/Named" }); const shared = z.object({ c: z.string() }); const reused = z.toJSONSchema(z.object({ x: shared.and(z.object({ d: z.string() })), y: shared }), { reused: "ref", }) as any; expect(reused.properties.x.allOf[0]).toEqual({ $ref: "#/$defs/__schema0" }); const cyclic: any = z.object({ get next() { return z.optional(cyclic); }, n: z.string(), }); expect(allOf(cyclic.and(z.object({ m: z.string() })))[0].$ref).toBe("#/$defs/__schema0"); }); test("an annotated member keeps its place", () => { expect( allOf( z .object({ a: z.string() }) .describe("A") .and(z.object({ b: z.string() })) )[0] ).toMatchObject({ description: "A", }); }); test("members that are not plain objects are left alone", () => { expect(allOf(z.intersection(z.string().min(2), z.string().max(5)))).toHaveLength(2); expect(allOf(z.object({ a: z.string() }).and(z.nullable(z.object({ b: z.string() }))))).toHaveLength(2); expect( allOf(z.object({ label: z.string() }).and(z.looseRecord(z.string().regex(/^label:[a-z]{2}$/), z.string()))) ).toHaveLength(2); }); test("a shared member is never rewritten for its other uses", () => { const shared = z.object({ c: z.string() }); const result = z.toJSONSchema(z.object({ x: shared.and(z.object({ d: z.string() })), y: shared })) as any; expect(result.properties.x).toMatchObject({ properties: { c: {}, d: {} }, additionalProperties: false }); // `y` is the same Zod schema, emitted separately, and keeps the closedness it had on its own. expect(result.properties.y).toEqual({ type: "object", properties: { c: { type: "string" } }, required: ["c"], additionalProperties: false, }); }); test("registry conversion keeps its cross-references", () => { const registry = z.registry<{ id: string }>(); const left = z.object({ p: z.string() }); const right = z.object({ q: z.string() }); registry.add(left, { id: "P" }); registry.add(right, { id: "Q" }); registry.add(left.and(right), { id: "PQ" }); expect((z.toJSONSchema(registry) as any).schemas.PQ.allOf).toEqual([{ $ref: "P" }, { $ref: "Q" }]); }); test("override still runs, and sees the intersection before it folds", () => { const schema = z.object({ a: z.string() }).and(z.object({ b: z.string() })); const result = z.toJSONSchema(schema, { override(ctx) { if ((ctx.jsonSchema as any).allOf) (ctx.jsonSchema as any).title = "intersection"; }, }) as any; expect(result).toMatchObject({ title: "intersection", type: "object", properties: { a: {}, b: {} } }); }); test("an override that writes object keywords wins over the fold", () => { // The override runs first, so anything it puts on the intersection is deliberate. Folding would overwrite it silently, so the fold stands down instead. const result = z.toJSONSchema(z.object({ a: z.string() }).and(z.object({ b: z.string() })), { override(ctx) { if ((ctx.jsonSchema as any).allOf) (ctx.jsonSchema as any).additionalProperties = true; }, }) as any; expect(result.additionalProperties).toBe(true); expect(result.allOf).toHaveLength(2); }); test("more than one union member is left alone rather than multiplied out", () => { // The second union lands among the members the first is distributed across, and a union is not an object, so every branch fails to fold and the whole intersection stands down. const schema = z .object({ a: z.string() }) .and(z.union([z.object({ v: z.string() }), z.object({ w: z.string() })])) .and(z.union([z.object({ x: z.string() }), z.object({ y: z.string() })])); expect(allOf(schema)).toHaveLength(3); }); }); test("currency code", () => { expect(z.toJSONSchema(z.currencyCode())).toEqual({ $schema: "https://json-schema.org/draft/2020-12/schema", type: "string", format: "currency_code", pattern: z.regexes.currencyCode.source, }); });