├── .prettierrc.json ├── .github ├── FUNDING.yml └── CR_logotype-full-color.png ├── .npmignore ├── .gitignore ├── postcjs.ts ├── postesm.ts ├── dist-test-v3 ├── package.json ├── types │ ├── tsconfig.json │ ├── package.json │ ├── index.ts │ └── package-lock.json ├── cjs │ ├── package.json │ ├── index.js │ └── package-lock.json └── esm │ ├── package.json │ ├── index.js │ └── package-lock.json ├── dist-test-v4 ├── package.json ├── types │ ├── tsconfig.json │ ├── package.json │ ├── index.ts │ └── package-lock.json ├── cjs │ ├── package.json │ ├── index.js │ └── package-lock.json └── esm │ ├── package.json │ ├── index.js │ └── package-lock.json ├── tsconfig.json ├── src ├── parsers │ ├── boolean.ts │ ├── catch.ts │ ├── branded.ts │ ├── readonly.ts │ ├── unknown.ts │ ├── enum.ts │ ├── undefined.ts │ ├── promise.ts │ ├── null.ts │ ├── default.ts │ ├── effects.ts │ ├── never.ts │ ├── any.ts │ ├── optional.ts │ ├── nativeEnum.ts │ ├── pipeline.ts │ ├── literal.ts │ ├── set.ts │ ├── map.ts │ ├── array.ts │ ├── tuple.ts │ ├── nullable.ts │ ├── date.ts │ ├── intersection.ts │ ├── object.ts │ ├── bigint.ts │ ├── number.ts │ ├── record.ts │ ├── union.ts │ └── string.ts ├── getRelativePath.ts ├── errorMessages.ts ├── Refs.ts ├── index.ts ├── parseTypes.ts ├── parseDef.ts ├── Options.ts ├── zodToJsonSchema.ts └── selectParser.ts ├── tsconfig.cjs.json ├── tsconfig.types.json ├── tsconfig.esm.json ├── test ├── parsers │ ├── errorReferences.ts │ ├── catch.test.ts │ ├── branded.test.ts │ ├── promise.test.ts │ ├── readonly.test.ts │ ├── pipe.test.ts │ ├── tuple.test.ts │ ├── effects.test.ts │ ├── default.test.ts │ ├── map.test.ts │ ├── nullable.test.ts │ ├── set.test.ts │ ├── bigint.test.ts │ ├── nativeEnum.test.ts │ ├── record.test.ts │ ├── optional.test.ts │ ├── date.test.ts │ ├── object.test.ts │ ├── array.test.ts │ ├── number.test.ts │ ├── union.test.ts │ └── intersection.test.ts ├── createIndex.ts ├── index.ts ├── openAiMode.test.ts ├── readme.test.ts ├── meta.test.ts ├── allParsersSchema.ts ├── zodToJsonSchema.test.ts ├── override.test.ts ├── suite.ts ├── parseDef.test.ts ├── issues.test.ts └── openApiMode.test.ts ├── contributing.md ├── LICENSE ├── createIndex.ts └── package.json /.prettierrc.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [StefanTerdell] 2 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | src 2 | tsconfig* 3 | test 4 | coverage 5 | dist-test 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | coverage 4 | lab.ts 5 | .idea 6 | -------------------------------------------------------------------------------- /postcjs.ts: -------------------------------------------------------------------------------- 1 | import { writeFileSync } from "fs"; 2 | 3 | writeFileSync("./dist/cjs/package.json", '{"type":"commonjs"}', "utf-8"); 4 | -------------------------------------------------------------------------------- /.github/CR_logotype-full-color.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StefanTerdell/zod-to-json-schema/HEAD/.github/CR_logotype-full-color.png -------------------------------------------------------------------------------- /postesm.ts: -------------------------------------------------------------------------------- 1 | import { writeFileSync } from "fs"; 2 | 3 | writeFileSync("./dist/esm/package.json", '{"type":"module","main":"index.js"}', "utf-8"); 4 | -------------------------------------------------------------------------------- /dist-test-v3/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "scripts": { 3 | "test": "npm --prefix ./cjs test && npm --prefix ./esm test && npm --prefix ./types test" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /dist-test-v4/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "scripts": { 3 | "test": "npm --prefix ./cjs test && npm --prefix ./esm test && npm --prefix ./types test" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /dist-test-v3/types/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "strict": true, 4 | "skipLibCheck": true, 5 | "esModuleInterop": true 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /dist-test-v4/types/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "strict": true, 4 | "skipLibCheck": true, 5 | "esModuleInterop": true 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "strict": true, 4 | "noEmit": true, 5 | "skipLibCheck": true, 6 | "esModuleInterop": true 7 | }, 8 | "include": ["src", "test"] 9 | } 10 | -------------------------------------------------------------------------------- /dist-test-v3/types/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "dependencies": { 3 | "zod": "^3.25", 4 | "zod-to-json-schema": "file://../../" 5 | }, 6 | "scripts": { 7 | "test": "npm i && tsc --noEmit" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /dist-test-v4/types/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "dependencies": { 3 | "zod": "^4", 4 | "zod-to-json-schema": "file://../../" 5 | }, 6 | "scripts": { 7 | "test": "npm i && tsc --noEmit" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /dist-test-v4/cjs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "dependencies": { 3 | "zod": "^4", 4 | "zod-to-json-schema": "file://../../dist/cjs/" 5 | }, 6 | "scripts": { 7 | "test": "npm i && node index.js" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /dist-test-v3/cjs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "dependencies": { 3 | "zod": "^3.25", 4 | "zod-to-json-schema": "file://../../dist/cjs/" 5 | }, 6 | "scripts": { 7 | "test": "npm i && node index.js" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/parsers/boolean.ts: -------------------------------------------------------------------------------- 1 | export type JsonSchema7BooleanType = { 2 | type: "boolean"; 3 | }; 4 | 5 | export function parseBooleanDef(): JsonSchema7BooleanType { 6 | return { 7 | type: "boolean", 8 | }; 9 | } 10 | -------------------------------------------------------------------------------- /tsconfig.cjs.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2020", 4 | "module": "commonjs", 5 | "outDir": "dist/cjs", 6 | "strict": true, 7 | "skipLibCheck": true, 8 | "esModuleInterop": true 9 | }, 10 | "include": ["src"] 11 | } 12 | -------------------------------------------------------------------------------- /dist-test-v3/esm/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "module", 3 | "main": "index.js", 4 | "dependencies": { 5 | "zod": "^3.25", 6 | "zod-to-json-schema": "file://../../dist/esm/" 7 | }, 8 | "scripts": { 9 | "test": "npm i && node index.js" 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /dist-test-v4/esm/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "module", 3 | "main": "index.js", 4 | "dependencies": { 5 | "zod": "^4", 6 | "zod-to-json-schema": "file://../../dist/esm/" 7 | }, 8 | "scripts": { 9 | "test": "npm i && node index.js" 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/parsers/catch.ts: -------------------------------------------------------------------------------- 1 | import { ZodCatchDef } from "zod/v3"; 2 | import { parseDef } from "../parseDef.js"; 3 | import { Refs } from "../Refs.js"; 4 | 5 | export const parseCatchDef = (def: ZodCatchDef, refs: Refs) => { 6 | return parseDef(def.innerType._def, refs); 7 | }; 8 | -------------------------------------------------------------------------------- /src/parsers/branded.ts: -------------------------------------------------------------------------------- 1 | import { ZodBrandedDef } from "zod/v3"; 2 | import { parseDef } from "../parseDef.js"; 3 | import { Refs } from "../Refs.js"; 4 | 5 | export function parseBrandedDef(_def: ZodBrandedDef, refs: Refs) { 6 | return parseDef(_def.type._def, refs); 7 | } 8 | -------------------------------------------------------------------------------- /tsconfig.types.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "declaration": true, 4 | "emitDeclarationOnly": true, 5 | "outDir": "dist/types", 6 | "strict": true, 7 | "skipLibCheck": true, 8 | "esModuleInterop": true 9 | }, 10 | "include": ["src"] 11 | } 12 | -------------------------------------------------------------------------------- /src/parsers/readonly.ts: -------------------------------------------------------------------------------- 1 | import { ZodReadonlyDef } from "zod/v3"; 2 | import { parseDef } from "../parseDef.js"; 3 | import { Refs } from "../Refs.js"; 4 | 5 | export const parseReadonlyDef = (def: ZodReadonlyDef, refs: Refs) => { 6 | return parseDef(def.innerType._def, refs); 7 | }; 8 | -------------------------------------------------------------------------------- /src/parsers/unknown.ts: -------------------------------------------------------------------------------- 1 | import { Refs } from "../Refs"; 2 | import { JsonSchema7AnyType, parseAnyDef } from "./any.js"; 3 | 4 | export type JsonSchema7UnknownType = JsonSchema7AnyType; 5 | 6 | export function parseUnknownDef(refs: Refs): JsonSchema7UnknownType { 7 | return parseAnyDef(refs); 8 | } 9 | -------------------------------------------------------------------------------- /dist-test-v3/esm/index.js: -------------------------------------------------------------------------------- 1 | import z from "zod" 2 | import { zodToJsonSchema } from "zod-to-json-schema" 3 | 4 | const result = zodToJsonSchema(z.string()); 5 | 6 | z 7 | .object({ 8 | type: z.literal("string"), 9 | $schema: z.string().url(), 10 | }) 11 | .strict() 12 | .parse(result); 13 | -------------------------------------------------------------------------------- /tsconfig.esm.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2020", 4 | "module": "es2020", 5 | "moduleResolution": "nodenext", 6 | "outDir": "dist/esm", 7 | "strict": true, 8 | "skipLibCheck": true, 9 | "esModuleInterop": true 10 | }, 11 | "include": ["src"] 12 | } 13 | -------------------------------------------------------------------------------- /dist-test-v4/esm/index.js: -------------------------------------------------------------------------------- 1 | import z from "zod/v3" 2 | import { zodToJsonSchema } from "zod-to-json-schema" 3 | 4 | const result = zodToJsonSchema(z.string()); 5 | 6 | z 7 | .object({ 8 | type: z.literal("string"), 9 | $schema: z.string().url(), 10 | }) 11 | .strict() 12 | .parse(result); 13 | -------------------------------------------------------------------------------- /dist-test-v3/cjs/index.js: -------------------------------------------------------------------------------- 1 | const z = require("zod"); 2 | const { zodToJsonSchema } = require("zod-to-json-schema"); 3 | 4 | const result = zodToJsonSchema(z.string()); 5 | 6 | z 7 | .object({ 8 | type: z.literal("string"), 9 | $schema: z.string().url(), 10 | }) 11 | .strict() 12 | .parse(result); 13 | -------------------------------------------------------------------------------- /src/getRelativePath.ts: -------------------------------------------------------------------------------- 1 | 2 | export const getRelativePath = (pathA: string[], pathB: string[]) => { 3 | let i = 0; 4 | for (; i < pathA.length && i < pathB.length; i++) { 5 | if (pathA[i] !== pathB[i]) break; 6 | } 7 | return [(pathA.length - i).toString(), ...pathB.slice(i)].join("/"); 8 | }; 9 | 10 | -------------------------------------------------------------------------------- /dist-test-v4/cjs/index.js: -------------------------------------------------------------------------------- 1 | const z = require("zod/v3"); 2 | const { zodToJsonSchema } = require("zod-to-json-schema"); 3 | 4 | const result = zodToJsonSchema(z.string()); 5 | 6 | z 7 | .object({ 8 | type: z.literal("string"), 9 | $schema: z.string().url(), 10 | }) 11 | .strict() 12 | .parse(result); 13 | -------------------------------------------------------------------------------- /src/parsers/enum.ts: -------------------------------------------------------------------------------- 1 | import { ZodEnumDef } from "zod/v3"; 2 | 3 | export type JsonSchema7EnumType = { 4 | type: "string"; 5 | enum: string[]; 6 | }; 7 | 8 | export function parseEnumDef(def: ZodEnumDef): JsonSchema7EnumType { 9 | return { 10 | type: "string", 11 | enum: Array.from(def.values), 12 | }; 13 | } 14 | -------------------------------------------------------------------------------- /test/parsers/errorReferences.ts: -------------------------------------------------------------------------------- 1 | import { Options, Targets } from "../../src/Options.js"; 2 | import { getRefs, Refs } from "../../src/Refs.js"; 3 | 4 | export function errorReferences( 5 | options?: string | Partial>, 6 | ): Refs { 7 | const r = getRefs(options); 8 | r.errorMessages = true; 9 | return r; 10 | } 11 | -------------------------------------------------------------------------------- /src/parsers/undefined.ts: -------------------------------------------------------------------------------- 1 | import { Refs } from "../Refs.js"; 2 | import { JsonSchema7AnyType, parseAnyDef } from "./any.js"; 3 | 4 | export type JsonSchema7UndefinedType = { 5 | not: JsonSchema7AnyType; 6 | }; 7 | 8 | export function parseUndefinedDef(refs: Refs): JsonSchema7UndefinedType { 9 | return { 10 | not: parseAnyDef(refs), 11 | }; 12 | } 13 | -------------------------------------------------------------------------------- /src/parsers/promise.ts: -------------------------------------------------------------------------------- 1 | import { ZodPromiseDef } from "zod/v3"; 2 | import { parseDef } from "../parseDef.js"; 3 | import { JsonSchema7Type } from "../parseTypes.js"; 4 | import { Refs } from "../Refs.js"; 5 | 6 | export function parsePromiseDef( 7 | def: ZodPromiseDef, 8 | refs: Refs, 9 | ): JsonSchema7Type | undefined { 10 | return parseDef(def.type._def, refs); 11 | } 12 | -------------------------------------------------------------------------------- /src/parsers/null.ts: -------------------------------------------------------------------------------- 1 | import { Refs } from "../Refs.js"; 2 | 3 | export type JsonSchema7NullType = { 4 | type: "null"; 5 | }; 6 | 7 | export function parseNullDef(refs: Refs): JsonSchema7NullType { 8 | return refs.target === "openApi3" 9 | ? ({ 10 | enum: ["null"], 11 | nullable: true, 12 | } as any) 13 | : { 14 | type: "null", 15 | }; 16 | } 17 | -------------------------------------------------------------------------------- /contributing.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Hey, thanks for wanting to contribute. 4 | 5 | Before you open a PR, make sure to open an issue and discuss the problem you want to solve. I will not consider PRs without issues. 6 | 7 | I use [gitmoji](https://gitmoji.dev/) for my commit messages because I think it's fun. I encourage you to do the same, but won't enforce it. 8 | 9 | I check PRs and issues very rarely so please be patient. 10 | -------------------------------------------------------------------------------- /src/parsers/default.ts: -------------------------------------------------------------------------------- 1 | import { ZodDefaultDef } from "zod/v3"; 2 | import { parseDef } from "../parseDef.js"; 3 | import { JsonSchema7Type } from "../parseTypes.js"; 4 | import { Refs } from "../Refs.js"; 5 | 6 | export function parseDefaultDef( 7 | _def: ZodDefaultDef, 8 | refs: Refs, 9 | ): JsonSchema7Type & { default: any } { 10 | return { 11 | ...parseDef(_def.innerType._def, refs), 12 | default: _def.defaultValue(), 13 | }; 14 | } 15 | -------------------------------------------------------------------------------- /src/parsers/effects.ts: -------------------------------------------------------------------------------- 1 | import { ZodEffectsDef } from "zod/v3"; 2 | import { parseDef } from "../parseDef.js"; 3 | import { JsonSchema7Type } from "../parseTypes.js"; 4 | import { Refs } from "../Refs.js"; 5 | import { parseAnyDef } from "./any.js"; 6 | 7 | export function parseEffectsDef( 8 | _def: ZodEffectsDef, 9 | refs: Refs, 10 | ): JsonSchema7Type | undefined { 11 | return refs.effectStrategy === "input" 12 | ? parseDef(_def.schema._def, refs) 13 | : parseAnyDef(refs); 14 | } 15 | -------------------------------------------------------------------------------- /src/parsers/never.ts: -------------------------------------------------------------------------------- 1 | import { Refs } from "../Refs.js"; 2 | import { JsonSchema7AnyType, parseAnyDef } from "./any.js"; 3 | 4 | export type JsonSchema7NeverType = { 5 | not: JsonSchema7AnyType; 6 | }; 7 | 8 | export function parseNeverDef(refs: Refs): JsonSchema7NeverType | undefined { 9 | return refs.target === "openAi" 10 | ? undefined 11 | : { 12 | not: parseAnyDef({ 13 | ...refs, 14 | currentPath: [...refs.currentPath, "not"], 15 | }), 16 | }; 17 | } 18 | -------------------------------------------------------------------------------- /dist-test-v3/types/index.ts: -------------------------------------------------------------------------------- 1 | import { zodToJsonSchema } from "zod-to-json-schema"; 2 | 3 | type Check = A extends B ? true : false; 4 | 5 | export const isFunction: Check = true; 6 | export const isNotArray: Check = false; 7 | 8 | export function $schemaIsString(schema: ReturnType) { 9 | if ("$schema" in schema && schema.$schema) { 10 | schema.$schema.toLowerCase(); 11 | // @ts-expect-error 12 | schema.$schema * 2; 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /dist-test-v4/types/index.ts: -------------------------------------------------------------------------------- 1 | import { zodToJsonSchema } from "zod-to-json-schema"; 2 | 3 | type Check = A extends B ? true : false; 4 | 5 | export const isFunction: Check = true; 6 | export const isNotArray: Check = false; 7 | 8 | export function $schemaIsString(schema: ReturnType) { 9 | if ("$schema" in schema && schema.$schema) { 10 | schema.$schema.toLowerCase(); 11 | // @ts-expect-error 12 | schema.$schema * 2; 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /test/parsers/catch.test.ts: -------------------------------------------------------------------------------- 1 | import { JSONSchema7Type } from "json-schema"; 2 | import { parseCatchDef } from "../../src/parsers/catch.js"; 3 | import { z } from "zod/v3"; 4 | import { getRefs } from "../../src/Refs.js"; 5 | import { suite } from "../suite.js"; 6 | suite("catch", (test) => { 7 | test("should be possible to use catch", (assert) => { 8 | const parsedSchema = parseCatchDef(z.number().catch(5)._def, getRefs()); 9 | const jsonSchema: JSONSchema7Type = { 10 | type: "number", 11 | }; 12 | assert(parsedSchema, jsonSchema); 13 | }); 14 | }); 15 | -------------------------------------------------------------------------------- /test/parsers/branded.test.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod/v3"; 2 | import { parseBrandedDef } from "../../src/parsers/branded.js"; 3 | import { getRefs } from "../../src/Refs.js"; 4 | import { suite } from "../suite.js"; 5 | 6 | suite("objects", (test) => { 7 | test("should be possible to use branded string", (assert) => { 8 | const schema = z.string().brand<"x">(); 9 | const parsedSchema = parseBrandedDef(schema._def, getRefs()); 10 | 11 | const expectedSchema = { 12 | type: "string", 13 | }; 14 | assert(parsedSchema, expectedSchema); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /test/parsers/promise.test.ts: -------------------------------------------------------------------------------- 1 | import { JSONSchema7Type } from "json-schema"; 2 | import { z } from "zod/v3"; 3 | import { parsePromiseDef } from "../../src/parsers/promise.js"; 4 | import { getRefs } from "../../src/Refs.js"; 5 | import { suite } from "../suite.js"; 6 | 7 | suite("promise", (test) => { 8 | test("should be possible to use promise", (assert) => { 9 | const parsedSchema = parsePromiseDef(z.promise(z.string())._def, getRefs()); 10 | const jsonSchema: JSONSchema7Type = { 11 | type: "string", 12 | }; 13 | assert(parsedSchema, jsonSchema); 14 | }); 15 | }); 16 | -------------------------------------------------------------------------------- /test/createIndex.ts: -------------------------------------------------------------------------------- 1 | import { readdirSync, writeFileSync, statSync } from "fs"; 2 | 3 | function checkDir(dir: string): string[] { 4 | return readdirSync(dir).reduce((a: string[], n) => { 5 | const f = `${dir}/${n}`; 6 | 7 | const s = statSync(f); 8 | 9 | if (s.isFile() && n.endsWith(".test.ts")) { 10 | a.push(f); 11 | } 12 | 13 | if (s.isDirectory()) { 14 | a.push(...checkDir(f)); 15 | } 16 | 17 | return a; 18 | }, []); 19 | } 20 | 21 | writeFileSync( 22 | "./test/index.ts", 23 | checkDir("./test") 24 | .map((f) => `import "./${f.slice(7, -3)}.js"`) 25 | .join("\n"), 26 | "utf-8", 27 | ); 28 | -------------------------------------------------------------------------------- /test/parsers/readonly.test.ts: -------------------------------------------------------------------------------- 1 | import { JSONSchema7Type } from "json-schema"; 2 | import { parseReadonlyDef } from "../../src/parsers/readonly.js"; 3 | import { z } from "zod/v3"; 4 | import { getRefs } from "../../src/Refs.js"; 5 | import { suite } from "../suite.js"; 6 | suite("readonly", (test) => { 7 | test("should be possible to use readonly", (assert) => { 8 | const parsedSchema = parseReadonlyDef( 9 | z.object({}).readonly()._def, 10 | getRefs(), 11 | ); 12 | const jsonSchema: JSONSchema7Type = { 13 | type: "object", 14 | properties: {}, 15 | additionalProperties: false, 16 | }; 17 | assert(parsedSchema, jsonSchema); 18 | }); 19 | }); 20 | -------------------------------------------------------------------------------- /src/parsers/any.ts: -------------------------------------------------------------------------------- 1 | import { Refs } from "../Refs.js"; 2 | import { getRelativePath } from "../getRelativePath.js"; 3 | 4 | export type JsonSchema7AnyType = { $ref?: string }; 5 | 6 | export function parseAnyDef(refs: Refs): JsonSchema7AnyType { 7 | if (refs.target !== "openAi") { 8 | return {}; 9 | } 10 | 11 | const anyDefinitionPath = [ 12 | ...refs.basePath, 13 | refs.definitionPath, 14 | refs.openAiAnyTypeName, 15 | ]; 16 | 17 | refs.flags.hasReferencedOpenAiAnyType = true; 18 | 19 | return { 20 | $ref: 21 | refs.$refStrategy === "relative" 22 | ? getRelativePath(anyDefinitionPath, refs.currentPath) 23 | : anyDefinitionPath.join("/"), 24 | }; 25 | } 26 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | ISC License 2 | 3 | Copyright (c) 2020, Stefan Terdell 4 | 5 | Permission to use, copy, modify, and/or distribute this software for any 6 | purpose with or without fee is hereby granted, provided that the above 7 | copyright notice and this permission notice appear in all copies. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 10 | WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 11 | MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 12 | ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 13 | WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 14 | ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 15 | OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. -------------------------------------------------------------------------------- /createIndex.ts: -------------------------------------------------------------------------------- 1 | import { readdirSync, writeFileSync, statSync } from "fs"; 2 | 3 | const ignore = ["src/index.ts"]; 4 | 5 | function checkSrcDir(path: string): string[] { 6 | const lines: string[] = []; 7 | 8 | for (const item of readdirSync(path)) { 9 | const itemPath = path + "/" + item; 10 | 11 | if (ignore.includes(itemPath)) { 12 | continue; 13 | } 14 | 15 | if (statSync(itemPath).isDirectory()) { 16 | lines.push(...checkSrcDir(itemPath)); 17 | } else if (item.endsWith(".ts")) { 18 | lines.push('export * from "./' + itemPath.slice(4, -2) + 'js"'); 19 | } 20 | } 21 | 22 | return lines; 23 | } 24 | 25 | const lines = checkSrcDir("src"); 26 | 27 | lines.push( 28 | 'import { zodToJsonSchema } from "./zodToJsonSchema.js"', 29 | "export default zodToJsonSchema;", 30 | ); 31 | 32 | writeFileSync("./src/index.ts", lines.join(";\n")); 33 | -------------------------------------------------------------------------------- /src/parsers/optional.ts: -------------------------------------------------------------------------------- 1 | import { ZodOptionalDef } from "zod/v3"; 2 | import { parseDef } from "../parseDef.js"; 3 | import { JsonSchema7Type } from "../parseTypes.js"; 4 | import { Refs } from "../Refs.js"; 5 | import { parseAnyDef } from "./any.js"; 6 | 7 | export const parseOptionalDef = ( 8 | def: ZodOptionalDef, 9 | refs: Refs, 10 | ): JsonSchema7Type | undefined => { 11 | if (refs.currentPath.toString() === refs.propertyPath?.toString()) { 12 | return parseDef(def.innerType._def, refs); 13 | } 14 | 15 | const innerSchema = parseDef(def.innerType._def, { 16 | ...refs, 17 | currentPath: [...refs.currentPath, "anyOf", "1"], 18 | }); 19 | 20 | return innerSchema 21 | ? { 22 | anyOf: [ 23 | { 24 | not: parseAnyDef(refs), 25 | }, 26 | innerSchema, 27 | ], 28 | } 29 | : parseAnyDef(refs); 30 | }; 31 | -------------------------------------------------------------------------------- /dist-test-v4/cjs/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cjs", 3 | "lockfileVersion": 3, 4 | "requires": true, 5 | "packages": { 6 | "": { 7 | "dependencies": { 8 | "zod": "^4", 9 | "zod-to-json-schema": "file://../../dist/cjs/" 10 | } 11 | }, 12 | "../../dist/cjs": {}, 13 | "../../dist/esm": { 14 | "extraneous": true 15 | }, 16 | "node_modules/zod": { 17 | "version": "4.1.12", 18 | "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.12.tgz", 19 | "integrity": "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==", 20 | "license": "MIT", 21 | "funding": { 22 | "url": "https://github.com/sponsors/colinhacks" 23 | } 24 | }, 25 | "node_modules/zod-to-json-schema": { 26 | "resolved": "../../dist/cjs", 27 | "link": true 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /dist-test-v3/cjs/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cjs", 3 | "lockfileVersion": 3, 4 | "requires": true, 5 | "packages": { 6 | "": { 7 | "dependencies": { 8 | "zod": "^3.25", 9 | "zod-to-json-schema": "file://../../dist/cjs/" 10 | } 11 | }, 12 | "../../dist/cjs": {}, 13 | "../../dist/esm": { 14 | "extraneous": true 15 | }, 16 | "node_modules/zod": { 17 | "version": "3.25.76", 18 | "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", 19 | "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", 20 | "license": "MIT", 21 | "funding": { 22 | "url": "https://github.com/sponsors/colinhacks" 23 | } 24 | }, 25 | "node_modules/zod-to-json-schema": { 26 | "resolved": "../../dist/cjs", 27 | "link": true 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/parsers/nativeEnum.ts: -------------------------------------------------------------------------------- 1 | import { ZodNativeEnumDef } from "zod/v3"; 2 | 3 | export type JsonSchema7NativeEnumType = { 4 | type: "string" | "number" | ["string", "number"]; 5 | enum: (string | number)[]; 6 | }; 7 | 8 | export function parseNativeEnumDef( 9 | def: ZodNativeEnumDef, 10 | ): JsonSchema7NativeEnumType { 11 | const object = def.values; 12 | const actualKeys = Object.keys(def.values).filter((key: string) => { 13 | return typeof object[object[key]] !== "number"; 14 | }); 15 | 16 | const actualValues = actualKeys.map((key: string) => object[key]); 17 | 18 | const parsedTypes = Array.from( 19 | new Set(actualValues.map((values: string | number) => typeof values)), 20 | ); 21 | 22 | return { 23 | type: 24 | parsedTypes.length === 1 25 | ? parsedTypes[0] === "string" 26 | ? "string" 27 | : "number" 28 | : ["string", "number"], 29 | enum: actualValues, 30 | }; 31 | } 32 | -------------------------------------------------------------------------------- /dist-test-v4/esm/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "esm", 3 | "lockfileVersion": 3, 4 | "requires": true, 5 | "packages": { 6 | "": { 7 | "dependencies": { 8 | "zod": "^4", 9 | "zod-to-json-schema": "file://../../dist/esm/" 10 | } 11 | }, 12 | "../../dist/cjs": { 13 | "extraneous": true 14 | }, 15 | "../../dist/cjs/package.json": { 16 | "extraneous": true 17 | }, 18 | "../../dist/esm": {}, 19 | "node_modules/zod": { 20 | "version": "4.1.12", 21 | "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.12.tgz", 22 | "integrity": "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==", 23 | "license": "MIT", 24 | "funding": { 25 | "url": "https://github.com/sponsors/colinhacks" 26 | } 27 | }, 28 | "node_modules/zod-to-json-schema": { 29 | "resolved": "../../dist/esm", 30 | "link": true 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /dist-test-v3/esm/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "esm", 3 | "lockfileVersion": 3, 4 | "requires": true, 5 | "packages": { 6 | "": { 7 | "dependencies": { 8 | "zod": "^3.25", 9 | "zod-to-json-schema": "file://../../dist/esm/" 10 | } 11 | }, 12 | "../../dist/cjs": { 13 | "extraneous": true 14 | }, 15 | "../../dist/cjs/package.json": { 16 | "extraneous": true 17 | }, 18 | "../../dist/esm": {}, 19 | "node_modules/zod": { 20 | "version": "3.25.76", 21 | "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", 22 | "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", 23 | "license": "MIT", 24 | "funding": { 25 | "url": "https://github.com/sponsors/colinhacks" 26 | } 27 | }, 28 | "node_modules/zod-to-json-schema": { 29 | "resolved": "../../dist/esm", 30 | "link": true 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/parsers/pipeline.ts: -------------------------------------------------------------------------------- 1 | import { ZodPipelineDef } from "zod/v3"; 2 | import { parseDef } from "../parseDef.js"; 3 | import { JsonSchema7Type } from "../parseTypes.js"; 4 | import { Refs } from "../Refs.js"; 5 | import { JsonSchema7AllOfType } from "./intersection.js"; 6 | 7 | export const parsePipelineDef = ( 8 | def: ZodPipelineDef, 9 | refs: Refs, 10 | ): JsonSchema7AllOfType | JsonSchema7Type | undefined => { 11 | if (refs.pipeStrategy === "input") { 12 | return parseDef(def.in._def, refs); 13 | } else if (refs.pipeStrategy === "output") { 14 | return parseDef(def.out._def, refs); 15 | } 16 | 17 | const a = parseDef(def.in._def, { 18 | ...refs, 19 | currentPath: [...refs.currentPath, "allOf", "0"], 20 | }); 21 | const b = parseDef(def.out._def, { 22 | ...refs, 23 | currentPath: [...refs.currentPath, "allOf", a ? "1" : "0"], 24 | }); 25 | 26 | return { 27 | allOf: [a, b].filter((x): x is JsonSchema7Type => x !== undefined), 28 | }; 29 | }; 30 | -------------------------------------------------------------------------------- /src/parsers/literal.ts: -------------------------------------------------------------------------------- 1 | import { ZodLiteralDef } from "zod/v3"; 2 | import { Refs } from "../Refs.js"; 3 | 4 | export type JsonSchema7LiteralType = 5 | | { 6 | type: "string" | "number" | "integer" | "boolean"; 7 | const: string | number | boolean; 8 | } 9 | | { 10 | type: "object" | "array"; 11 | }; 12 | 13 | export function parseLiteralDef( 14 | def: ZodLiteralDef, 15 | refs: Refs, 16 | ): JsonSchema7LiteralType { 17 | const parsedType = typeof def.value; 18 | if ( 19 | parsedType !== "bigint" && 20 | parsedType !== "number" && 21 | parsedType !== "boolean" && 22 | parsedType !== "string" 23 | ) { 24 | return { 25 | type: Array.isArray(def.value) ? "array" : "object", 26 | }; 27 | } 28 | 29 | if (refs.target === "openApi3") { 30 | return { 31 | type: parsedType === "bigint" ? "integer" : parsedType, 32 | enum: [def.value], 33 | } as any; 34 | } 35 | 36 | return { 37 | type: parsedType === "bigint" ? "integer" : parsedType, 38 | const: def.value, 39 | }; 40 | } 41 | -------------------------------------------------------------------------------- /test/parsers/pipe.test.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod/v3"; 2 | import { parsePipelineDef } from "../../src/parsers/pipeline.js"; 3 | import { getRefs } from "../../src/Refs.js"; 4 | import { suite } from "../suite.js"; 5 | 6 | suite("pipe", (test) => { 7 | test("Should create an allOf schema with all its inner schemas represented", (assert) => { 8 | const schema = z.number().pipe(z.number().int()); 9 | 10 | assert(parsePipelineDef(schema._def, getRefs()), { 11 | allOf: [{ type: "number" }, { type: "integer" }], 12 | }); 13 | }); 14 | 15 | test("Should parse the input schema if that strategy is selected", (assert) => { 16 | const schema = z.number().pipe(z.number().int()); 17 | 18 | assert(parsePipelineDef(schema._def, getRefs({ pipeStrategy: "input" })), { 19 | type: "number", 20 | }); 21 | }); 22 | 23 | test("Should parse the output schema (last schema in pipe) if that strategy is selected", (assert) => { 24 | const schema = z.string().pipe(z.date()).pipe(z.number().int()); 25 | 26 | assert(parsePipelineDef(schema._def, getRefs({ pipeStrategy: "output" })), { 27 | type: "integer", 28 | }); 29 | }); 30 | }); 31 | -------------------------------------------------------------------------------- /src/errorMessages.ts: -------------------------------------------------------------------------------- 1 | import { JsonSchema7TypeUnion } from "./parseTypes.js"; 2 | import { Refs } from "./Refs.js"; 3 | 4 | export type ErrorMessages< 5 | T extends JsonSchema7TypeUnion | { format: string } | { pattern: string }, 6 | OmitProperties extends string = "", 7 | > = Partial< 8 | Omit<{ [key in keyof T]: string }, OmitProperties | "type" | "errorMessages"> 9 | >; 10 | 11 | export function addErrorMessage< 12 | T extends { errorMessage?: ErrorMessages }, 13 | >(res: T, key: keyof T, errorMessage: string | undefined, refs: Refs) { 14 | if (!refs?.errorMessages) return; 15 | if (errorMessage) { 16 | res.errorMessage = { 17 | ...res.errorMessage, 18 | [key]: errorMessage, 19 | }; 20 | } 21 | } 22 | 23 | export function setResponseValueAndErrors< 24 | Json7Type extends JsonSchema7TypeUnion & { 25 | errorMessage?: ErrorMessages; 26 | }, 27 | Key extends keyof Omit, 28 | >( 29 | res: Json7Type, 30 | key: Key, 31 | value: Json7Type[Key], 32 | errorMessage: string | undefined, 33 | refs: Refs, 34 | ) { 35 | res[key] = value; 36 | addErrorMessage(res, key, errorMessage, refs); 37 | } 38 | -------------------------------------------------------------------------------- /test/index.ts: -------------------------------------------------------------------------------- 1 | import "./allParsers.test.js"; 2 | import "./issues.test.js"; 3 | import "./meta.test.js"; 4 | import "./openAiMode.test.js"; 5 | import "./openApiMode.test.js"; 6 | import "./override.test.js"; 7 | import "./parseDef.test.js"; 8 | import "./parsers/array.test.js"; 9 | import "./parsers/bigint.test.js"; 10 | import "./parsers/branded.test.js"; 11 | import "./parsers/catch.test.js"; 12 | import "./parsers/date.test.js"; 13 | import "./parsers/default.test.js"; 14 | import "./parsers/effects.test.js"; 15 | import "./parsers/intersection.test.js"; 16 | import "./parsers/map.test.js"; 17 | import "./parsers/nativeEnum.test.js"; 18 | import "./parsers/nullable.test.js"; 19 | import "./parsers/number.test.js"; 20 | import "./parsers/object.test.js"; 21 | import "./parsers/optional.test.js"; 22 | import "./parsers/pipe.test.js"; 23 | import "./parsers/promise.test.js"; 24 | import "./parsers/readonly.test.js"; 25 | import "./parsers/record.test.js"; 26 | import "./parsers/set.test.js"; 27 | import "./parsers/string.test.js"; 28 | import "./parsers/tuple.test.js"; 29 | import "./parsers/union.test.js"; 30 | import "./readme.test.js"; 31 | import "./references.test.js"; 32 | import "./zodToJsonSchema.test.js"; 33 | -------------------------------------------------------------------------------- /test/parsers/tuple.test.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod/v3"; 2 | import { parseTupleDef } from "../../src/parsers/tuple.js"; 3 | import { getRefs } from "../../src/Refs.js"; 4 | import { suite } from "../suite.js"; 5 | 6 | suite("objects", (test) => { 7 | test("should be possible to describe a simple tuple schema", (assert) => { 8 | const schema = z.tuple([z.string(), z.number()]); 9 | 10 | const parsedSchema = parseTupleDef(schema._def, getRefs()); 11 | const expectedSchema = { 12 | type: "array", 13 | items: [{ type: "string" }, { type: "number" }], 14 | minItems: 2, 15 | maxItems: 2, 16 | }; 17 | assert(parsedSchema, expectedSchema); 18 | }); 19 | 20 | test("should be possible to describe a tuple schema with rest()", (assert) => { 21 | const schema = z.tuple([z.string(), z.number()]).rest(z.boolean()); 22 | 23 | const parsedSchema = parseTupleDef(schema._def, getRefs()); 24 | const expectedSchema = { 25 | type: "array", 26 | items: [{ type: "string" }, { type: "number" }], 27 | minItems: 2, 28 | additionalItems: { 29 | type: "boolean", 30 | }, 31 | }; 32 | assert(parsedSchema, expectedSchema); 33 | }); 34 | }); 35 | -------------------------------------------------------------------------------- /test/parsers/effects.test.ts: -------------------------------------------------------------------------------- 1 | import { JSONSchema7Type } from "json-schema"; 2 | import { z } from "zod/v3"; 3 | import { parseEffectsDef } from "../../src/parsers/effects.js"; 4 | import { getRefs } from "../../src/Refs.js"; 5 | import { suite } from "../suite.js"; 6 | 7 | suite("effects", (test) => { 8 | test("should be possible to use refine", (assert) => { 9 | const parsedSchema = parseEffectsDef( 10 | z.number().refine((x) => x + 1)._def, 11 | getRefs(), 12 | ); 13 | const jsonSchema: JSONSchema7Type = { 14 | type: "number", 15 | }; 16 | assert(parsedSchema, jsonSchema); 17 | }); 18 | 19 | test("should default to the input type", (assert) => { 20 | const schema = z.string().transform((arg) => parseInt(arg)); 21 | 22 | const jsonSchema = parseEffectsDef(schema._def, getRefs()); 23 | 24 | assert(jsonSchema, { 25 | type: "string", 26 | }); 27 | }); 28 | 29 | test("should return object based on 'any' strategy", (assert) => { 30 | const schema = z.string().transform((arg) => parseInt(arg)); 31 | 32 | const jsonSchema = parseEffectsDef( 33 | schema._def, 34 | getRefs({ 35 | effectStrategy: "any", 36 | }), 37 | ); 38 | 39 | assert(jsonSchema, {}); 40 | }); 41 | }); 42 | -------------------------------------------------------------------------------- /src/parsers/set.ts: -------------------------------------------------------------------------------- 1 | import { ZodSetDef } from "zod/v3"; 2 | import { ErrorMessages, setResponseValueAndErrors } from "../errorMessages.js"; 3 | import { parseDef } from "../parseDef.js"; 4 | import { JsonSchema7Type } from "../parseTypes.js"; 5 | import { Refs } from "../Refs.js"; 6 | 7 | export type JsonSchema7SetType = { 8 | type: "array"; 9 | uniqueItems: true; 10 | items?: JsonSchema7Type; 11 | minItems?: number; 12 | maxItems?: number; 13 | errorMessage?: ErrorMessages; 14 | }; 15 | 16 | export function parseSetDef(def: ZodSetDef, refs: Refs): JsonSchema7SetType { 17 | const items = parseDef(def.valueType._def, { 18 | ...refs, 19 | currentPath: [...refs.currentPath, "items"], 20 | }); 21 | 22 | const schema: JsonSchema7SetType = { 23 | type: "array", 24 | uniqueItems: true, 25 | items, 26 | }; 27 | 28 | if (def.minSize) { 29 | setResponseValueAndErrors( 30 | schema, 31 | "minItems", 32 | def.minSize.value, 33 | def.minSize.message, 34 | refs, 35 | ); 36 | } 37 | 38 | if (def.maxSize) { 39 | setResponseValueAndErrors( 40 | schema, 41 | "maxItems", 42 | def.maxSize.value, 43 | def.maxSize.message, 44 | refs, 45 | ); 46 | } 47 | 48 | return schema; 49 | } 50 | -------------------------------------------------------------------------------- /src/parsers/map.ts: -------------------------------------------------------------------------------- 1 | import { ZodMapDef } from "zod/v3"; 2 | import { parseDef } from "../parseDef.js"; 3 | import { JsonSchema7Type } from "../parseTypes.js"; 4 | import { Refs } from "../Refs.js"; 5 | import { JsonSchema7RecordType, parseRecordDef } from "./record.js"; 6 | import { parseAnyDef } from "./any.js"; 7 | 8 | export type JsonSchema7MapType = { 9 | type: "array"; 10 | maxItems: 125; 11 | items: { 12 | type: "array"; 13 | items: [JsonSchema7Type, JsonSchema7Type]; 14 | minItems: 2; 15 | maxItems: 2; 16 | }; 17 | }; 18 | 19 | export function parseMapDef( 20 | def: ZodMapDef, 21 | refs: Refs, 22 | ): JsonSchema7MapType | JsonSchema7RecordType { 23 | if (refs.mapStrategy === "record") { 24 | return parseRecordDef(def, refs); 25 | } 26 | 27 | const keys = 28 | parseDef(def.keyType._def, { 29 | ...refs, 30 | currentPath: [...refs.currentPath, "items", "items", "0"], 31 | }) || parseAnyDef(refs); 32 | const values = 33 | parseDef(def.valueType._def, { 34 | ...refs, 35 | currentPath: [...refs.currentPath, "items", "items", "1"], 36 | }) || parseAnyDef(refs); 37 | return { 38 | type: "array", 39 | maxItems: 125, 40 | items: { 41 | type: "array", 42 | items: [keys, values], 43 | minItems: 2, 44 | maxItems: 2, 45 | }, 46 | }; 47 | } 48 | -------------------------------------------------------------------------------- /src/Refs.ts: -------------------------------------------------------------------------------- 1 | import { ZodTypeDef } from "zod/v3"; 2 | import { getDefaultOptions, Options, Targets } from "./Options.js"; 3 | import { JsonSchema7Type } from "./parseTypes.js"; 4 | 5 | export type Refs = { 6 | seen: Map; 7 | currentPath: string[]; 8 | propertyPath: string[] | undefined; 9 | flags: { hasReferencedOpenAiAnyType: boolean }; 10 | } & Options; 11 | 12 | export type Seen = { 13 | def: ZodTypeDef; 14 | path: string[]; 15 | jsonSchema: JsonSchema7Type | undefined; 16 | }; 17 | 18 | export const getRefs = (options?: string | Partial>): Refs => { 19 | const _options = getDefaultOptions(options); 20 | const currentPath = 21 | _options.name !== undefined 22 | ? [..._options.basePath, _options.definitionPath, _options.name] 23 | : _options.basePath; 24 | return { 25 | ..._options, 26 | flags: { hasReferencedOpenAiAnyType: false }, 27 | currentPath: currentPath, 28 | propertyPath: undefined, 29 | seen: new Map( 30 | Object.entries(_options.definitions).map(([name, def]) => [ 31 | def._def, 32 | { 33 | def: def._def, 34 | path: [..._options.basePath, _options.definitionPath, name], 35 | // Resolution of references will be forced even though seen, so it's ok that the schema is undefined here for now. 36 | jsonSchema: undefined, 37 | }, 38 | ]), 39 | ), 40 | }; 41 | }; 42 | -------------------------------------------------------------------------------- /test/openAiMode.test.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod/v3"; 2 | import { suite } from "./suite.js"; 3 | import { zodToJsonSchema } from "../src/zodToJsonSchema.js"; 4 | 5 | suite("Open AI mode", (test) => { 6 | test("root object properties should be required", (assert) => { 7 | const input = z.object({ 8 | hello: z.string().optional(), 9 | nested: z.object({ 10 | hello: z.string().optional(), 11 | }), 12 | }); 13 | 14 | const output = zodToJsonSchema(input, { target: "openAi" }); 15 | 16 | const expected = { 17 | $schema: "https://json-schema.org/draft/2019-09/schema#", 18 | type: "object", 19 | required: ["hello", "nested"], 20 | properties: { 21 | hello: { 22 | type: ["string", "null"], 23 | }, 24 | nested: { 25 | type: "object", 26 | required: ["hello"], 27 | properties: { 28 | hello: { 29 | type: ["string", "null"], 30 | }, 31 | }, 32 | additionalProperties: false, 33 | }, 34 | }, 35 | additionalProperties: false, 36 | }; 37 | 38 | assert(output, expected); 39 | }); 40 | 41 | test("Using a root union or record should produce a warning", (assert) => { 42 | const input = z.union([z.string(), z.record(z.string())]); 43 | 44 | let warnings = 0; 45 | const borrowed = console.warn; 46 | console.warn = () => warnings++; 47 | 48 | zodToJsonSchema(input, { target: "openAi" }); 49 | 50 | console.warn = borrowed; 51 | 52 | assert(warnings, 2); 53 | }); 54 | }); 55 | -------------------------------------------------------------------------------- /dist-test-v4/types/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "types", 3 | "lockfileVersion": 3, 4 | "requires": true, 5 | "packages": { 6 | "": { 7 | "dependencies": { 8 | "zod": "^4", 9 | "zod-to-json-schema": "file://../../" 10 | } 11 | }, 12 | "../..": { 13 | "version": "3.25.0", 14 | "license": "ISC", 15 | "devDependencies": { 16 | "@types/json-schema": "^7.0.9", 17 | "@types/node": "^20.9.0", 18 | "ajv": "^8.6.3", 19 | "ajv-errors": "^3.0.0", 20 | "ajv-formats": "^2.1.1", 21 | "fast-diff": "^1.3.0", 22 | "local-ref-resolver": "^0.2.0", 23 | "rimraf": "^3.0.2", 24 | "tsx": "^4.19.0", 25 | "typescript": "^5.1.3", 26 | "zod": "^3.25 || ^4" 27 | }, 28 | "peerDependencies": { 29 | "zod": "^3.25 || ^4" 30 | } 31 | }, 32 | "../../dist/cjs": { 33 | "extraneous": true 34 | }, 35 | "../../dist/cjs/package.json": { 36 | "extraneous": true 37 | }, 38 | "../../dist/esm": { 39 | "extraneous": true 40 | }, 41 | "node_modules/zod": { 42 | "version": "4.1.12", 43 | "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.12.tgz", 44 | "integrity": "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==", 45 | "license": "MIT", 46 | "funding": { 47 | "url": "https://github.com/sponsors/colinhacks" 48 | } 49 | }, 50 | "node_modules/zod-to-json-schema": { 51 | "resolved": "../..", 52 | "link": true 53 | } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /dist-test-v3/types/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "types", 3 | "lockfileVersion": 3, 4 | "requires": true, 5 | "packages": { 6 | "": { 7 | "dependencies": { 8 | "zod": "^3.25", 9 | "zod-to-json-schema": "file://../../" 10 | } 11 | }, 12 | "../..": { 13 | "version": "3.25.0", 14 | "license": "ISC", 15 | "devDependencies": { 16 | "@types/json-schema": "^7.0.9", 17 | "@types/node": "^20.9.0", 18 | "ajv": "^8.6.3", 19 | "ajv-errors": "^3.0.0", 20 | "ajv-formats": "^2.1.1", 21 | "fast-diff": "^1.3.0", 22 | "local-ref-resolver": "^0.2.0", 23 | "rimraf": "^3.0.2", 24 | "tsx": "^4.19.0", 25 | "typescript": "^5.1.3", 26 | "zod": "^3.25 || ^4" 27 | }, 28 | "peerDependencies": { 29 | "zod": "^3.25 || ^4" 30 | } 31 | }, 32 | "../../dist/cjs": { 33 | "extraneous": true 34 | }, 35 | "../../dist/cjs/package.json": { 36 | "extraneous": true 37 | }, 38 | "../../dist/esm": { 39 | "extraneous": true 40 | }, 41 | "node_modules/zod": { 42 | "version": "3.25.76", 43 | "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", 44 | "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", 45 | "license": "MIT", 46 | "funding": { 47 | "url": "https://github.com/sponsors/colinhacks" 48 | } 49 | }, 50 | "node_modules/zod-to-json-schema": { 51 | "resolved": "../..", 52 | "link": true 53 | } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./Options.js"; 2 | export * from "./Refs.js"; 3 | export * from "./errorMessages.js"; 4 | export * from "./getRelativePath.js"; 5 | export * from "./parseDef.js"; 6 | export * from "./parseTypes.js"; 7 | export * from "./parsers/any.js"; 8 | export * from "./parsers/array.js"; 9 | export * from "./parsers/bigint.js"; 10 | export * from "./parsers/boolean.js"; 11 | export * from "./parsers/branded.js"; 12 | export * from "./parsers/catch.js"; 13 | export * from "./parsers/date.js"; 14 | export * from "./parsers/default.js"; 15 | export * from "./parsers/effects.js"; 16 | export * from "./parsers/enum.js"; 17 | export * from "./parsers/intersection.js"; 18 | export * from "./parsers/literal.js"; 19 | export * from "./parsers/map.js"; 20 | export * from "./parsers/nativeEnum.js"; 21 | export * from "./parsers/never.js"; 22 | export * from "./parsers/null.js"; 23 | export * from "./parsers/nullable.js"; 24 | export * from "./parsers/number.js"; 25 | export * from "./parsers/object.js"; 26 | export * from "./parsers/optional.js"; 27 | export * from "./parsers/pipeline.js"; 28 | export * from "./parsers/promise.js"; 29 | export * from "./parsers/readonly.js"; 30 | export * from "./parsers/record.js"; 31 | export * from "./parsers/set.js"; 32 | export * from "./parsers/string.js"; 33 | export * from "./parsers/tuple.js"; 34 | export * from "./parsers/undefined.js"; 35 | export * from "./parsers/union.js"; 36 | export * from "./parsers/unknown.js"; 37 | export * from "./selectParser.js"; 38 | export * from "./zodToJsonSchema.js"; 39 | import { zodToJsonSchema } from "./zodToJsonSchema.js"; 40 | export default zodToJsonSchema; -------------------------------------------------------------------------------- /test/parsers/default.test.ts: -------------------------------------------------------------------------------- 1 | import { JSONSchema7Type } from "json-schema"; 2 | import { z } from "zod/v3"; 3 | import { parseDefaultDef } from "../../src/parsers/default.js"; 4 | import { getRefs } from "../../src/Refs.js"; 5 | import { suite } from "../suite.js"; 6 | 7 | suite("promise", (test) => { 8 | test("should be possible to use default on objects", (assert) => { 9 | const parsedSchema = parseDefaultDef( 10 | z.object({ foo: z.boolean() }).default({ foo: true })._def, 11 | getRefs(), 12 | ); 13 | const jsonSchema: JSONSchema7Type = { 14 | type: "object", 15 | additionalProperties: false, 16 | required: ["foo"], 17 | properties: { 18 | foo: { 19 | type: "boolean", 20 | }, 21 | }, 22 | default: { 23 | foo: true, 24 | }, 25 | }; 26 | assert(parsedSchema, jsonSchema); 27 | }); 28 | 29 | test("should be possible to use default on primitives", (assert) => { 30 | const parsedSchema = parseDefaultDef( 31 | z.string().default("default")._def, 32 | getRefs(), 33 | ); 34 | const jsonSchema: JSONSchema7Type = { 35 | type: "string", 36 | default: "default", 37 | }; 38 | assert(parsedSchema, jsonSchema); 39 | }); 40 | 41 | test("default with transform", (assert) => { 42 | const stringWithDefault = z 43 | .string() 44 | .transform((val) => val.toUpperCase()) 45 | .default("default"); 46 | 47 | const parsedSchema = parseDefaultDef(stringWithDefault._def, getRefs()); 48 | const jsonSchema: JSONSchema7Type = { 49 | type: "string", 50 | default: "default", 51 | }; 52 | 53 | assert(parsedSchema, jsonSchema); 54 | }); 55 | }); 56 | -------------------------------------------------------------------------------- /src/parsers/array.ts: -------------------------------------------------------------------------------- 1 | import { ZodArrayDef, ZodFirstPartyTypeKind } from "zod/v3"; 2 | import { ErrorMessages, setResponseValueAndErrors } from "../errorMessages.js"; 3 | import { parseDef } from "../parseDef.js"; 4 | import { JsonSchema7Type } from "../parseTypes.js"; 5 | import { Refs } from "../Refs.js"; 6 | 7 | export type JsonSchema7ArrayType = { 8 | type: "array"; 9 | items?: JsonSchema7Type; 10 | minItems?: number; 11 | maxItems?: number; 12 | errorMessages?: ErrorMessages; 13 | }; 14 | 15 | export function parseArrayDef(def: ZodArrayDef, refs: Refs) { 16 | const res: JsonSchema7ArrayType = { 17 | type: "array", 18 | }; 19 | if ( 20 | def.type?._def && 21 | def.type?._def?.typeName !== ZodFirstPartyTypeKind.ZodAny 22 | ) { 23 | res.items = parseDef(def.type._def, { 24 | ...refs, 25 | currentPath: [...refs.currentPath, "items"], 26 | }); 27 | } 28 | 29 | if (def.minLength) { 30 | setResponseValueAndErrors( 31 | res, 32 | "minItems", 33 | def.minLength.value, 34 | def.minLength.message, 35 | refs, 36 | ); 37 | } 38 | if (def.maxLength) { 39 | setResponseValueAndErrors( 40 | res, 41 | "maxItems", 42 | def.maxLength.value, 43 | def.maxLength.message, 44 | refs, 45 | ); 46 | } 47 | if (def.exactLength) { 48 | setResponseValueAndErrors( 49 | res, 50 | "minItems", 51 | def.exactLength.value, 52 | def.exactLength.message, 53 | refs, 54 | ); 55 | setResponseValueAndErrors( 56 | res, 57 | "maxItems", 58 | def.exactLength.value, 59 | def.exactLength.message, 60 | refs, 61 | ); 62 | } 63 | return res; 64 | } 65 | -------------------------------------------------------------------------------- /src/parsers/tuple.ts: -------------------------------------------------------------------------------- 1 | import { ZodTupleDef, ZodTupleItems, ZodTypeAny } from "zod/v3"; 2 | import { parseDef } from "../parseDef.js"; 3 | import { JsonSchema7Type } from "../parseTypes.js"; 4 | import { Refs } from "../Refs.js"; 5 | 6 | export type JsonSchema7TupleType = { 7 | type: "array"; 8 | minItems: number; 9 | items: JsonSchema7Type[]; 10 | } & ( 11 | | { 12 | maxItems: number; 13 | } 14 | | { 15 | additionalItems?: JsonSchema7Type; 16 | } 17 | ); 18 | 19 | export function parseTupleDef( 20 | def: ZodTupleDef, 21 | refs: Refs, 22 | ): JsonSchema7TupleType { 23 | if (def.rest) { 24 | return { 25 | type: "array", 26 | minItems: def.items.length, 27 | items: def.items 28 | .map((x, i) => 29 | parseDef(x._def, { 30 | ...refs, 31 | currentPath: [...refs.currentPath, "items", `${i}`], 32 | }), 33 | ) 34 | .reduce( 35 | (acc: JsonSchema7Type[], x) => (x === undefined ? acc : [...acc, x]), 36 | [], 37 | ), 38 | additionalItems: parseDef(def.rest._def, { 39 | ...refs, 40 | currentPath: [...refs.currentPath, "additionalItems"], 41 | }), 42 | }; 43 | } else { 44 | return { 45 | type: "array", 46 | minItems: def.items.length, 47 | maxItems: def.items.length, 48 | items: def.items 49 | .map((x, i) => 50 | parseDef(x._def, { 51 | ...refs, 52 | currentPath: [...refs.currentPath, "items", `${i}`], 53 | }), 54 | ) 55 | .reduce( 56 | (acc: JsonSchema7Type[], x) => (x === undefined ? acc : [...acc, x]), 57 | [], 58 | ), 59 | }; 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /test/readme.test.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod/v3"; 2 | import zodToJsonSchema from "../src"; 3 | import { suite } from "./suite.js"; 4 | 5 | suite("The readme example", (test) => { 6 | test("should be valid", (assert) => { 7 | const mySchema = z 8 | .object({ 9 | myString: z.string().min(5), 10 | myUnion: z.union([z.number(), z.boolean()]), 11 | }) 12 | .describe("My neat object schema"); 13 | 14 | const jsonSchema = zodToJsonSchema(mySchema, "mySchema"); 15 | 16 | assert(jsonSchema, { 17 | $schema: "http://json-schema.org/draft-07/schema#", 18 | $ref: "#/definitions/mySchema", 19 | definitions: { 20 | mySchema: { 21 | description: "My neat object schema", 22 | type: "object", 23 | properties: { 24 | myString: { 25 | type: "string", 26 | minLength: 5, 27 | }, 28 | myUnion: { 29 | type: ["number", "boolean"], 30 | }, 31 | }, 32 | additionalProperties: false, 33 | required: ["myString", "myUnion"], 34 | }, 35 | }, 36 | }); 37 | }); 38 | test("should have a valid error message example", (assert) => { 39 | const EmailSchema = z.string().email("Invalid email").min(5, "Too short"); 40 | const expected = { 41 | $schema: "http://json-schema.org/draft-07/schema#", 42 | type: "string", 43 | format: "email", 44 | minLength: 5, 45 | errorMessage: { 46 | format: "Invalid email", 47 | minLength: "Too short", 48 | }, 49 | }; 50 | const parsedJsonSchema = zodToJsonSchema(EmailSchema, { 51 | errorMessages: true, 52 | }); 53 | assert(parsedJsonSchema, expected); 54 | }); 55 | }); 56 | -------------------------------------------------------------------------------- /test/parsers/map.test.ts: -------------------------------------------------------------------------------- 1 | import { JSONSchema7Type } from "json-schema"; 2 | import { z } from "zod/v3"; 3 | import { parseMapDef } from "../../src/parsers/map.js"; 4 | import Ajv from "ajv"; 5 | import { getRefs } from "../../src/Refs.js"; 6 | import { suite } from "../suite.js"; 7 | const ajv = new Ajv(); 8 | suite("map", (test) => { 9 | test("should be possible to use Map", (assert) => { 10 | const mapSchema = z.map(z.string(), z.number()); 11 | 12 | const parsedSchema = parseMapDef(mapSchema._def, getRefs()); 13 | 14 | const jsonSchema: JSONSchema7Type = { 15 | type: "array", 16 | maxItems: 125, 17 | items: { 18 | type: "array", 19 | items: [ 20 | { 21 | type: "string", 22 | }, 23 | { 24 | type: "number", 25 | }, 26 | ], 27 | minItems: 2, 28 | maxItems: 2, 29 | }, 30 | }; 31 | 32 | assert(parsedSchema, jsonSchema); 33 | 34 | const myMap: z.infer = new Map(); 35 | myMap.set("hello", 123); 36 | 37 | ajv.validate(jsonSchema, Array.from(myMap)); 38 | const ajvResult = !ajv.errors; 39 | 40 | const zodResult = mapSchema.safeParse(myMap).success; 41 | 42 | assert(zodResult, true); 43 | assert(ajvResult, true); 44 | }); 45 | 46 | test("should be possible to use additionalProperties-pattern (record)", (assert) => { 47 | assert( 48 | parseMapDef( 49 | z.map(z.string().min(1), z.number())._def, 50 | getRefs({ mapStrategy: "record" }), 51 | ), 52 | { 53 | type: "object", 54 | additionalProperties: { 55 | type: "number", 56 | }, 57 | propertyNames: { 58 | minLength: 1, 59 | }, 60 | }, 61 | ); 62 | }); 63 | }); 64 | -------------------------------------------------------------------------------- /test/parsers/nullable.test.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod/v3"; 2 | import { parseObjectDef } from "../../src/parsers/object.js"; 3 | import { getRefs } from "../../src/Refs.js"; 4 | import { suite } from "../suite.js"; 5 | 6 | suite("nullable", (test) => { 7 | test("should be possible to properly reference nested nullable primitives", (assert) => { 8 | const nullablePrimitive = z.string().nullable(); 9 | 10 | const schema = z.object({ 11 | one: nullablePrimitive, 12 | two: nullablePrimitive, 13 | }); 14 | 15 | const jsonSchema: any = parseObjectDef(schema._def, getRefs()); 16 | 17 | assert(jsonSchema.properties.one.type, ["string", "null"]); 18 | assert(jsonSchema.properties.two.$ref, "#/properties/one"); 19 | }); 20 | 21 | test("should be possible to properly reference nested nullable primitives", (assert) => { 22 | const three = z.string(); 23 | 24 | const nullableObject = z 25 | .object({ 26 | three, 27 | }) 28 | .nullable(); 29 | 30 | const schema = z.object({ 31 | one: nullableObject, 32 | two: nullableObject, 33 | three, 34 | }); 35 | 36 | const jsonSchema: any = parseObjectDef(schema._def, getRefs()); 37 | 38 | assert(jsonSchema.properties.one, { 39 | anyOf: [ 40 | { 41 | type: "object", 42 | additionalProperties: false, 43 | required: ["three"], 44 | properties: { 45 | three: { 46 | type: "string", 47 | }, 48 | }, 49 | }, 50 | { 51 | type: "null", 52 | }, 53 | ], 54 | }); 55 | assert(jsonSchema.properties.two.$ref, "#/properties/one"); 56 | assert( 57 | jsonSchema.properties.three.$ref, 58 | "#/properties/one/anyOf/0/properties/three", 59 | ); 60 | }); 61 | }); 62 | -------------------------------------------------------------------------------- /src/parsers/nullable.ts: -------------------------------------------------------------------------------- 1 | import { ZodNullableDef } from "zod/v3"; 2 | import { parseDef } from "../parseDef.js"; 3 | import { JsonSchema7Type } from "../parseTypes.js"; 4 | import { Refs } from "../Refs.js"; 5 | import { JsonSchema7NullType } from "./null.js"; 6 | import { primitiveMappings } from "./union.js"; 7 | 8 | export type JsonSchema7NullableType = 9 | | { 10 | anyOf: [JsonSchema7Type, JsonSchema7NullType]; 11 | } 12 | | { 13 | type: [string, "null"]; 14 | }; 15 | 16 | export function parseNullableDef( 17 | def: ZodNullableDef, 18 | refs: Refs, 19 | ): JsonSchema7NullableType | undefined { 20 | if ( 21 | ["ZodString", "ZodNumber", "ZodBigInt", "ZodBoolean", "ZodNull"].includes( 22 | def.innerType._def.typeName, 23 | ) && 24 | (!def.innerType._def.checks || !def.innerType._def.checks.length) 25 | ) { 26 | if (refs.target === "openApi3") { 27 | return { 28 | type: primitiveMappings[ 29 | def.innerType._def.typeName as keyof typeof primitiveMappings 30 | ], 31 | nullable: true, 32 | } as any; 33 | } 34 | 35 | return { 36 | type: [ 37 | primitiveMappings[ 38 | def.innerType._def.typeName as keyof typeof primitiveMappings 39 | ], 40 | "null", 41 | ], 42 | }; 43 | } 44 | 45 | if (refs.target === "openApi3") { 46 | const base = parseDef(def.innerType._def, { 47 | ...refs, 48 | currentPath: [...refs.currentPath], 49 | }); 50 | 51 | if (base && "$ref" in base) return { allOf: [base], nullable: true } as any; 52 | 53 | return base && ({ ...base, nullable: true } as any); 54 | } 55 | 56 | const base = parseDef(def.innerType._def, { 57 | ...refs, 58 | currentPath: [...refs.currentPath, "anyOf", "0"], 59 | }); 60 | 61 | return base && { anyOf: [base, { type: "null" }] }; 62 | } 63 | -------------------------------------------------------------------------------- /test/parsers/set.test.ts: -------------------------------------------------------------------------------- 1 | import { JSONSchema7Type } from "json-schema"; 2 | import { z } from "zod/v3"; 3 | import { parseSetDef } from "../../src/parsers/set.js"; 4 | import { getRefs } from "../../src/Refs.js"; 5 | import { errorReferences } from "./errorReferences.js"; 6 | import { suite } from "../suite.js"; 7 | 8 | suite("set", (test) => { 9 | test("should include min and max size error messages if they're passed.", (assert) => { 10 | const minSizeError = "Set must have at least 5 elements"; 11 | const maxSizeError = "Set can't have more than 10 elements"; 12 | const errs = { 13 | minItems: minSizeError, 14 | maxItems: maxSizeError, 15 | }; 16 | const jsonSchema: JSONSchema7Type = { 17 | type: "array", 18 | minItems: 5, 19 | maxItems: 10, 20 | errorMessage: errs, 21 | uniqueItems: true, 22 | items: {}, 23 | }; 24 | const zodSchema = z.set(z.any()).min(5, minSizeError).max(10, maxSizeError); 25 | const jsonParsedSchema = parseSetDef(zodSchema._def, errorReferences()); 26 | assert(jsonParsedSchema, jsonSchema); 27 | }); 28 | test("should not include error messages if none are passed", (assert) => { 29 | const jsonSchema: JSONSchema7Type = { 30 | type: "array", 31 | minItems: 5, 32 | maxItems: 10, 33 | uniqueItems: true, 34 | items: {}, 35 | }; 36 | const zodSchema = z.set(z.any()).min(5).max(10); 37 | const jsonParsedSchema = parseSetDef(zodSchema._def, errorReferences()); 38 | assert(jsonParsedSchema, jsonSchema); 39 | }); 40 | test("should not include error messages if it's not explicitly set to true in the References constructor", (assert) => { 41 | const zodSchema = z.set(z.any()).min(1, "bad").max(5, "vbad"); 42 | const jsonParsedSchema = parseSetDef(zodSchema._def, getRefs()); 43 | assert(jsonParsedSchema.errorMessage, undefined); 44 | }); 45 | }); 46 | -------------------------------------------------------------------------------- /src/parsers/date.ts: -------------------------------------------------------------------------------- 1 | import { ZodDateDef } from "zod/v3"; 2 | import { Refs } from "../Refs.js"; 3 | import { ErrorMessages, setResponseValueAndErrors } from "../errorMessages.js"; 4 | import { JsonSchema7NumberType } from "./number.js"; 5 | import { DateStrategy } from "../Options.js"; 6 | 7 | export type JsonSchema7DateType = 8 | | { 9 | type: "integer" | "string"; 10 | format: "unix-time" | "date-time" | "date"; 11 | minimum?: number; 12 | maximum?: number; 13 | errorMessage?: ErrorMessages; 14 | } 15 | | { 16 | anyOf: JsonSchema7DateType[]; 17 | }; 18 | 19 | export function parseDateDef( 20 | def: ZodDateDef, 21 | refs: Refs, 22 | overrideDateStrategy?: DateStrategy, 23 | ): JsonSchema7DateType { 24 | const strategy = overrideDateStrategy ?? refs.dateStrategy; 25 | 26 | if (Array.isArray(strategy)) { 27 | return { 28 | anyOf: strategy.map((item, i) => parseDateDef(def, refs, item)), 29 | }; 30 | } 31 | 32 | switch (strategy) { 33 | case "string": 34 | case "format:date-time": 35 | return { 36 | type: "string", 37 | format: "date-time", 38 | }; 39 | case "format:date": 40 | return { 41 | type: "string", 42 | format: "date", 43 | }; 44 | case "integer": 45 | return integerDateParser(def, refs); 46 | } 47 | } 48 | 49 | const integerDateParser = (def: ZodDateDef, refs: Refs) => { 50 | const res: JsonSchema7DateType = { 51 | type: "integer", 52 | format: "unix-time", 53 | }; 54 | 55 | if (refs.target === "openApi3") { 56 | return res; 57 | } 58 | 59 | for (const check of def.checks) { 60 | switch (check.kind) { 61 | case "min": 62 | setResponseValueAndErrors( 63 | res, 64 | "minimum", 65 | check.value, // This is in milliseconds 66 | check.message, 67 | refs, 68 | ); 69 | break; 70 | case "max": 71 | setResponseValueAndErrors( 72 | res, 73 | "maximum", 74 | check.value, // This is in milliseconds 75 | check.message, 76 | refs, 77 | ); 78 | break; 79 | } 80 | } 81 | 82 | return res; 83 | }; 84 | -------------------------------------------------------------------------------- /test/meta.test.ts: -------------------------------------------------------------------------------- 1 | import { JSONSchema7 } from "json-schema"; 2 | import { z } from "zod/v3"; 3 | import { zodToJsonSchema } from "../src/zodToJsonSchema.js"; 4 | import { suite } from "./suite.js"; 5 | suite("Meta data", (it) => { 6 | it("should be possible to use description", (assert) => { 7 | const $z = z.string().describe("My neat string"); 8 | const $j = zodToJsonSchema($z); 9 | const $e: JSONSchema7 = { 10 | $schema: "http://json-schema.org/draft-07/schema#", 11 | type: "string", 12 | description: "My neat string", 13 | }; 14 | 15 | assert($j, $e); 16 | }); 17 | 18 | it("should be possible to add a markdownDescription", (assert) => { 19 | const $z = z.string().describe("My neat string"); 20 | const $j = zodToJsonSchema($z, { markdownDescription: true }); 21 | const $e = { 22 | $schema: "http://json-schema.org/draft-07/schema#", 23 | type: "string", 24 | description: "My neat string", 25 | markdownDescription: "My neat string", 26 | }; 27 | 28 | assert($j, $e); 29 | }); 30 | 31 | it("should handle optional schemas with different descriptions", (assert) => { 32 | const recurringSchema = z.object({}); 33 | const zodSchema = z 34 | .object({ 35 | p1: recurringSchema.optional().describe("aaaaaaaaa"), 36 | p2: recurringSchema.optional().describe("bbbbbbbbb"), 37 | p3: recurringSchema.optional().describe("ccccccccc"), 38 | }) 39 | .describe("sssssssss"); 40 | 41 | const jsonSchema = zodToJsonSchema(zodSchema, { 42 | target: "openApi3", 43 | $refStrategy: "none", 44 | }); 45 | 46 | assert(jsonSchema, { 47 | additionalProperties: false, 48 | description: "sssssssss", 49 | properties: { 50 | p1: { 51 | additionalProperties: false, 52 | description: "aaaaaaaaa", 53 | properties: {}, 54 | type: "object", 55 | }, 56 | p2: { 57 | additionalProperties: false, 58 | description: "bbbbbbbbb", 59 | properties: {}, 60 | type: "object", 61 | }, 62 | p3: { 63 | additionalProperties: false, 64 | description: "ccccccccc", 65 | properties: {}, 66 | type: "object", 67 | }, 68 | }, 69 | type: "object", 70 | }); 71 | }); 72 | }); 73 | -------------------------------------------------------------------------------- /src/parseTypes.ts: -------------------------------------------------------------------------------- 1 | import { JsonSchema7AnyType } from "./parsers/any.js"; 2 | import { JsonSchema7ArrayType } from "./parsers/array.js"; 3 | import { JsonSchema7BigintType } from "./parsers/bigint.js"; 4 | import { JsonSchema7BooleanType } from "./parsers/boolean.js"; 5 | import { JsonSchema7DateType } from "./parsers/date.js"; 6 | import { JsonSchema7EnumType } from "./parsers/enum.js"; 7 | import { JsonSchema7AllOfType } from "./parsers/intersection.js"; 8 | import { JsonSchema7LiteralType } from "./parsers/literal.js"; 9 | import { JsonSchema7MapType } from "./parsers/map.js"; 10 | import { JsonSchema7NativeEnumType } from "./parsers/nativeEnum.js"; 11 | import { JsonSchema7NeverType } from "./parsers/never.js"; 12 | import { JsonSchema7NullType } from "./parsers/null.js"; 13 | import { JsonSchema7NullableType } from "./parsers/nullable.js"; 14 | import { JsonSchema7NumberType } from "./parsers/number.js"; 15 | import { JsonSchema7ObjectType } from "./parsers/object.js"; 16 | import { JsonSchema7RecordType } from "./parsers/record.js"; 17 | import { JsonSchema7SetType } from "./parsers/set.js"; 18 | import { JsonSchema7StringType } from "./parsers/string.js"; 19 | import { JsonSchema7TupleType } from "./parsers/tuple.js"; 20 | import { JsonSchema7UndefinedType } from "./parsers/undefined.js"; 21 | import { JsonSchema7UnionType } from "./parsers/union.js"; 22 | import { JsonSchema7UnknownType } from "./parsers/unknown.js"; 23 | 24 | type JsonSchema7RefType = { $ref: string }; 25 | type JsonSchema7Meta = { 26 | title?: string; 27 | default?: any; 28 | description?: string; 29 | markdownDescription?: string; 30 | }; 31 | 32 | export type JsonSchema7TypeUnion = 33 | | JsonSchema7StringType 34 | | JsonSchema7ArrayType 35 | | JsonSchema7NumberType 36 | | JsonSchema7BigintType 37 | | JsonSchema7BooleanType 38 | | JsonSchema7DateType 39 | | JsonSchema7EnumType 40 | | JsonSchema7LiteralType 41 | | JsonSchema7NativeEnumType 42 | | JsonSchema7NullType 43 | | JsonSchema7NumberType 44 | | JsonSchema7ObjectType 45 | | JsonSchema7RecordType 46 | | JsonSchema7TupleType 47 | | JsonSchema7UnionType 48 | | JsonSchema7UndefinedType 49 | | JsonSchema7RefType 50 | | JsonSchema7NeverType 51 | | JsonSchema7MapType 52 | | JsonSchema7AnyType 53 | | JsonSchema7NullableType 54 | | JsonSchema7AllOfType 55 | | JsonSchema7UnknownType 56 | | JsonSchema7SetType; 57 | 58 | export type JsonSchema7Type = JsonSchema7TypeUnion & JsonSchema7Meta; 59 | -------------------------------------------------------------------------------- /src/parsers/intersection.ts: -------------------------------------------------------------------------------- 1 | import { ZodIntersectionDef } from "zod/v3"; 2 | import { parseDef } from "../parseDef.js"; 3 | import { JsonSchema7Type } from "../parseTypes.js"; 4 | import { Refs } from "../Refs.js"; 5 | import { JsonSchema7StringType } from "./string.js"; 6 | 7 | export type JsonSchema7AllOfType = { 8 | allOf: JsonSchema7Type[]; 9 | unevaluatedProperties?: boolean; 10 | }; 11 | 12 | const isJsonSchema7AllOfType = ( 13 | type: JsonSchema7Type | JsonSchema7StringType, 14 | ): type is JsonSchema7AllOfType => { 15 | if ("type" in type && type.type === "string") return false; 16 | return "allOf" in type; 17 | }; 18 | 19 | export function parseIntersectionDef( 20 | def: ZodIntersectionDef, 21 | refs: Refs, 22 | ): JsonSchema7AllOfType | JsonSchema7Type | undefined { 23 | const allOf = [ 24 | parseDef(def.left._def, { 25 | ...refs, 26 | currentPath: [...refs.currentPath, "allOf", "0"], 27 | }), 28 | parseDef(def.right._def, { 29 | ...refs, 30 | currentPath: [...refs.currentPath, "allOf", "1"], 31 | }), 32 | ].filter((x): x is JsonSchema7Type => !!x); 33 | 34 | let unevaluatedProperties: 35 | | Pick 36 | | undefined = 37 | refs.target === "jsonSchema2019-09" 38 | ? { unevaluatedProperties: false } 39 | : undefined; 40 | 41 | const mergedAllOf: JsonSchema7Type[] = []; 42 | // If either of the schemas is an allOf, merge them into a single allOf 43 | allOf.forEach((schema) => { 44 | if (isJsonSchema7AllOfType(schema)) { 45 | mergedAllOf.push(...schema.allOf); 46 | if (schema.unevaluatedProperties === undefined) { 47 | // If one of the schemas has no unevaluatedProperties set, 48 | // the merged schema should also have no unevaluatedProperties set 49 | unevaluatedProperties = undefined; 50 | } 51 | } else { 52 | let nestedSchema: JsonSchema7Type = schema; 53 | if ( 54 | "additionalProperties" in schema && 55 | schema.additionalProperties === false 56 | ) { 57 | const { additionalProperties, ...rest } = schema; 58 | nestedSchema = rest; 59 | } else { 60 | // As soon as one of the schemas has additionalProperties set not to false, we allow unevaluatedProperties 61 | unevaluatedProperties = undefined; 62 | } 63 | mergedAllOf.push(nestedSchema); 64 | } 65 | }); 66 | return mergedAllOf.length 67 | ? { 68 | allOf: mergedAllOf, 69 | ...unevaluatedProperties, 70 | } 71 | : undefined; 72 | } 73 | -------------------------------------------------------------------------------- /test/parsers/bigint.test.ts: -------------------------------------------------------------------------------- 1 | import { JSONSchema7Type } from "json-schema"; 2 | import { parseBigintDef } from "../../src/parsers/bigint.js"; 3 | import { z } from "zod/v3"; 4 | import { getRefs } from "../../src/Refs.js"; 5 | import { suite } from "../suite.js"; 6 | suite("bigint", (test) => { 7 | test("should be possible to use bigint", (assert) => { 8 | const parsedSchema = parseBigintDef(z.bigint()._def, getRefs()); 9 | const jsonSchema: JSONSchema7Type = { 10 | type: "integer", 11 | format: "int64", 12 | }; 13 | assert(parsedSchema, jsonSchema); 14 | }); 15 | 16 | // Jest doesn't like bigints. 🤷 17 | test("should be possible to define gt/lt", (assert) => { 18 | const parsedSchema = parseBigintDef( 19 | z.bigint().gte(BigInt(10)).lte(BigInt(20))._def, 20 | getRefs(), 21 | ); 22 | const jsonSchema = { 23 | type: "integer", 24 | format: "int64", 25 | minimum: BigInt(10), 26 | maximum: BigInt(20), 27 | }; 28 | assert(parsedSchema, jsonSchema); 29 | }); 30 | 31 | test("should be possible to define gt/lt (jsonSchema2019-09)", (assert) => { 32 | const parsedSchema = parseBigintDef( 33 | z.bigint().gte(BigInt(10)).lte(BigInt(20))._def, 34 | getRefs({ 35 | target: "jsonSchema2019-09", 36 | }), 37 | ); 38 | const jsonSchema = { 39 | type: "integer", 40 | format: "int64", 41 | minimum: BigInt(10), 42 | maximum: BigInt(20), 43 | }; 44 | assert(parsedSchema, jsonSchema); 45 | }); 46 | 47 | test("should be possible to define gt/lt", (assert) => { 48 | const parsedSchema = parseBigintDef( 49 | z.bigint().gt(BigInt(10)).lt(BigInt(20))._def, 50 | getRefs(), 51 | ); 52 | const jsonSchema = { 53 | type: "integer", 54 | format: "int64", 55 | exclusiveMinimum: BigInt(10), 56 | exclusiveMaximum: BigInt(20), 57 | }; 58 | assert(parsedSchema, jsonSchema); 59 | }); 60 | 61 | test("should be possible to define gt/lt (jsonSchema2019-09)", (assert) => { 62 | const parsedSchema = parseBigintDef( 63 | z.bigint().gt(BigInt(10)).lt(BigInt(20))._def, 64 | getRefs({ 65 | target: "jsonSchema2019-09", 66 | }), 67 | ); 68 | const jsonSchema = { 69 | type: "integer", 70 | format: "int64", 71 | exclusiveMinimum: true, 72 | exclusiveMaximum: true, 73 | minimum: BigInt(10), 74 | maximum: BigInt(20), 75 | }; 76 | assert(parsedSchema, jsonSchema); 77 | }); 78 | 79 | test("should be possible to define multipleOf", (assert) => { 80 | const parsedSchema = parseBigintDef( 81 | z.bigint().multipleOf(BigInt(5))._def, 82 | getRefs(), 83 | ); 84 | const jsonSchema = { 85 | type: "integer", 86 | format: "int64", 87 | multipleOf: BigInt(5), 88 | }; 89 | assert(parsedSchema, jsonSchema); 90 | }); 91 | }); 92 | -------------------------------------------------------------------------------- /src/parsers/object.ts: -------------------------------------------------------------------------------- 1 | import { ZodObjectDef, ZodTypeAny } from "zod/v3"; 2 | import { parseDef } from "../parseDef.js"; 3 | import { JsonSchema7Type } from "../parseTypes.js"; 4 | import { Refs } from "../Refs.js"; 5 | 6 | export type JsonSchema7ObjectType = { 7 | type: "object"; 8 | properties: Record; 9 | additionalProperties?: boolean | JsonSchema7Type; 10 | required?: string[]; 11 | }; 12 | 13 | export function parseObjectDef(def: ZodObjectDef, refs: Refs) { 14 | const forceOptionalIntoNullable = refs.target === "openAi"; 15 | 16 | const result: JsonSchema7ObjectType = { 17 | type: "object", 18 | properties: {}, 19 | }; 20 | 21 | const required: string[] = []; 22 | 23 | const shape = def.shape(); 24 | 25 | for (const propName in shape) { 26 | let propDef = shape[propName]; 27 | 28 | if (propDef === undefined || propDef._def === undefined) { 29 | continue; 30 | } 31 | 32 | let propOptional = safeIsOptional(propDef); 33 | 34 | if (propOptional && forceOptionalIntoNullable) { 35 | if (propDef._def.typeName === "ZodOptional") { 36 | propDef = propDef._def.innerType; 37 | } 38 | 39 | if (!propDef.isNullable()) { 40 | propDef = propDef.nullable(); 41 | } 42 | 43 | propOptional = false; 44 | } 45 | 46 | const parsedDef = parseDef(propDef._def, { 47 | ...refs, 48 | currentPath: [...refs.currentPath, "properties", propName], 49 | propertyPath: [...refs.currentPath, "properties", propName], 50 | }); 51 | 52 | if (parsedDef === undefined) { 53 | continue; 54 | } 55 | 56 | result.properties[propName] = parsedDef; 57 | 58 | if (!propOptional) { 59 | required.push(propName); 60 | } 61 | } 62 | 63 | if (required.length) { 64 | result.required = required; 65 | } 66 | 67 | const additionalProperties = decideAdditionalProperties(def, refs); 68 | 69 | if (additionalProperties !== undefined) { 70 | result.additionalProperties = additionalProperties; 71 | } 72 | 73 | return result; 74 | } 75 | 76 | function decideAdditionalProperties(def: ZodObjectDef, refs: Refs) { 77 | if (def.catchall._def.typeName !== "ZodNever") { 78 | return parseDef(def.catchall._def, { 79 | ...refs, 80 | currentPath: [...refs.currentPath, "additionalProperties"], 81 | }); 82 | } 83 | 84 | switch (def.unknownKeys) { 85 | case "passthrough": 86 | return refs.allowedAdditionalProperties; 87 | case "strict": 88 | return refs.rejectedAdditionalProperties; 89 | case "strip": 90 | return refs.removeAdditionalStrategy === "strict" 91 | ? refs.allowedAdditionalProperties 92 | : refs.rejectedAdditionalProperties; 93 | } 94 | } 95 | 96 | function safeIsOptional(schema: ZodTypeAny): boolean { 97 | try { 98 | return schema.isOptional(); 99 | } catch { 100 | return true; 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /src/parsers/bigint.ts: -------------------------------------------------------------------------------- 1 | import { ZodBigIntDef } from "zod/v3"; 2 | import { Refs } from "../Refs.js"; 3 | import { ErrorMessages, setResponseValueAndErrors } from "../errorMessages.js"; 4 | 5 | export type JsonSchema7BigintType = { 6 | type: "integer"; 7 | format: "int64"; 8 | minimum?: BigInt; 9 | exclusiveMinimum?: BigInt; 10 | maximum?: BigInt; 11 | exclusiveMaximum?: BigInt; 12 | multipleOf?: BigInt; 13 | errorMessage?: ErrorMessages; 14 | }; 15 | 16 | export function parseBigintDef( 17 | def: ZodBigIntDef, 18 | refs: Refs, 19 | ): JsonSchema7BigintType { 20 | const res: JsonSchema7BigintType = { 21 | type: "integer", 22 | format: "int64", 23 | }; 24 | 25 | if (!def.checks) return res; 26 | 27 | for (const check of def.checks) { 28 | switch (check.kind) { 29 | case "min": 30 | if (refs.target === "jsonSchema7") { 31 | if (check.inclusive) { 32 | setResponseValueAndErrors( 33 | res, 34 | "minimum", 35 | check.value, 36 | check.message, 37 | refs, 38 | ); 39 | } else { 40 | setResponseValueAndErrors( 41 | res, 42 | "exclusiveMinimum", 43 | check.value, 44 | check.message, 45 | refs, 46 | ); 47 | } 48 | } else { 49 | if (!check.inclusive) { 50 | res.exclusiveMinimum = true as any; 51 | } 52 | setResponseValueAndErrors( 53 | res, 54 | "minimum", 55 | check.value, 56 | check.message, 57 | refs, 58 | ); 59 | } 60 | break; 61 | case "max": 62 | if (refs.target === "jsonSchema7") { 63 | if (check.inclusive) { 64 | setResponseValueAndErrors( 65 | res, 66 | "maximum", 67 | check.value, 68 | check.message, 69 | refs, 70 | ); 71 | } else { 72 | setResponseValueAndErrors( 73 | res, 74 | "exclusiveMaximum", 75 | check.value, 76 | check.message, 77 | refs, 78 | ); 79 | } 80 | } else { 81 | if (!check.inclusive) { 82 | res.exclusiveMaximum = true as any; 83 | } 84 | setResponseValueAndErrors( 85 | res, 86 | "maximum", 87 | check.value, 88 | check.message, 89 | refs, 90 | ); 91 | } 92 | break; 93 | case "multipleOf": 94 | setResponseValueAndErrors( 95 | res, 96 | "multipleOf", 97 | check.value, 98 | check.message, 99 | refs, 100 | ); 101 | break; 102 | } 103 | } 104 | return res; 105 | } 106 | -------------------------------------------------------------------------------- /src/parsers/number.ts: -------------------------------------------------------------------------------- 1 | import { ZodNumberDef } from "zod/v3"; 2 | import { 3 | addErrorMessage, 4 | ErrorMessages, 5 | setResponseValueAndErrors, 6 | } from "../errorMessages.js"; 7 | import { Refs } from "../Refs.js"; 8 | 9 | export type JsonSchema7NumberType = { 10 | type: "number" | "integer"; 11 | minimum?: number; 12 | exclusiveMinimum?: number; 13 | maximum?: number; 14 | exclusiveMaximum?: number; 15 | multipleOf?: number; 16 | errorMessage?: ErrorMessages; 17 | }; 18 | 19 | export function parseNumberDef( 20 | def: ZodNumberDef, 21 | refs: Refs, 22 | ): JsonSchema7NumberType { 23 | const res: JsonSchema7NumberType = { 24 | type: "number", 25 | }; 26 | 27 | if (!def.checks) return res; 28 | 29 | for (const check of def.checks) { 30 | switch (check.kind) { 31 | case "int": 32 | res.type = "integer"; 33 | addErrorMessage(res, "type", check.message, refs); 34 | break; 35 | case "min": 36 | if (refs.target === "jsonSchema7") { 37 | if (check.inclusive) { 38 | setResponseValueAndErrors( 39 | res, 40 | "minimum", 41 | check.value, 42 | check.message, 43 | refs, 44 | ); 45 | } else { 46 | setResponseValueAndErrors( 47 | res, 48 | "exclusiveMinimum", 49 | check.value, 50 | check.message, 51 | refs, 52 | ); 53 | } 54 | } else { 55 | if (!check.inclusive) { 56 | res.exclusiveMinimum = true as any; 57 | } 58 | setResponseValueAndErrors( 59 | res, 60 | "minimum", 61 | check.value, 62 | check.message, 63 | refs, 64 | ); 65 | } 66 | break; 67 | case "max": 68 | if (refs.target === "jsonSchema7") { 69 | if (check.inclusive) { 70 | setResponseValueAndErrors( 71 | res, 72 | "maximum", 73 | check.value, 74 | check.message, 75 | refs, 76 | ); 77 | } else { 78 | setResponseValueAndErrors( 79 | res, 80 | "exclusiveMaximum", 81 | check.value, 82 | check.message, 83 | refs, 84 | ); 85 | } 86 | } else { 87 | if (!check.inclusive) { 88 | res.exclusiveMaximum = true as any; 89 | } 90 | setResponseValueAndErrors( 91 | res, 92 | "maximum", 93 | check.value, 94 | check.message, 95 | refs, 96 | ); 97 | } 98 | break; 99 | case "multipleOf": 100 | setResponseValueAndErrors( 101 | res, 102 | "multipleOf", 103 | check.value, 104 | check.message, 105 | refs, 106 | ); 107 | break; 108 | } 109 | } 110 | return res; 111 | } 112 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "zod-to-json-schema", 3 | "version": "3.25.0", 4 | "description": "Converts Zod schemas to Json Schemas", 5 | "types": "./dist/types/index.d.ts", 6 | "main": "./dist/cjs/index.js", 7 | "module": "./dist/esm/index.js", 8 | "exports": { 9 | "import": { 10 | "types": "./dist/types/index.d.ts", 11 | "default": "./dist/esm/index.js" 12 | }, 13 | "require": { 14 | "types": "./dist/types/index.d.ts", 15 | "default": "./dist/cjs/index.js" 16 | } 17 | }, 18 | "scripts": { 19 | "build:test": "npm --prefix ./dist-test-v3 test && npm --prefix ./dist-test-v4 test", 20 | "build:types": "tsc -p tsconfig.types.json", 21 | "build:cjs": "tsc -p tsconfig.cjs.json && tsx postcjs.ts", 22 | "build:esm": "tsc -p tsconfig.esm.json && tsx postesm.ts", 23 | "build": "npm i && npm run gen && npm test && rimraf ./dist && npm run build:types && npm run build:cjs && npm run build:esm && npm run build:test", 24 | "dry": "npm run build && npm pub --dry-run", 25 | "test:watch": "tsx watch test/index.ts", 26 | "test:gen": "tsx test/createIndex.ts", 27 | "test": "tsx test/index.ts", 28 | "gen": "tsx createIndex.ts" 29 | }, 30 | "keywords": ["zod", "json", "schema", "open", "api", "conversion"], 31 | "author": "Stefan Terdell", 32 | "contributors": [ 33 | "Hammad Asif (https://github.com/mrhammadasif)", 34 | "Noah Rosenzweig (https://github.com/Noah2610)", 35 | "John Wright (https://github.com/johngeorgewright)", 36 | "Krzysztof Ciombor (https://github.com/krzysztofciombor)", 37 | "Yuta Mombetsu (https://github.com/mokocm)", 38 | "Tom Arad (https://github.com/tomarad)", 39 | "Isaac Way (https://github.com/iway1)", 40 | "Andreas Berger (https://github.com/Andy2003)", 41 | "Jan Potoms (https://github.com/Janpot)", 42 | "Santiago Cammi (https://github.com/scammi)", 43 | "Philipp Burckhardt (https://github.com/Planeshifter)", 44 | "Bram del Canho (https://github.com/Bram-dc)", 45 | "Gilad Hecht (https://github.com/gthecht)", 46 | "Colin McDonnell (https://github.com/colinhacks)", 47 | "Spappz (https://github.com/Spappz)", 48 | "Jacob Lee (https://github.com/jacoblee93)", 49 | "Brett Zamir (https://github.com/brettz9)", 50 | "Isaiah Marc Sanchez (https://github.com/imsanchez)", 51 | "Mitchell Merry (https://github.com/mitchell-merry)", 52 | "Enzo Monjardín (https://github.com/enzomonjardin)", 53 | "Víctor Hernández (https://github.com/NanezX)", 54 | "Faïz Hernawan Abdillah (https://github.com/Abdillah)" 55 | ], 56 | "repository": { 57 | "type": "git", 58 | "url": "https://github.com/StefanTerdell/zod-to-json-schema" 59 | }, 60 | "license": "ISC", 61 | "peerDependencies": { 62 | "zod": "^3.25 || ^4" 63 | }, 64 | "devDependencies": { 65 | "@types/json-schema": "^7.0.9", 66 | "@types/node": "^20.9.0", 67 | "ajv": "^8.6.3", 68 | "ajv-errors": "^3.0.0", 69 | "ajv-formats": "^2.1.1", 70 | "fast-diff": "^1.3.0", 71 | "local-ref-resolver": "^0.2.0", 72 | "rimraf": "^3.0.2", 73 | "tsx": "^4.19.0", 74 | "typescript": "^5.1.3", 75 | "zod": "^3.25 || ^4" 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /test/parsers/nativeEnum.test.ts: -------------------------------------------------------------------------------- 1 | import { JSONSchema7Type } from "json-schema"; 2 | import { z } from "zod/v3"; 3 | import { parseNativeEnumDef } from "../../src/parsers/nativeEnum.js"; 4 | import { suite } from "../suite.js"; 5 | 6 | suite("Native enums", (test) => { 7 | test("should be possible to convert a basic native number enum", (assert) => { 8 | enum MyEnum { 9 | val1, 10 | val2, 11 | val3, 12 | } 13 | 14 | const parsedSchema = parseNativeEnumDef(z.nativeEnum(MyEnum)._def); 15 | const jsonSchema: JSONSchema7Type = { 16 | type: "number", 17 | enum: [0, 1, 2], 18 | }; 19 | assert(parsedSchema, jsonSchema); 20 | }); 21 | 22 | test("should be possible to convert a native string enum", (assert) => { 23 | enum MyEnum { 24 | val1 = "a", 25 | val2 = "b", 26 | val3 = "c", 27 | } 28 | 29 | const parsedSchema = parseNativeEnumDef(z.nativeEnum(MyEnum)._def); 30 | const jsonSchema: JSONSchema7Type = { 31 | type: "string", 32 | enum: ["a", "b", "c"], 33 | }; 34 | assert(parsedSchema, jsonSchema); 35 | }); 36 | 37 | test("should be possible to convert a mixed value native enum", (assert) => { 38 | enum MyEnum { 39 | val1 = "a", 40 | val2 = 1, 41 | val3 = "c", 42 | } 43 | 44 | const parsedSchema = parseNativeEnumDef(z.nativeEnum(MyEnum)._def); 45 | const jsonSchema: JSONSchema7Type = { 46 | type: ["string", "number"], 47 | enum: ["a", 1, "c"], 48 | }; 49 | assert(parsedSchema, jsonSchema); 50 | }); 51 | 52 | test("should be possible to convert a native const assertion object", (assert) => { 53 | const MyConstAssertionObject = { 54 | val1: 0, 55 | val2: 1, 56 | val3: 2, 57 | } as const; 58 | 59 | const parsedSchema = parseNativeEnumDef( 60 | z.nativeEnum(MyConstAssertionObject)._def, 61 | ); 62 | const jsonSchema: JSONSchema7Type = { 63 | type: "number", 64 | enum: [0, 1, 2], 65 | }; 66 | assert(parsedSchema, jsonSchema); 67 | }); 68 | 69 | test("should be possible to convert a native const assertion string object", (assert) => { 70 | const MyConstAssertionObject = { 71 | val1: "a", 72 | val2: "b", 73 | val3: "c", 74 | } as const; 75 | 76 | const parsedSchema = parseNativeEnumDef( 77 | z.nativeEnum(MyConstAssertionObject)._def, 78 | ); 79 | const jsonSchema: JSONSchema7Type = { 80 | type: "string", 81 | enum: ["a", "b", "c"], 82 | }; 83 | assert(parsedSchema, jsonSchema); 84 | }); 85 | 86 | test("should be possible to convert a mixed value native const assertion string object", (assert) => { 87 | const MyConstAssertionObject = { 88 | val1: "a", 89 | val2: 1, 90 | val3: "c", 91 | } as const; 92 | 93 | const parsedSchema = parseNativeEnumDef( 94 | z.nativeEnum(MyConstAssertionObject)._def, 95 | ); 96 | const jsonSchema: JSONSchema7Type = { 97 | type: ["string", "number"], 98 | enum: ["a", 1, "c"], 99 | }; 100 | assert(parsedSchema, jsonSchema); 101 | }); 102 | }); 103 | -------------------------------------------------------------------------------- /src/parseDef.ts: -------------------------------------------------------------------------------- 1 | import { ZodTypeDef } from "zod/v3"; 2 | import { Refs, Seen } from "./Refs.js"; 3 | import { ignoreOverride } from "./Options.js"; 4 | import { JsonSchema7Type } from "./parseTypes.js"; 5 | import { selectParser } from "./selectParser.js"; 6 | import { getRelativePath } from "./getRelativePath.js"; 7 | import { parseAnyDef } from "./parsers/any.js"; 8 | 9 | export function parseDef( 10 | def: ZodTypeDef, 11 | refs: Refs, 12 | forceResolution = false, // Forces a new schema to be instantiated even though its def has been seen. Used for improving refs in definitions. See https://github.com/StefanTerdell/zod-to-json-schema/pull/61. 13 | ): JsonSchema7Type | undefined { 14 | const seenItem = refs.seen.get(def); 15 | 16 | if (refs.override) { 17 | const overrideResult = refs.override?.( 18 | def, 19 | refs, 20 | seenItem, 21 | forceResolution, 22 | ); 23 | 24 | if (overrideResult !== ignoreOverride) { 25 | return overrideResult; 26 | } 27 | } 28 | 29 | if (seenItem && !forceResolution) { 30 | const seenSchema = get$ref(seenItem, refs); 31 | 32 | if (seenSchema !== undefined) { 33 | return seenSchema; 34 | } 35 | } 36 | 37 | const newItem: Seen = { def, path: refs.currentPath, jsonSchema: undefined }; 38 | 39 | refs.seen.set(def, newItem); 40 | 41 | const jsonSchemaOrGetter = selectParser(def, (def as any).typeName, refs); 42 | 43 | // If the return was a function, then the inner definition needs to be extracted before a call to parseDef (recursive) 44 | const jsonSchema = 45 | typeof jsonSchemaOrGetter === "function" 46 | ? parseDef(jsonSchemaOrGetter(), refs) 47 | : jsonSchemaOrGetter; 48 | 49 | if (jsonSchema) { 50 | addMeta(def, refs, jsonSchema); 51 | } 52 | 53 | if (refs.postProcess) { 54 | const postProcessResult = refs.postProcess(jsonSchema, def, refs); 55 | 56 | newItem.jsonSchema = jsonSchema; 57 | 58 | return postProcessResult; 59 | } 60 | 61 | newItem.jsonSchema = jsonSchema; 62 | 63 | return jsonSchema; 64 | } 65 | 66 | const get$ref = ( 67 | item: Seen, 68 | refs: Refs, 69 | ): 70 | | { 71 | $ref: string; 72 | } 73 | | {} 74 | | undefined => { 75 | switch (refs.$refStrategy) { 76 | case "root": 77 | return { $ref: item.path.join("/") }; 78 | case "relative": 79 | return { $ref: getRelativePath(refs.currentPath, item.path) }; 80 | case "none": 81 | case "seen": { 82 | if ( 83 | item.path.length < refs.currentPath.length && 84 | item.path.every((value, index) => refs.currentPath[index] === value) 85 | ) { 86 | console.warn( 87 | `Recursive reference detected at ${refs.currentPath.join( 88 | "/", 89 | )}! Defaulting to any`, 90 | ); 91 | 92 | return parseAnyDef(refs); 93 | } 94 | 95 | return refs.$refStrategy === "seen" ? parseAnyDef(refs) : undefined; 96 | } 97 | } 98 | }; 99 | 100 | const addMeta = ( 101 | def: ZodTypeDef, 102 | refs: Refs, 103 | jsonSchema: JsonSchema7Type, 104 | ): JsonSchema7Type => { 105 | if (def.description) { 106 | jsonSchema.description = def.description; 107 | 108 | if (refs.markdownDescription) { 109 | jsonSchema.markdownDescription = def.description; 110 | } 111 | } 112 | return jsonSchema; 113 | }; 114 | -------------------------------------------------------------------------------- /src/parsers/record.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ZodFirstPartyTypeKind, 3 | ZodMapDef, 4 | ZodRecordDef, 5 | ZodTypeAny, 6 | } from "zod/v3"; 7 | import { parseDef } from "../parseDef.js"; 8 | import { JsonSchema7Type } from "../parseTypes.js"; 9 | import { Refs } from "../Refs.js"; 10 | import { JsonSchema7EnumType } from "./enum.js"; 11 | import { JsonSchema7ObjectType } from "./object.js"; 12 | import { JsonSchema7StringType, parseStringDef } from "./string.js"; 13 | import { parseBrandedDef } from "./branded.js"; 14 | import { parseAnyDef } from "./any.js"; 15 | 16 | type JsonSchema7RecordPropertyNamesType = 17 | | Omit 18 | | Omit; 19 | 20 | export type JsonSchema7RecordType = { 21 | type: "object"; 22 | additionalProperties?: JsonSchema7Type | true; 23 | propertyNames?: JsonSchema7RecordPropertyNamesType; 24 | }; 25 | 26 | export function parseRecordDef( 27 | def: ZodRecordDef | ZodMapDef, 28 | refs: Refs, 29 | ): JsonSchema7RecordType { 30 | if (refs.target === "openAi") { 31 | console.warn( 32 | "Warning: OpenAI may not support records in schemas! Try an array of key-value pairs instead.", 33 | ); 34 | } 35 | 36 | if ( 37 | refs.target === "openApi3" && 38 | def.keyType?._def.typeName === ZodFirstPartyTypeKind.ZodEnum 39 | ) { 40 | return { 41 | type: "object", 42 | required: def.keyType._def.values, 43 | properties: def.keyType._def.values.reduce( 44 | (acc: Record, key: string) => ({ 45 | ...acc, 46 | [key]: 47 | parseDef(def.valueType._def, { 48 | ...refs, 49 | currentPath: [...refs.currentPath, "properties", key], 50 | }) ?? parseAnyDef(refs), 51 | }), 52 | {}, 53 | ), 54 | additionalProperties: refs.rejectedAdditionalProperties, 55 | } satisfies JsonSchema7ObjectType as any; 56 | } 57 | 58 | const schema: JsonSchema7RecordType = { 59 | type: "object", 60 | additionalProperties: 61 | parseDef(def.valueType._def, { 62 | ...refs, 63 | currentPath: [...refs.currentPath, "additionalProperties"], 64 | }) ?? refs.allowedAdditionalProperties, 65 | }; 66 | 67 | if (refs.target === "openApi3") { 68 | return schema; 69 | } 70 | 71 | if ( 72 | def.keyType?._def.typeName === ZodFirstPartyTypeKind.ZodString && 73 | def.keyType._def.checks?.length 74 | ) { 75 | const { type, ...keyType } = parseStringDef(def.keyType._def, refs); 76 | 77 | return { 78 | ...schema, 79 | propertyNames: keyType, 80 | }; 81 | } else if (def.keyType?._def.typeName === ZodFirstPartyTypeKind.ZodEnum) { 82 | return { 83 | ...schema, 84 | propertyNames: { 85 | enum: def.keyType._def.values, 86 | }, 87 | }; 88 | } else if ( 89 | def.keyType?._def.typeName === ZodFirstPartyTypeKind.ZodBranded && 90 | def.keyType._def.type._def.typeName === ZodFirstPartyTypeKind.ZodString && 91 | def.keyType._def.type._def.checks?.length 92 | ) { 93 | const { type, ...keyType } = parseBrandedDef( 94 | def.keyType._def, 95 | refs, 96 | ) as JsonSchema7StringType; 97 | 98 | return { 99 | ...schema, 100 | propertyNames: keyType, 101 | }; 102 | } 103 | 104 | return schema; 105 | } 106 | -------------------------------------------------------------------------------- /test/allParsersSchema.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod/v3"; 2 | 3 | enum nativeEnum { 4 | "a", 5 | "b", 6 | "c", 7 | } 8 | 9 | export const allParsersSchema = z 10 | .object({ 11 | any: z.any(), 12 | array: z.array(z.any()), 13 | arrayMin: z.array(z.any()).min(1), 14 | arrayMax: z.array(z.any()).max(1), 15 | arrayMinMax: z.array(z.any()).min(1).max(1), 16 | bigInt: z.bigint(), 17 | boolean: z.boolean(), 18 | date: z.date(), 19 | default: z.any().default(42), 20 | effectRefine: z.string().refine((x) => x + x), 21 | effectTransform: z.string().transform((x) => !!x), 22 | effectPreprocess: z.preprocess((x) => { 23 | try { 24 | return JSON.stringify(x); 25 | } catch { 26 | return "wahh"; 27 | } 28 | }, z.string()), 29 | enum: z.enum(["hej", "svejs"]), 30 | intersection: z.intersection(z.string().min(1), z.string().max(4)), 31 | literal: z.literal("hej"), 32 | map: z.map(z.string().uuid(), z.boolean()), 33 | nativeEnum: z.nativeEnum(nativeEnum), 34 | never: z.never() as any, 35 | null: z.null(), 36 | nullablePrimitive: z.string().nullable(), 37 | nullableObject: z.object({ hello: z.string() }).nullable(), 38 | number: z.number(), 39 | numberGt: z.number().gt(1), 40 | numberLt: z.number().lt(1), 41 | numberGtLt: z.number().gt(1).lt(1), 42 | numberGte: z.number().gte(1), 43 | numberLte: z.number().lte(1), 44 | numberGteLte: z.number().gte(1).lte(1), 45 | numberMultipleOf: z.number().multipleOf(2), 46 | numberInt: z.number().int(), 47 | objectPasstrough: z 48 | .object({ foo: z.string(), bar: z.number().optional() }) 49 | .passthrough(), 50 | objectCatchall: z 51 | .object({ foo: z.string(), bar: z.number().optional() }) 52 | .catchall(z.boolean()), 53 | objectStrict: z 54 | .object({ foo: z.string(), bar: z.number().optional() }) 55 | .strict(), 56 | objectStrip: z 57 | .object({ foo: z.string(), bar: z.number().optional() }) 58 | .strip(), 59 | promise: z.promise(z.string()), 60 | recordStringBoolean: z.record(z.string(), z.boolean()), 61 | recordUuidBoolean: z.record(z.string().uuid(), z.boolean()), 62 | recordBooleanBoolean: z.record(z.boolean(), z.boolean()), 63 | set: z.set(z.string()), 64 | string: z.string(), 65 | stringMin: z.string().min(1), 66 | stringMax: z.string().max(1), 67 | stringEmail: z.string().email(), 68 | stringEmoji: z.string().emoji(), 69 | stringUrl: z.string().url(), 70 | stringUuid: z.string().uuid(), 71 | stringRegEx: z.string().regex(new RegExp("abc")), 72 | stringCuid: z.string().cuid(), 73 | tuple: z.tuple([z.string(), z.number(), z.boolean()]), 74 | undefined: z.undefined(), 75 | unionPrimitives: z.union([ 76 | z.string(), 77 | z.number(), 78 | z.boolean(), 79 | z.bigint(), 80 | z.null(), 81 | ]), 82 | unionPrimitiveLiterals: z.union([ 83 | z.literal(123), 84 | z.literal("abc"), 85 | z.literal(null), 86 | z.literal(true), 87 | // z.literal(1n), // target es2020 88 | ]), 89 | unionNonPrimitives: z.union([ 90 | z.string(), 91 | z.object({ 92 | foo: z.string(), 93 | bar: z.number().optional(), 94 | }), 95 | ]), 96 | unknown: z.unknown(), 97 | }) 98 | .partial() 99 | .default({ string: "hello" }) 100 | .describe("watup"); 101 | -------------------------------------------------------------------------------- /test/parsers/record.test.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod/v3"; 2 | import { parseRecordDef } from "../../src/parsers/record.js"; 3 | import { getRefs } from "../../src/Refs.js"; 4 | import { suite } from "../suite.js"; 5 | 6 | suite("records", (test) => { 7 | test("should be possible to describe a simple record", (assert) => { 8 | const schema = z.record(z.number()); 9 | 10 | const parsedSchema = parseRecordDef(schema._def, getRefs()); 11 | const expectedSchema = { 12 | type: "object", 13 | additionalProperties: { 14 | type: "number", 15 | }, 16 | }; 17 | assert(parsedSchema, expectedSchema); 18 | }); 19 | test("should be possible to describe a simple record with a branded key", (assert) => { 20 | const schema = z.record(z.string().brand("MyBrand"), z.number()); 21 | 22 | const parsedSchema = parseRecordDef(schema._def, getRefs()); 23 | const expectedSchema = { 24 | type: "object", 25 | additionalProperties: { 26 | type: "number", 27 | }, 28 | }; 29 | assert(parsedSchema, expectedSchema); 30 | }); 31 | 32 | test("should be possible to describe a complex record with checks", (assert) => { 33 | const schema = z.record( 34 | z.object({ foo: z.number().min(2) }).catchall(z.string().cuid()), 35 | ); 36 | 37 | const parsedSchema = parseRecordDef(schema._def, getRefs()); 38 | const expectedSchema = { 39 | type: "object", 40 | additionalProperties: { 41 | type: "object", 42 | properties: { 43 | foo: { 44 | type: "number", 45 | minimum: 2, 46 | }, 47 | }, 48 | required: ["foo"], 49 | additionalProperties: { 50 | type: "string", 51 | pattern: "^[cC][^\\s-]{8,}$", 52 | }, 53 | }, 54 | }; 55 | assert(parsedSchema, expectedSchema); 56 | }); 57 | 58 | test("should be possible to describe a key schema", (assert) => { 59 | const schema = z.record(z.string().uuid(), z.number()); 60 | 61 | const parsedSchema = parseRecordDef(schema._def, getRefs()); 62 | const expectedSchema = { 63 | type: "object", 64 | additionalProperties: { 65 | type: "number", 66 | }, 67 | propertyNames: { 68 | format: "uuid", 69 | }, 70 | }; 71 | assert(parsedSchema, expectedSchema); 72 | }); 73 | 74 | test("should be possible to describe a branded key schema", (assert) => { 75 | const schema = z.record( 76 | z.string().regex(/.+/).brand("MyBrandedThingo"), 77 | z.number(), 78 | ); 79 | 80 | const parsedSchema = parseRecordDef(schema._def, getRefs()); 81 | const expectedSchema = { 82 | type: "object", 83 | additionalProperties: { 84 | type: "number", 85 | }, 86 | propertyNames: { 87 | pattern: ".+", 88 | }, 89 | }; 90 | assert(parsedSchema, expectedSchema); 91 | }); 92 | 93 | test("should be possible to describe a key with an enum", (assert) => { 94 | const schema = z.record(z.enum(["foo", "bar"]), z.number()); 95 | const parsedSchema = parseRecordDef(schema._def, getRefs()); 96 | const expectedSchema = { 97 | type: "object", 98 | additionalProperties: { 99 | type: "number", 100 | }, 101 | propertyNames: { 102 | enum: ["foo", "bar"], 103 | }, 104 | }; 105 | assert(parsedSchema, expectedSchema); 106 | }); 107 | }); 108 | -------------------------------------------------------------------------------- /src/Options.ts: -------------------------------------------------------------------------------- 1 | import { ZodSchema, ZodTypeDef } from "zod/v3"; 2 | import { Refs, Seen } from "./Refs"; 3 | import { JsonSchema7Type } from "./parseTypes"; 4 | 5 | export type Targets = 6 | | "jsonSchema7" 7 | | "jsonSchema2019-09" 8 | | "openApi3" 9 | | "openAi"; 10 | 11 | export type DateStrategy = 12 | | "format:date-time" 13 | | "format:date" 14 | | "string" 15 | | "integer"; 16 | 17 | export const ignoreOverride = Symbol( 18 | "Let zodToJsonSchema decide on which parser to use", 19 | ); 20 | 21 | export type OverrideCallback = ( 22 | def: ZodTypeDef, 23 | refs: Refs, 24 | seen: Seen | undefined, 25 | forceResolution?: boolean, 26 | ) => JsonSchema7Type | undefined | typeof ignoreOverride; 27 | 28 | export type PostProcessCallback = ( 29 | jsonSchema: JsonSchema7Type | undefined, 30 | def: ZodTypeDef, 31 | refs: Refs, 32 | ) => JsonSchema7Type | undefined; 33 | 34 | export const jsonDescription: PostProcessCallback = (jsonSchema, def) => { 35 | if (def.description) { 36 | try { 37 | return { 38 | ...jsonSchema, 39 | ...JSON.parse(def.description), 40 | }; 41 | } catch {} 42 | } 43 | 44 | return jsonSchema; 45 | }; 46 | 47 | export type Options = { 48 | name: string | undefined; 49 | $refStrategy: "root" | "relative" | "none" | "seen"; 50 | basePath: string[]; 51 | effectStrategy: "input" | "any"; 52 | pipeStrategy: "input" | "output" | "all"; 53 | dateStrategy: DateStrategy | DateStrategy[]; 54 | mapStrategy: "entries" | "record"; 55 | removeAdditionalStrategy: "passthrough" | "strict"; 56 | allowedAdditionalProperties: true | undefined; 57 | rejectedAdditionalProperties: false | undefined; 58 | target: Target; 59 | strictUnions: boolean; 60 | definitionPath: string; 61 | definitions: Record; 62 | errorMessages: boolean; 63 | markdownDescription: boolean; 64 | patternStrategy: "escape" | "preserve"; 65 | applyRegexFlags: boolean; 66 | emailStrategy: "format:email" | "format:idn-email" | "pattern:zod"; 67 | base64Strategy: "format:binary" | "contentEncoding:base64" | "pattern:zod"; 68 | nameStrategy: "ref" | "title"; 69 | override?: OverrideCallback; 70 | postProcess?: PostProcessCallback; 71 | openAiAnyTypeName: string 72 | }; 73 | 74 | export const defaultOptions: Options = { 75 | name: undefined, 76 | $refStrategy: "root", 77 | basePath: ["#"], 78 | effectStrategy: "input", 79 | pipeStrategy: "all", 80 | dateStrategy: "format:date-time", 81 | mapStrategy: "entries", 82 | removeAdditionalStrategy: "passthrough", 83 | allowedAdditionalProperties: true, 84 | rejectedAdditionalProperties: false, 85 | definitionPath: "definitions", 86 | target: "jsonSchema7", 87 | strictUnions: false, 88 | definitions: {}, 89 | errorMessages: false, 90 | markdownDescription: false, 91 | patternStrategy: "escape", 92 | applyRegexFlags: false, 93 | emailStrategy: "format:email", 94 | base64Strategy: "contentEncoding:base64", 95 | nameStrategy: "ref", 96 | openAiAnyTypeName: "OpenAiAnyType" 97 | }; 98 | 99 | export const getDefaultOptions = ( 100 | options: Partial> | string | undefined, 101 | ) => 102 | (typeof options === "string" 103 | ? { 104 | ...defaultOptions, 105 | name: options, 106 | } 107 | : { 108 | ...defaultOptions, 109 | ...options, 110 | }) as Options; 111 | -------------------------------------------------------------------------------- /test/zodToJsonSchema.test.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod/v3"; 2 | import { zodToJsonSchema } from "../src/zodToJsonSchema.js"; 3 | import { suite } from "./suite.js"; 4 | 5 | suite("Root schema result after parsing", (it) => { 6 | it("should return the schema directly in the root if no name is passed", (assert) => { 7 | assert(zodToJsonSchema(z.any()), { 8 | $schema: "http://json-schema.org/draft-07/schema#", 9 | }); 10 | }); 11 | it('should return the schema inside a named property in "definitions" if a name is passed', (assert) => { 12 | assert(zodToJsonSchema(z.any(), "MySchema"), { 13 | $schema: "http://json-schema.org/draft-07/schema#", 14 | $ref: `#/definitions/MySchema`, 15 | definitions: { 16 | MySchema: {}, 17 | }, 18 | }); 19 | }); 20 | 21 | it('should return the schema inside a named property in "$defs" if a name and definitionPath is passed in options', (assert) => { 22 | assert( 23 | zodToJsonSchema(z.any(), { name: "MySchema", definitionPath: "$defs" }), 24 | { 25 | $schema: "http://json-schema.org/draft-07/schema#", 26 | $ref: `#/$defs/MySchema`, 27 | $defs: { 28 | MySchema: {}, 29 | }, 30 | }, 31 | ); 32 | }); 33 | 34 | it("should not scrub 'any'-schemas from unions when strictUnions=false", (assert) => { 35 | assert( 36 | zodToJsonSchema( 37 | z.union([z.any(), z.instanceof(String), z.string(), z.number()]), 38 | { strictUnions: false }, 39 | ), 40 | { 41 | $schema: "http://json-schema.org/draft-07/schema#", 42 | anyOf: [{}, {}, { type: "string" }, { type: "number" }], 43 | }, 44 | ); 45 | }); 46 | 47 | it("should scrub 'any'-schemas from unions when strictUnions=true", (assert) => { 48 | assert( 49 | zodToJsonSchema( 50 | z.union([z.any(), z.instanceof(String), z.string(), z.number()]), 51 | { strictUnions: true }, 52 | ), 53 | { 54 | $schema: "http://json-schema.org/draft-07/schema#", 55 | anyOf: [{ type: "string" }, { type: "number" }], 56 | }, 57 | ); 58 | }); 59 | 60 | it("should scrub 'any'-schemas from unions when strictUnions=true in objects", (assert) => { 61 | assert( 62 | zodToJsonSchema( 63 | z.object({ 64 | field: z.union([ 65 | z.any(), 66 | z.instanceof(String), 67 | z.string(), 68 | z.number(), 69 | ]), 70 | }), 71 | { strictUnions: true }, 72 | ), 73 | { 74 | $schema: "http://json-schema.org/draft-07/schema#", 75 | additionalProperties: false, 76 | properties: { 77 | field: { anyOf: [{ type: "string" }, { type: "number" }] }, 78 | }, 79 | type: "object", 80 | }, 81 | ); 82 | }); 83 | 84 | it("Definitions play nice with named schemas", (assert) => { 85 | const MySpecialStringSchema = z.string(); 86 | const MyArraySchema = z.array(MySpecialStringSchema); 87 | 88 | const result = zodToJsonSchema(MyArraySchema, { 89 | definitions: { 90 | MySpecialStringSchema, 91 | MyArraySchema, 92 | }, 93 | }); 94 | 95 | assert(result, { 96 | $schema: "http://json-schema.org/draft-07/schema#", 97 | $ref: "#/definitions/MyArraySchema", 98 | definitions: { 99 | MySpecialStringSchema: { type: "string" }, 100 | MyArraySchema: { 101 | type: "array", 102 | items: { 103 | $ref: "#/definitions/MySpecialStringSchema", 104 | }, 105 | }, 106 | }, 107 | }); 108 | }); 109 | 110 | it("should be possible to add name as title instead of as ref", (assert) => { 111 | assert( 112 | zodToJsonSchema(z.string(), { name: "hello", nameStrategy: "title" }), 113 | { 114 | $schema: "http://json-schema.org/draft-07/schema#", 115 | type: "string", 116 | title: "hello", 117 | }, 118 | ); 119 | }); 120 | }); 121 | -------------------------------------------------------------------------------- /src/zodToJsonSchema.ts: -------------------------------------------------------------------------------- 1 | import { ZodSchema } from "zod/v3"; 2 | import { Options, Targets } from "./Options.js"; 3 | import { parseDef } from "./parseDef.js"; 4 | import { JsonSchema7Type } from "./parseTypes.js"; 5 | import { getRefs } from "./Refs.js"; 6 | import { parseAnyDef } from "./parsers/any.js"; 7 | 8 | const zodToJsonSchema = ( 9 | schema: ZodSchema, 10 | options?: Partial> | string, 11 | ): (Target extends "jsonSchema7" ? JsonSchema7Type : object) & { 12 | $schema?: string; 13 | definitions?: { 14 | [key: string]: Target extends "jsonSchema7" 15 | ? JsonSchema7Type 16 | : Target extends "jsonSchema2019-09" 17 | ? JsonSchema7Type 18 | : object; 19 | }; 20 | } => { 21 | const refs = getRefs(options); 22 | 23 | let definitions = 24 | typeof options === "object" && options.definitions 25 | ? Object.entries(options.definitions).reduce( 26 | (acc: { [key: string]: JsonSchema7Type }, [name, schema]) => ({ 27 | ...acc, 28 | [name]: 29 | parseDef( 30 | schema._def, 31 | { 32 | ...refs, 33 | currentPath: [...refs.basePath, refs.definitionPath, name], 34 | }, 35 | true, 36 | ) ?? parseAnyDef(refs), 37 | }), 38 | {}, 39 | ) 40 | : undefined; 41 | 42 | const name = 43 | typeof options === "string" 44 | ? options 45 | : options?.nameStrategy === "title" 46 | ? undefined 47 | : options?.name; 48 | 49 | const main = 50 | parseDef( 51 | schema._def, 52 | name === undefined 53 | ? refs 54 | : { 55 | ...refs, 56 | currentPath: [...refs.basePath, refs.definitionPath, name], 57 | }, 58 | false, 59 | ) ?? (parseAnyDef(refs) as JsonSchema7Type); 60 | 61 | const title = 62 | typeof options === "object" && 63 | options.name !== undefined && 64 | options.nameStrategy === "title" 65 | ? options.name 66 | : undefined; 67 | 68 | if (title !== undefined) { 69 | main.title = title; 70 | } 71 | 72 | if (refs.flags.hasReferencedOpenAiAnyType) { 73 | if (!definitions) { 74 | definitions = {}; 75 | } 76 | 77 | if (!definitions[refs.openAiAnyTypeName]) { 78 | definitions[refs.openAiAnyTypeName] = { 79 | // Skipping "object" as no properties can be defined and additionalProperties must be "false" 80 | type: ["string", "number", "integer", "boolean", "array", "null"], 81 | items: { 82 | $ref: 83 | refs.$refStrategy === "relative" 84 | ? "1" 85 | : [ 86 | ...refs.basePath, 87 | refs.definitionPath, 88 | refs.openAiAnyTypeName, 89 | ].join("/"), 90 | }, 91 | } as JsonSchema7Type; 92 | } 93 | } 94 | 95 | const combined: ReturnType> = 96 | name === undefined 97 | ? definitions 98 | ? { 99 | ...main, 100 | [refs.definitionPath]: definitions, 101 | } 102 | : main 103 | : { 104 | $ref: [ 105 | ...(refs.$refStrategy === "relative" ? [] : refs.basePath), 106 | refs.definitionPath, 107 | name, 108 | ].join("/"), 109 | [refs.definitionPath]: { 110 | ...definitions, 111 | [name]: main, 112 | }, 113 | }; 114 | 115 | if (refs.target === "jsonSchema7") { 116 | combined.$schema = "http://json-schema.org/draft-07/schema#"; 117 | } else if (refs.target === "jsonSchema2019-09" || refs.target === "openAi") { 118 | combined.$schema = "https://json-schema.org/draft/2019-09/schema#"; 119 | } 120 | 121 | if ( 122 | refs.target === "openAi" && 123 | ("anyOf" in combined || 124 | "oneOf" in combined || 125 | "allOf" in combined || 126 | ("type" in combined && Array.isArray(combined.type))) 127 | ) { 128 | console.warn( 129 | "Warning: OpenAI may not support schemas with unions as roots! Try wrapping it in an object property.", 130 | ); 131 | } 132 | 133 | return combined; 134 | }; 135 | 136 | export { zodToJsonSchema }; 137 | -------------------------------------------------------------------------------- /test/parsers/optional.test.ts: -------------------------------------------------------------------------------- 1 | import { JSONSchema7Type } from "json-schema"; 2 | import { z } from "zod/v3"; 3 | import { parseDef } from "../../src/parseDef.js"; 4 | import { getRefs } from "../../src/Refs.js"; 5 | import { suite } from "../suite.js"; 6 | 7 | suite("Standalone optionals", (test) => { 8 | test("should work as unions with undefined", (assert) => { 9 | const parsedSchema = parseDef(z.string().optional()._def, getRefs()); 10 | 11 | const jsonSchema: JSONSchema7Type = { 12 | anyOf: [ 13 | { 14 | not: {}, 15 | }, 16 | { 17 | type: "string", 18 | }, 19 | ], 20 | }; 21 | 22 | assert(parsedSchema, jsonSchema); 23 | }); 24 | 25 | test("should work as unions with void", (assert) => { 26 | const parsedSchema = parseDef(z.void().optional()._def, getRefs()); 27 | 28 | const jsonSchema: JSONSchema7Type = {}; 29 | 30 | assert(parsedSchema, jsonSchema); 31 | }); 32 | 33 | test("should not affect object properties", (assert) => { 34 | const parsedSchema = parseDef( 35 | z.object({ myProperty: z.string().optional() })._def, 36 | getRefs(), 37 | ); 38 | 39 | const jsonSchema: JSONSchema7Type = { 40 | type: "object", 41 | properties: { 42 | myProperty: { 43 | type: "string", 44 | }, 45 | }, 46 | additionalProperties: false, 47 | }; 48 | 49 | assert(parsedSchema, jsonSchema); 50 | }); 51 | 52 | test("should work with nested properties", (assert) => { 53 | const parsedSchema = parseDef( 54 | z.object({ myProperty: z.string().optional().array() })._def, 55 | getRefs(), 56 | ); 57 | 58 | const jsonSchema: JSONSchema7Type = { 59 | type: "object", 60 | properties: { 61 | myProperty: { 62 | type: "array", 63 | items: { 64 | anyOf: [{ not: {} }, { type: "string" }], 65 | }, 66 | }, 67 | }, 68 | required: ["myProperty"], 69 | additionalProperties: false, 70 | }; 71 | 72 | assert(parsedSchema, jsonSchema); 73 | }); 74 | 75 | test("should work with nested properties as object properties", (assert) => { 76 | const parsedSchema = parseDef( 77 | z.object({ 78 | myProperty: z.object({ myInnerProperty: z.string().optional() }), 79 | })._def, 80 | getRefs(), 81 | ); 82 | 83 | const jsonSchema: JSONSchema7Type = { 84 | type: "object", 85 | properties: { 86 | myProperty: { 87 | type: "object", 88 | properties: { 89 | myInnerProperty: { 90 | type: "string", 91 | }, 92 | }, 93 | additionalProperties: false, 94 | }, 95 | }, 96 | required: ["myProperty"], 97 | additionalProperties: false, 98 | }; 99 | 100 | assert(parsedSchema, jsonSchema); 101 | }); 102 | 103 | test("should work with nested properties with nested object property parents", (assert) => { 104 | const parsedSchema = parseDef( 105 | z.object({ 106 | myProperty: z.object({ 107 | myInnerProperty: z.string().optional().array(), 108 | }), 109 | })._def, 110 | getRefs(), 111 | ); 112 | 113 | const jsonSchema: JSONSchema7Type = { 114 | type: "object", 115 | properties: { 116 | myProperty: { 117 | type: "object", 118 | properties: { 119 | myInnerProperty: { 120 | type: "array", 121 | items: { 122 | anyOf: [ 123 | { not: {} }, 124 | { 125 | type: "string", 126 | }, 127 | ], 128 | }, 129 | }, 130 | }, 131 | required: ["myInnerProperty"], 132 | additionalProperties: false, 133 | }, 134 | }, 135 | required: ["myProperty"], 136 | additionalProperties: false, 137 | }; 138 | 139 | assert(parsedSchema, jsonSchema); 140 | }); 141 | 142 | test("should work with ref pathing", (assert) => { 143 | const recurring = z.string(); 144 | 145 | const schema = z.tuple([recurring.optional(), recurring]); 146 | 147 | const parsedSchema = parseDef(schema._def, getRefs()); 148 | 149 | const jsonSchema: JSONSchema7Type = { 150 | type: "array", 151 | minItems: 2, 152 | maxItems: 2, 153 | items: [ 154 | { anyOf: [{ not: {} }, { type: "string" }] }, 155 | { $ref: "#/items/0/anyOf/1" }, 156 | ], 157 | }; 158 | 159 | assert(parsedSchema, jsonSchema); 160 | }); 161 | }); 162 | -------------------------------------------------------------------------------- /test/parsers/date.test.ts: -------------------------------------------------------------------------------- 1 | import { JSONSchema7Type } from "json-schema"; 2 | import { z } from "zod/v3"; 3 | import { parseDateDef } from "../../src/parsers/date.js"; 4 | import { getRefs } from "../../src/Refs.js"; 5 | import { errorReferences } from "./errorReferences.js"; 6 | import { suite } from "../suite.js"; 7 | 8 | suite("Date validations", (test) => { 9 | test("should be possible to date as a string type", (assert) => { 10 | const zodDateSchema = z.date(); 11 | const parsedSchemaWithOption = parseDateDef( 12 | zodDateSchema._def, 13 | getRefs({ dateStrategy: "string" }), 14 | ); 15 | const parsedSchemaFromDefault = parseDateDef(zodDateSchema._def, getRefs()); 16 | 17 | const jsonSchema: JSONSchema7Type = { 18 | type: "string", 19 | format: "date-time", 20 | }; 21 | 22 | assert(parsedSchemaWithOption, jsonSchema); 23 | assert(parsedSchemaFromDefault, jsonSchema); 24 | }); 25 | 26 | test("should be possible to describe date (openApi3)", (assert) => { 27 | const zodDateSchema = z.date(); 28 | const parsedSchema = parseDateDef( 29 | zodDateSchema._def, 30 | getRefs({ dateStrategy: "integer", target: "openApi3" }), 31 | ); 32 | 33 | const jsonSchema: JSONSchema7Type = { 34 | type: "integer", 35 | format: "unix-time", 36 | }; 37 | 38 | assert(parsedSchema, jsonSchema); 39 | }); 40 | 41 | test("should be possible to describe minimum date", (assert) => { 42 | const zodDateSchema = z 43 | .date() 44 | .min(new Date("1970-01-02"), { message: "Too old" }); 45 | const parsedSchema = parseDateDef( 46 | zodDateSchema._def, 47 | getRefs({ dateStrategy: "integer" }), 48 | ); 49 | 50 | const jsonSchema: JSONSchema7Type = { 51 | type: "integer", 52 | format: "unix-time", 53 | minimum: 86400000, 54 | }; 55 | 56 | assert(parsedSchema, jsonSchema); 57 | }); 58 | 59 | test("should be possible to describe maximum date", (assert) => { 60 | const zodDateSchema = z.date().max(new Date("1970-01-02")); 61 | const parsedSchema = parseDateDef( 62 | zodDateSchema._def, 63 | getRefs({ dateStrategy: "integer" }), 64 | ); 65 | 66 | const jsonSchema: JSONSchema7Type = { 67 | type: "integer", 68 | format: "unix-time", 69 | maximum: 86400000, 70 | }; 71 | 72 | assert(parsedSchema, jsonSchema); 73 | }); 74 | 75 | test("should be possible to describe both maximum and minimum date", (assert) => { 76 | const zodDateSchema = z 77 | .date() 78 | .min(new Date("1970-01-02")) 79 | .max(new Date("1972-01-02")); 80 | const parsedSchema = parseDateDef( 81 | zodDateSchema._def, 82 | getRefs({ dateStrategy: "integer" }), 83 | ); 84 | 85 | const jsonSchema: JSONSchema7Type = { 86 | type: "integer", 87 | format: "unix-time", 88 | minimum: 86400000, 89 | maximum: 63158400000, 90 | }; 91 | 92 | assert(parsedSchema, jsonSchema); 93 | }); 94 | 95 | test("should include custom error message for both maximum and minimum if they're passed", (assert) => { 96 | const minimumErrorMessage = "To young"; 97 | const maximumErrorMessage = "To old"; 98 | const zodDateSchema = z 99 | .date() 100 | .min(new Date("1970-01-02"), minimumErrorMessage) 101 | .max(new Date("1972-01-02"), maximumErrorMessage); 102 | 103 | const parsedSchema = parseDateDef( 104 | zodDateSchema._def, 105 | errorReferences({ dateStrategy: "integer" }), 106 | ); 107 | 108 | const jsonSchema: JSONSchema7Type = { 109 | type: "integer", 110 | format: "unix-time", 111 | minimum: 86400000, 112 | maximum: 63158400000, 113 | errorMessage: { 114 | minimum: minimumErrorMessage, 115 | maximum: maximumErrorMessage, 116 | }, 117 | }; 118 | 119 | assert(parsedSchema, jsonSchema); 120 | }); 121 | 122 | test("multiple choices of strategy should result in anyOf", (assert) => { 123 | const zodDateSchema = z.date(); 124 | const parsedSchema = parseDateDef( 125 | zodDateSchema._def, 126 | getRefs({ dateStrategy: ["format:date-time", "format:date", "integer"] }), 127 | ); 128 | 129 | const jsonSchema: JSONSchema7Type = { 130 | anyOf: [ 131 | { 132 | type: "string", 133 | format: "date-time", 134 | }, 135 | { 136 | type: "string", 137 | format: "date", 138 | }, 139 | { 140 | type: "integer", 141 | format: "unix-time", 142 | }, 143 | ], 144 | }; 145 | 146 | assert(parsedSchema, jsonSchema); 147 | }); 148 | }); 149 | -------------------------------------------------------------------------------- /test/override.test.ts: -------------------------------------------------------------------------------- 1 | import { suite } from "./suite.js"; 2 | import zodToJsonSchema, { 3 | PostProcessCallback, 4 | ignoreOverride, 5 | jsonDescription, 6 | } from "../src"; 7 | import { z } from "zod/v3"; 8 | 9 | suite("override", (test) => { 10 | test("the readme example", (assert) => { 11 | assert( 12 | zodToJsonSchema( 13 | z.object({ 14 | ignoreThis: z.string(), 15 | overrideThis: z.string(), 16 | removeThis: z.string(), 17 | }), 18 | { 19 | override: (def, refs) => { 20 | const path = refs.currentPath.join("/"); 21 | 22 | if (path === "#/properties/overrideThis") { 23 | return { 24 | type: "integer", 25 | }; 26 | } 27 | 28 | if (path === "#/properties/removeThis") { 29 | return undefined; 30 | } 31 | 32 | // Important! Do not return `undefined` or void unless you want to remove the property from the resulting schema completely. 33 | return ignoreOverride; 34 | }, 35 | }, 36 | ), 37 | { 38 | $schema: "http://json-schema.org/draft-07/schema#", 39 | type: "object", 40 | required: ["ignoreThis", "overrideThis"], 41 | properties: { 42 | ignoreThis: { 43 | type: "string", 44 | }, 45 | overrideThis: { 46 | type: "integer", 47 | }, 48 | }, 49 | additionalProperties: false, 50 | }, 51 | ); 52 | }); 53 | }); 54 | 55 | suite("postProcess", (test) => { 56 | test("the readme example", (assert) => { 57 | const zodSchema = z.object({ 58 | myString: z.string().describe( 59 | JSON.stringify({ 60 | title: "My string", 61 | description: "My description", 62 | examples: ["Foo", "Bar"], 63 | }), 64 | ), 65 | myNumber: z.number(), 66 | }); 67 | 68 | // Define the callback to be used to process the output using the PostProcessCallback type: 69 | const postProcess: PostProcessCallback = ( 70 | // The original output produced by the package itself: 71 | jsonSchema, 72 | // The ZodSchema def used to produce the original schema: 73 | def, 74 | // The refs object containing the current path, passed options, etc. 75 | refs, 76 | ) => { 77 | if (!jsonSchema) { 78 | return jsonSchema; 79 | } 80 | 81 | // Try to expand description as JSON meta: 82 | if (jsonSchema.description) { 83 | try { 84 | jsonSchema = { 85 | ...jsonSchema, 86 | ...JSON.parse(jsonSchema.description), 87 | }; 88 | } catch {} 89 | } 90 | 91 | // Make all numbers nullable: 92 | if ("type" in jsonSchema! && jsonSchema.type === "number") { 93 | jsonSchema.type = ["number", "null"]; 94 | } 95 | 96 | // Add the refs path, just because 97 | (jsonSchema as any).path = refs.currentPath; 98 | 99 | return jsonSchema; 100 | }; 101 | 102 | const jsonSchemaResult = zodToJsonSchema(zodSchema, { 103 | postProcess, 104 | }); 105 | 106 | const expectedResult = { 107 | $schema: "http://json-schema.org/draft-07/schema#", 108 | type: "object", 109 | required: ["myString", "myNumber"], 110 | properties: { 111 | myString: { 112 | type: "string", 113 | title: "My string", 114 | description: "My description", 115 | examples: ["Foo", "Bar"], 116 | path: ["#", "properties", "myString"], 117 | }, 118 | myNumber: { 119 | type: ["number", "null"], 120 | path: ["#", "properties", "myNumber"], 121 | }, 122 | }, 123 | additionalProperties: false, 124 | path: ["#"], 125 | }; 126 | 127 | assert(jsonSchemaResult, expectedResult); 128 | }); 129 | 130 | test("expanding description json", (assert) => { 131 | const zodSchema = z.string().describe( 132 | JSON.stringify({ 133 | title: "My string", 134 | description: "My description", 135 | examples: ["Foo", "Bar"], 136 | whatever: 123, 137 | }), 138 | ); 139 | 140 | const jsonSchemaResult = zodToJsonSchema(zodSchema, { 141 | postProcess: jsonDescription, 142 | }); 143 | 144 | const expectedJsonSchema = { 145 | $schema: "http://json-schema.org/draft-07/schema#", 146 | type: "string", 147 | title: "My string", 148 | description: "My description", 149 | examples: ["Foo", "Bar"], 150 | whatever: 123, 151 | }; 152 | 153 | assert(jsonSchemaResult, expectedJsonSchema); 154 | }); 155 | }); 156 | -------------------------------------------------------------------------------- /src/parsers/union.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ZodDiscriminatedUnionDef, 3 | ZodLiteralDef, 4 | ZodTypeAny, 5 | ZodUnionDef, 6 | } from "zod/v3"; 7 | import { parseDef } from "../parseDef.js"; 8 | import { JsonSchema7Type } from "../parseTypes.js"; 9 | import { Refs } from "../Refs.js"; 10 | 11 | export const primitiveMappings = { 12 | ZodString: "string", 13 | ZodNumber: "number", 14 | ZodBigInt: "integer", 15 | ZodBoolean: "boolean", 16 | ZodNull: "null", 17 | } as const; 18 | type ZodPrimitive = keyof typeof primitiveMappings; 19 | type JsonSchema7Primitive = 20 | (typeof primitiveMappings)[keyof typeof primitiveMappings]; 21 | 22 | export type JsonSchema7UnionType = 23 | | JsonSchema7PrimitiveUnionType 24 | | JsonSchema7AnyOfType; 25 | 26 | type JsonSchema7PrimitiveUnionType = 27 | | { 28 | type: JsonSchema7Primitive | JsonSchema7Primitive[]; 29 | } 30 | | { 31 | type: JsonSchema7Primitive | JsonSchema7Primitive[]; 32 | enum: (string | number | bigint | boolean | null)[]; 33 | }; 34 | 35 | type JsonSchema7AnyOfType = { 36 | anyOf: JsonSchema7Type[]; 37 | }; 38 | 39 | export function parseUnionDef( 40 | def: ZodUnionDef | ZodDiscriminatedUnionDef, 41 | refs: Refs, 42 | ): JsonSchema7PrimitiveUnionType | JsonSchema7AnyOfType | undefined { 43 | if (refs.target === "openApi3") return asAnyOf(def, refs); 44 | 45 | const options: readonly ZodTypeAny[] = 46 | def.options instanceof Map ? Array.from(def.options.values()) : def.options; 47 | 48 | // This blocks tries to look ahead a bit to produce nicer looking schemas with type array instead of anyOf. 49 | if ( 50 | options.every( 51 | (x) => 52 | x._def.typeName in primitiveMappings && 53 | (!x._def.checks || !x._def.checks.length), 54 | ) 55 | ) { 56 | // all types in union are primitive and lack checks, so might as well squash into {type: [...]} 57 | 58 | const types = options.reduce((types: JsonSchema7Primitive[], x) => { 59 | const type = primitiveMappings[x._def.typeName as ZodPrimitive]; //Can be safely casted due to row 43 60 | return type && !types.includes(type) ? [...types, type] : types; 61 | }, []); 62 | 63 | return { 64 | type: types.length > 1 ? types : types[0], 65 | }; 66 | } else if ( 67 | options.every((x) => x._def.typeName === "ZodLiteral" && !x.description) 68 | ) { 69 | // all options literals 70 | 71 | const types = options.reduce( 72 | (acc: JsonSchema7Primitive[], x: { _def: ZodLiteralDef }) => { 73 | const type = typeof x._def.value; 74 | switch (type) { 75 | case "string": 76 | case "number": 77 | case "boolean": 78 | return [...acc, type]; 79 | case "bigint": 80 | return [...acc, "integer" as const]; 81 | case "object": 82 | if (x._def.value === null) return [...acc, "null" as const]; 83 | case "symbol": 84 | case "undefined": 85 | case "function": 86 | default: 87 | return acc; 88 | } 89 | }, 90 | [], 91 | ); 92 | 93 | if (types.length === options.length) { 94 | // all the literals are primitive, as far as null can be considered primitive 95 | 96 | const uniqueTypes = types.filter((x, i, a) => a.indexOf(x) === i); 97 | return { 98 | type: uniqueTypes.length > 1 ? uniqueTypes : uniqueTypes[0], 99 | enum: options.reduce( 100 | (acc, x) => { 101 | return acc.includes(x._def.value) ? acc : [...acc, x._def.value]; 102 | }, 103 | [] as (string | number | bigint | boolean | null)[], 104 | ), 105 | }; 106 | } 107 | } else if (options.every((x) => x._def.typeName === "ZodEnum")) { 108 | return { 109 | type: "string", 110 | enum: options.reduce( 111 | (acc: string[], x) => [ 112 | ...acc, 113 | ...x._def.values.filter((x: string) => !acc.includes(x)), 114 | ], 115 | [], 116 | ), 117 | }; 118 | } 119 | 120 | return asAnyOf(def, refs); 121 | } 122 | 123 | const asAnyOf = ( 124 | def: ZodUnionDef | ZodDiscriminatedUnionDef, 125 | refs: Refs, 126 | ): JsonSchema7PrimitiveUnionType | JsonSchema7AnyOfType | undefined => { 127 | const anyOf = ( 128 | (def.options instanceof Map 129 | ? Array.from(def.options.values()) 130 | : def.options) as any[] 131 | ) 132 | .map((x, i) => 133 | parseDef(x._def, { 134 | ...refs, 135 | currentPath: [...refs.currentPath, "anyOf", `${i}`], 136 | }), 137 | ) 138 | .filter( 139 | (x): x is JsonSchema7Type => 140 | !!x && 141 | (!refs.strictUnions || 142 | (typeof x === "object" && Object.keys(x).length > 0)), 143 | ); 144 | 145 | return anyOf.length ? { anyOf } : undefined; 146 | }; 147 | -------------------------------------------------------------------------------- /test/parsers/object.test.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod/v3"; 2 | import { parseObjectDef } from "../../src/parsers/object.js"; 3 | import { getRefs } from "../../src/Refs.js"; 4 | import { suite } from "../suite.js"; 5 | 6 | suite("objects", (test) => { 7 | test("should be possible to describe catchAll schema", (assert) => { 8 | const schema = z 9 | .object({ normalProperty: z.string() }) 10 | .catchall(z.boolean()); 11 | 12 | const parsedSchema = parseObjectDef(schema._def, getRefs()); 13 | const expectedSchema = { 14 | type: "object", 15 | properties: { 16 | normalProperty: { type: "string" }, 17 | }, 18 | required: ["normalProperty"], 19 | additionalProperties: { 20 | type: "boolean", 21 | }, 22 | }; 23 | assert(parsedSchema, expectedSchema); 24 | }); 25 | 26 | test("should be possible to use selective partial", (assert) => { 27 | const schema = z 28 | .object({ foo: z.boolean(), bar: z.number() }) 29 | .partial({ foo: true }); 30 | 31 | const parsedSchema = parseObjectDef(schema._def, getRefs()); 32 | const expectedSchema = { 33 | type: "object", 34 | properties: { 35 | foo: { type: "boolean" }, 36 | bar: { type: "number" }, 37 | }, 38 | required: ["bar"], 39 | additionalProperties: false, 40 | }; 41 | assert(parsedSchema, expectedSchema); 42 | }); 43 | 44 | test("should allow additional properties unless strict when removeAdditionalStrategy is strict", (assert) => { 45 | const schema = z.object({ foo: z.boolean(), bar: z.number() }); 46 | 47 | const parsedSchema = parseObjectDef( 48 | schema._def, 49 | getRefs({ removeAdditionalStrategy: "strict" }), 50 | ); 51 | const expectedSchema = { 52 | type: "object", 53 | properties: { 54 | foo: { type: "boolean" }, 55 | bar: { type: "number" }, 56 | }, 57 | required: ["foo", "bar"], 58 | additionalProperties: true, 59 | }; 60 | assert(parsedSchema, expectedSchema); 61 | 62 | const strictSchema = z 63 | .object({ foo: z.boolean(), bar: z.number() }) 64 | .strict(); 65 | 66 | const parsedStrictSchema = parseObjectDef( 67 | strictSchema._def, 68 | getRefs({ removeAdditionalStrategy: "strict" }), 69 | ); 70 | const expectedStrictSchema = { 71 | type: "object", 72 | properties: { 73 | foo: { type: "boolean" }, 74 | bar: { type: "number" }, 75 | }, 76 | required: ["foo", "bar"], 77 | additionalProperties: false, 78 | }; 79 | assert(parsedStrictSchema, expectedStrictSchema); 80 | }); 81 | 82 | test("should allow additional properties with catchall when removeAdditionalStrategy is strict", (assert) => { 83 | const schema = z 84 | .object({ foo: z.boolean(), bar: z.number() }) 85 | .catchall(z.boolean()); 86 | 87 | const parsedSchema = parseObjectDef( 88 | schema._def, 89 | getRefs({ removeAdditionalStrategy: "strict" }), 90 | ); 91 | 92 | const expectedSchema = { 93 | type: "object", 94 | properties: { 95 | foo: { type: "boolean" }, 96 | bar: { type: "number" }, 97 | }, 98 | required: ["foo", "bar"], 99 | additionalProperties: { 100 | type: "boolean", 101 | }, 102 | }; 103 | assert(parsedSchema, expectedSchema); 104 | }); 105 | 106 | test("should be possible to not set additionalProperties at all when allowed", (assert) => { 107 | const schema = z 108 | .object({ foo: z.boolean(), bar: z.number() }) 109 | .passthrough(); 110 | 111 | const parsedSchema = parseObjectDef( 112 | schema._def, 113 | getRefs({ 114 | removeAdditionalStrategy: "passthrough", 115 | allowedAdditionalProperties: undefined, 116 | }), 117 | ); 118 | 119 | const expectedSchema = { 120 | type: "object", 121 | properties: { 122 | foo: { type: "boolean" }, 123 | bar: { type: "number" }, 124 | }, 125 | required: ["foo", "bar"], 126 | }; 127 | 128 | assert(parsedSchema, expectedSchema); 129 | }); 130 | 131 | test("should be possible to not set additionalProperties at all when rejected", (assert) => { 132 | const schema = z 133 | .object({ foo: z.boolean(), bar: z.number() }) 134 | .strict(); 135 | 136 | const parsedSchema = parseObjectDef( 137 | schema._def, 138 | getRefs({ 139 | removeAdditionalStrategy: "passthrough", 140 | rejectedAdditionalProperties: undefined, 141 | }), 142 | ); 143 | 144 | const expectedSchema = { 145 | type: "object", 146 | properties: { 147 | foo: { type: "boolean" }, 148 | bar: { type: "number" }, 149 | }, 150 | required: ["foo", "bar"], 151 | }; 152 | 153 | assert(parsedSchema, expectedSchema); 154 | }); 155 | }); 156 | -------------------------------------------------------------------------------- /src/selectParser.ts: -------------------------------------------------------------------------------- 1 | import { ZodFirstPartyTypeKind } from "zod/v3"; 2 | import { parseAnyDef } from "./parsers/any.js"; 3 | import { parseArrayDef } from "./parsers/array.js"; 4 | import { parseBigintDef } from "./parsers/bigint.js"; 5 | import { parseBooleanDef } from "./parsers/boolean.js"; 6 | import { parseBrandedDef } from "./parsers/branded.js"; 7 | import { parseCatchDef } from "./parsers/catch.js"; 8 | import { parseDateDef } from "./parsers/date.js"; 9 | import { parseDefaultDef } from "./parsers/default.js"; 10 | import { parseEffectsDef } from "./parsers/effects.js"; 11 | import { parseEnumDef } from "./parsers/enum.js"; 12 | import { parseIntersectionDef } from "./parsers/intersection.js"; 13 | import { parseLiteralDef } from "./parsers/literal.js"; 14 | import { parseMapDef } from "./parsers/map.js"; 15 | import { parseNativeEnumDef } from "./parsers/nativeEnum.js"; 16 | import { parseNeverDef } from "./parsers/never.js"; 17 | import { parseNullDef } from "./parsers/null.js"; 18 | import { parseNullableDef } from "./parsers/nullable.js"; 19 | import { parseNumberDef } from "./parsers/number.js"; 20 | import { parseObjectDef } from "./parsers/object.js"; 21 | import { parseOptionalDef } from "./parsers/optional.js"; 22 | import { parsePipelineDef } from "./parsers/pipeline.js"; 23 | import { parsePromiseDef } from "./parsers/promise.js"; 24 | import { parseRecordDef } from "./parsers/record.js"; 25 | import { parseSetDef } from "./parsers/set.js"; 26 | import { parseStringDef } from "./parsers/string.js"; 27 | import { parseTupleDef } from "./parsers/tuple.js"; 28 | import { parseUndefinedDef } from "./parsers/undefined.js"; 29 | import { parseUnionDef } from "./parsers/union.js"; 30 | import { parseUnknownDef } from "./parsers/unknown.js"; 31 | import { Refs } from "./Refs.js"; 32 | import { parseReadonlyDef } from "./parsers/readonly.js"; 33 | import { JsonSchema7Type } from "./parseTypes.js"; 34 | 35 | export type InnerDefGetter = () => any; 36 | 37 | export const selectParser = ( 38 | def: any, 39 | typeName: ZodFirstPartyTypeKind, 40 | refs: Refs, 41 | ): JsonSchema7Type | undefined | InnerDefGetter => { 42 | switch (typeName) { 43 | case ZodFirstPartyTypeKind.ZodString: 44 | return parseStringDef(def, refs); 45 | case ZodFirstPartyTypeKind.ZodNumber: 46 | return parseNumberDef(def, refs); 47 | case ZodFirstPartyTypeKind.ZodObject: 48 | return parseObjectDef(def, refs); 49 | case ZodFirstPartyTypeKind.ZodBigInt: 50 | return parseBigintDef(def, refs); 51 | case ZodFirstPartyTypeKind.ZodBoolean: 52 | return parseBooleanDef(); 53 | case ZodFirstPartyTypeKind.ZodDate: 54 | return parseDateDef(def, refs); 55 | case ZodFirstPartyTypeKind.ZodUndefined: 56 | return parseUndefinedDef(refs); 57 | case ZodFirstPartyTypeKind.ZodNull: 58 | return parseNullDef(refs); 59 | case ZodFirstPartyTypeKind.ZodArray: 60 | return parseArrayDef(def, refs); 61 | case ZodFirstPartyTypeKind.ZodUnion: 62 | case ZodFirstPartyTypeKind.ZodDiscriminatedUnion: 63 | return parseUnionDef(def, refs); 64 | case ZodFirstPartyTypeKind.ZodIntersection: 65 | return parseIntersectionDef(def, refs); 66 | case ZodFirstPartyTypeKind.ZodTuple: 67 | return parseTupleDef(def, refs); 68 | case ZodFirstPartyTypeKind.ZodRecord: 69 | return parseRecordDef(def, refs); 70 | case ZodFirstPartyTypeKind.ZodLiteral: 71 | return parseLiteralDef(def, refs); 72 | case ZodFirstPartyTypeKind.ZodEnum: 73 | return parseEnumDef(def); 74 | case ZodFirstPartyTypeKind.ZodNativeEnum: 75 | return parseNativeEnumDef(def); 76 | case ZodFirstPartyTypeKind.ZodNullable: 77 | return parseNullableDef(def, refs); 78 | case ZodFirstPartyTypeKind.ZodOptional: 79 | return parseOptionalDef(def, refs); 80 | case ZodFirstPartyTypeKind.ZodMap: 81 | return parseMapDef(def, refs); 82 | case ZodFirstPartyTypeKind.ZodSet: 83 | return parseSetDef(def, refs); 84 | case ZodFirstPartyTypeKind.ZodLazy: 85 | return () => (def as any).getter()._def; 86 | case ZodFirstPartyTypeKind.ZodPromise: 87 | return parsePromiseDef(def, refs); 88 | case ZodFirstPartyTypeKind.ZodNaN: 89 | case ZodFirstPartyTypeKind.ZodNever: 90 | return parseNeverDef(refs); 91 | case ZodFirstPartyTypeKind.ZodEffects: 92 | return parseEffectsDef(def, refs); 93 | case ZodFirstPartyTypeKind.ZodAny: 94 | return parseAnyDef(refs); 95 | case ZodFirstPartyTypeKind.ZodUnknown: 96 | return parseUnknownDef(refs); 97 | case ZodFirstPartyTypeKind.ZodDefault: 98 | return parseDefaultDef(def, refs); 99 | case ZodFirstPartyTypeKind.ZodBranded: 100 | return parseBrandedDef(def, refs); 101 | case ZodFirstPartyTypeKind.ZodReadonly: 102 | return parseReadonlyDef(def, refs); 103 | case ZodFirstPartyTypeKind.ZodCatch: 104 | return parseCatchDef(def, refs); 105 | case ZodFirstPartyTypeKind.ZodPipeline: 106 | return parsePipelineDef(def, refs); 107 | case ZodFirstPartyTypeKind.ZodFunction: 108 | case ZodFirstPartyTypeKind.ZodVoid: 109 | case ZodFirstPartyTypeKind.ZodSymbol: 110 | return undefined; 111 | default: 112 | return ((_: never) => undefined)(typeName); 113 | } 114 | }; 115 | -------------------------------------------------------------------------------- /test/parsers/array.test.ts: -------------------------------------------------------------------------------- 1 | import { JSONSchema7Type } from "json-schema"; 2 | import { z } from "zod/v3"; 3 | import { parseArrayDef } from "../../src/parsers/array.js"; 4 | import { getRefs } from "../../src/Refs.js"; 5 | import { errorReferences } from "./errorReferences.js"; 6 | import deref from "local-ref-resolver"; 7 | import { suite } from "../suite.js"; 8 | 9 | suite("Arrays and array validations", (test) => { 10 | test("should be possible to describe a simple array", (assert) => { 11 | const parsedSchema = parseArrayDef(z.array(z.string())._def, getRefs()); 12 | const jsonSchema: JSONSchema7Type = { 13 | type: "array", 14 | items: { 15 | type: "string", 16 | }, 17 | }; 18 | assert(parsedSchema, jsonSchema); 19 | }); 20 | test("should be possible to describe a simple array with any item", (assert) => { 21 | const parsedSchema = parseArrayDef(z.array(z.any())._def, getRefs()); 22 | const jsonSchema: JSONSchema7Type = { 23 | type: "array", 24 | }; 25 | assert(parsedSchema, jsonSchema); 26 | }); 27 | test("should be possible to describe a string array with a minimum and maximum length", (assert) => { 28 | const parsedSchema = parseArrayDef( 29 | z.array(z.string()).min(2).max(4)._def, 30 | getRefs(), 31 | ); 32 | const jsonSchema: JSONSchema7Type = { 33 | type: "array", 34 | items: { 35 | type: "string", 36 | }, 37 | minItems: 2, 38 | maxItems: 4, 39 | }; 40 | assert(parsedSchema, jsonSchema); 41 | }); 42 | test("should be possible to describe a string array with an exect length", (assert) => { 43 | const parsedSchema = parseArrayDef( 44 | z.array(z.string()).length(5)._def, 45 | getRefs(), 46 | ); 47 | const jsonSchema: JSONSchema7Type = { 48 | type: "array", 49 | items: { 50 | type: "string", 51 | }, 52 | minItems: 5, 53 | maxItems: 5, 54 | }; 55 | assert(parsedSchema, jsonSchema); 56 | }); 57 | test("should be possible to describe a string array with a minimum length of 1 by using nonempty", (assert) => { 58 | const parsedSchema = parseArrayDef( 59 | z.array(z.any()).nonempty()._def, 60 | getRefs(), 61 | ); 62 | const jsonSchema: JSONSchema7Type = { 63 | type: "array", 64 | minItems: 1, 65 | }; 66 | assert(parsedSchema, jsonSchema); 67 | }); 68 | 69 | test("should be possible do properly reference array items", (assert) => { 70 | const willHaveBeenSeen = z.object({ hello: z.string() }); 71 | const unionSchema = z.union([willHaveBeenSeen, willHaveBeenSeen]); 72 | const arraySchema = z.array(unionSchema); 73 | const jsonSchema = parseArrayDef(arraySchema._def, getRefs()); 74 | //TODO: Remove 'any'-cast when json schema type package supports it. 'anyOf' in 'items' should be completely according to spec though. 75 | assert((jsonSchema.items as any).anyOf[1].$ref, "#/items/anyOf/0"); 76 | 77 | const resolvedSchema = deref(jsonSchema); 78 | assert(resolvedSchema.items.anyOf[1] === resolvedSchema.items.anyOf[0]); 79 | }); 80 | 81 | test("should include custom error messages for minLength and maxLength", (assert) => { 82 | const minLengthMessage = "Must have at least 5 items."; 83 | const maxLengthMessage = "Can have at most 10 items."; 84 | const jsonSchema: JSONSchema7Type = { 85 | type: "array", 86 | minItems: 5, 87 | maxItems: 10, 88 | errorMessage: { 89 | minItems: minLengthMessage, 90 | maxItems: maxLengthMessage, 91 | }, 92 | }; 93 | const zodArraySchema = z 94 | .array(z.any()) 95 | .min(5, minLengthMessage) 96 | .max(10, maxLengthMessage); 97 | const jsonParsedSchema = parseArrayDef( 98 | zodArraySchema._def, 99 | errorReferences(), 100 | ); 101 | assert(jsonSchema, jsonParsedSchema); 102 | }); 103 | test("should include custom error messages for exactLength", (assert) => { 104 | const exactLengthMessage = "Must have exactly 5 items."; 105 | const jsonSchema: JSONSchema7Type = { 106 | type: "array", 107 | minItems: 5, 108 | maxItems: 5, 109 | errorMessage: { 110 | minItems: exactLengthMessage, 111 | maxItems: exactLengthMessage, 112 | }, 113 | }; 114 | const zodArraySchema = z.array(z.any()).length(5, exactLengthMessage); 115 | const jsonParsedSchema = parseArrayDef( 116 | zodArraySchema._def, 117 | errorReferences(), 118 | ); 119 | assert(jsonSchema, jsonParsedSchema); 120 | }); 121 | 122 | test("should not include errorMessages property if none are passed", (assert) => { 123 | const jsonSchema: JSONSchema7Type = { 124 | type: "array", 125 | minItems: 5, 126 | maxItems: 10, 127 | }; 128 | const zodArraySchema = z.array(z.any()).min(5).max(10); 129 | const jsonParsedSchema = parseArrayDef( 130 | zodArraySchema._def, 131 | errorReferences(), 132 | ); 133 | assert(jsonSchema, jsonParsedSchema); 134 | }); 135 | test("should not include error messages if it isn't explicitly set to true in References constructor", (assert) => { 136 | const zodSchemas = [ 137 | z.array(z.any()).min(1, "bad"), 138 | z.array(z.any()).max(1, "bad"), 139 | ]; 140 | for (const schema of zodSchemas) { 141 | const jsonParsedSchema = parseArrayDef(schema._def, getRefs()); 142 | assert(jsonParsedSchema.errorMessages, undefined); 143 | } 144 | }); 145 | }); 146 | -------------------------------------------------------------------------------- /test/suite.ts: -------------------------------------------------------------------------------- 1 | import diff from "fast-diff"; 2 | 3 | const RED = "\x1b[31m"; 4 | const GREEN = "\x1b[32m"; 5 | const RESET = "\x1b[39m"; 6 | 7 | type TestContext = (assert: (result: any, expected?: any) => void) => void; 8 | type TestFunction = (name: string, context: TestContext) => void; 9 | type SuiteContext = (test: TestFunction) => void; 10 | type ErrorMap = { [key: string]: Error }; 11 | type Error = 12 | | { missmatch: "value" | "type" | "length" | "keys"; expected: any; got: any } 13 | | { missmatch: "nested"; properties: { [key: string]: Error } }; 14 | 15 | export function suite(suiteName: string, suiteContext: SuiteContext): void { 16 | let tests = 0; 17 | let passedTests = 0; 18 | 19 | const test: TestFunction = (testName, testContext) => { 20 | tests++; 21 | 22 | let assertions = 0; 23 | let passedAssertions = 0; 24 | try { 25 | testContext((...args) => { 26 | assertions++; 27 | 28 | const error = 29 | args.length === 2 30 | ? assert(args[0], args[1], []) 31 | : args[0] 32 | ? undefined 33 | : ({ 34 | missmatch: "value", 35 | expected: "truthy", 36 | got: args[0], 37 | } satisfies Error); 38 | 39 | if (!error) { 40 | passedAssertions++; 41 | } else { 42 | console.error( 43 | `❌ ${suiteName}, ${testName}, assertion ${assertions} failed:`, 44 | formatError(error), 45 | ); 46 | } 47 | }); 48 | 49 | if (assertions === 0) { 50 | console.log(`⚠ ${suiteName}, ${testName}: No assertions found`); 51 | } 52 | 53 | if (assertions === passedAssertions) { 54 | passedTests++; 55 | } 56 | } catch (e) { 57 | console.error( 58 | `❌ ${suiteName}, ${testName}: Error thrown after ${assertions} ${ 59 | assertions === 1 ? "assertion" : "assertions" 60 | }. Error:`, 61 | e, 62 | ); 63 | } 64 | }; 65 | 66 | suiteContext(test); 67 | 68 | if (tests === 0) { 69 | console.log(`⚠ ${suiteName}: No tests found`); 70 | } else if (tests === passedTests) { 71 | console.log( 72 | `✔ ${suiteName}: ${tests} ${tests === 1 ? "test" : "tests"} passed`, 73 | ); 74 | } else { 75 | console.error( 76 | `❌ ${suiteName}: ${passedTests}/${tests} ${ 77 | passedTests === 1 ? "test" : "tests" 78 | } passed`, 79 | ); 80 | process.exitCode = 1; 81 | } 82 | } 83 | 84 | function formatError(error: Error, depth = 0): string { 85 | const indent = " ".repeat(depth); 86 | 87 | if (error.missmatch === "nested") { 88 | return `{\n${indent} ${Object.keys(error.properties) 89 | .map((key) => `${key}: ${formatError(error.properties[key], depth + 2)}`) 90 | .join(`\n${indent} `)}\n${indent}}`; 91 | } else if ( 92 | error.missmatch === "value" && 93 | typeof error.expected === "string" && 94 | typeof error.got === "string" 95 | ) { 96 | return `Diff = ${colorDiff(error.got, error.expected)}`; 97 | } else { 98 | return `Missmatch = ${error.missmatch}, Expected = ${error.expected}, Got = ${error.got}`; 99 | } 100 | } 101 | 102 | function assert( 103 | a: unknown, 104 | b: unknown, 105 | path: (string | number)[], 106 | ): Error | undefined { 107 | if (a === b) { 108 | return undefined; 109 | } 110 | 111 | if (typeof a === "object") { 112 | if (typeof b !== "object") { 113 | return { missmatch: "type", expected: typeof a, got: typeof b }; 114 | } 115 | 116 | if (a === null) { 117 | return { missmatch: "value", expected: null, got: b }; 118 | } 119 | 120 | if (b === null) { 121 | return { missmatch: "value", expected: a, got: null }; 122 | } 123 | 124 | if (Array.isArray(a)) { 125 | if (!Array.isArray(b)) { 126 | return { missmatch: "type", expected: "object", got: "array" }; 127 | } 128 | 129 | if (a.length !== b.length) { 130 | return { missmatch: "length", expected: b.length, got: a.length }; 131 | } 132 | } else if (Array.isArray(b)) { 133 | return { missmatch: "type", expected: "array", got: "object" }; 134 | } 135 | 136 | const keysA = Object.keys(a).sort(); 137 | const keysB = Object.keys(b).sort(); 138 | 139 | if (keysA.join() !== keysB.join()) { 140 | return { missmatch: "keys", got: keysA, expected: keysB }; 141 | } 142 | 143 | let foundError = false; 144 | 145 | const errorMap = [...keysA, ...keysB].reduce((errorMap: ErrorMap, key) => { 146 | if (key in errorMap) { 147 | return errorMap; 148 | } 149 | 150 | const error = assert(a[key as keyof typeof a], b[key as keyof typeof b], [...path, key]); 151 | 152 | if (error) { 153 | foundError = true; 154 | 155 | errorMap[key] = error; 156 | } 157 | 158 | return errorMap; 159 | }, {}); 160 | 161 | if (foundError) { 162 | return { missmatch: "nested", properties: errorMap }; 163 | } else { 164 | return undefined; 165 | } 166 | } 167 | 168 | if ( 169 | typeof a === "function" && 170 | typeof b === "function" && 171 | a.toString() === b.toString() 172 | ) { 173 | return undefined; 174 | } 175 | 176 | if (typeof a !== typeof b) { 177 | return { missmatch: "type", got: typeof a, expected: typeof b }; 178 | } 179 | 180 | return { missmatch: "value", got: a, expected: b }; 181 | } 182 | 183 | export function colorDiff(got: string, exp: string) { 184 | return ( 185 | diff(got, exp).reduce( 186 | (acc, [type, value]) => 187 | acc + (type === -1 ? GREEN : type === 1 ? RED : RESET) + value, 188 | "", 189 | ) + RESET 190 | ); 191 | } 192 | -------------------------------------------------------------------------------- /test/parsers/number.test.ts: -------------------------------------------------------------------------------- 1 | import { JSONSchema7Type } from "json-schema"; 2 | import { z } from "zod/v3"; 3 | import { parseNumberDef } from "../../src/parsers/number.js"; 4 | import { getRefs } from "../../src/Refs.js"; 5 | import { errorReferences } from "./errorReferences.js"; 6 | import { suite } from "../suite.js"; 7 | suite("Number validations", (test) => { 8 | test("should be possible to describe minimum number", (assert) => { 9 | const parsedSchema = parseNumberDef(z.number().min(5)._def, getRefs()); 10 | const jsonSchema: JSONSchema7Type = { 11 | type: "number", 12 | minimum: 5, 13 | }; 14 | assert(parsedSchema, jsonSchema); 15 | }); 16 | test("should be possible to describe maximum number", (assert) => { 17 | const parsedSchema = parseNumberDef(z.number().max(5)._def, getRefs()); 18 | const jsonSchema: JSONSchema7Type = { 19 | type: "number", 20 | maximum: 5, 21 | }; 22 | assert(parsedSchema, jsonSchema); 23 | }); 24 | test("should be possible to describe both minimum and maximum number", (assert) => { 25 | const parsedSchema = parseNumberDef( 26 | z.number().min(5).max(5)._def, 27 | getRefs(), 28 | ); 29 | const jsonSchema: JSONSchema7Type = { 30 | type: "number", 31 | minimum: 5, 32 | maximum: 5, 33 | }; 34 | assert(parsedSchema, jsonSchema); 35 | }); 36 | test("should be possible to describe an integer", (assert) => { 37 | const parsedSchema = parseNumberDef(z.number().int()._def, getRefs()); 38 | const jsonSchema: JSONSchema7Type = { 39 | type: "integer", 40 | }; 41 | assert(parsedSchema, jsonSchema); 42 | }); 43 | test("should be possible to describe multiples of n", (assert) => { 44 | const parsedSchema = parseNumberDef( 45 | z.number().multipleOf(2)._def, 46 | getRefs(), 47 | ); 48 | const jsonSchema: JSONSchema7Type = { 49 | type: "number", 50 | multipleOf: 2, 51 | }; 52 | assert(parsedSchema, jsonSchema); 53 | }); 54 | test("should be possible to describe positive, negative, nonpositive and nonnegative numbers", (assert) => { 55 | const parsedSchema = parseNumberDef( 56 | z.number().positive().negative().nonpositive().nonnegative()._def, 57 | getRefs(), 58 | ); 59 | const jsonSchema: JSONSchema7Type = { 60 | type: "number", 61 | minimum: 0, 62 | maximum: 0, 63 | exclusiveMaximum: 0, 64 | exclusiveMinimum: 0, 65 | }; 66 | assert(parsedSchema, jsonSchema); 67 | }); 68 | test("should include custom error messages for inclusive checks if they're passed", (assert) => { 69 | const minErrorMessage = "Number must be at least 5"; 70 | const maxErrorMessage = "Number must be at most 10"; 71 | const zodNumberSchema = z 72 | .number() 73 | .gte(5, minErrorMessage) 74 | .lte(10, maxErrorMessage); 75 | const jsonSchema: JSONSchema7Type = { 76 | type: "number", 77 | minimum: 5, 78 | maximum: 10, 79 | errorMessage: { 80 | minimum: minErrorMessage, 81 | maximum: maxErrorMessage, 82 | }, 83 | }; 84 | const jsonParsedSchema = parseNumberDef( 85 | zodNumberSchema._def, 86 | errorReferences(), 87 | ); 88 | assert(jsonParsedSchema, jsonSchema); 89 | }); 90 | test("should include custom error messages for exclusive checks if they're passed", (assert) => { 91 | const minErrorMessage = "Number must be greater than 5"; 92 | const maxErrorMessage = "Number must less than 10"; 93 | const zodNumberSchema = z 94 | .number() 95 | .gt(5, minErrorMessage) 96 | .lt(10, maxErrorMessage); 97 | const jsonSchema: JSONSchema7Type = { 98 | type: "number", 99 | exclusiveMinimum: 5, 100 | exclusiveMaximum: 10, 101 | errorMessage: { 102 | exclusiveMinimum: minErrorMessage, 103 | exclusiveMaximum: maxErrorMessage, 104 | }, 105 | }; 106 | const jsonParsedSchema = parseNumberDef( 107 | zodNumberSchema._def, 108 | errorReferences(), 109 | ); 110 | assert(jsonParsedSchema, jsonSchema); 111 | }); 112 | test("should include custom error messages for multipleOf and int if they're passed", (assert) => { 113 | const intErrorMessage = "Must be an integer"; 114 | const multipleOfErrorMessage = "Must be a multiple of 5"; 115 | const jsonSchema: JSONSchema7Type = { 116 | type: "integer", 117 | multipleOf: 5, 118 | errorMessage: { 119 | type: intErrorMessage, 120 | multipleOf: multipleOfErrorMessage, 121 | }, 122 | }; 123 | const zodNumberSchema = z 124 | .number() 125 | .multipleOf(5, multipleOfErrorMessage) 126 | .int(intErrorMessage); 127 | const jsonParsedSchema = parseNumberDef( 128 | zodNumberSchema._def, 129 | errorReferences(), 130 | ); 131 | assert(jsonParsedSchema, jsonSchema); 132 | }); 133 | test("should not include errorMessage property if they're not passed", (assert) => { 134 | const zodNumberSchemas = [ 135 | z.number().lt(5), 136 | z.number().gt(5), 137 | z.number().gte(5), 138 | z.number().lte(5), 139 | z.number().multipleOf(5), 140 | z.number().int(), 141 | z.number().int().multipleOf(5).lt(5).gt(3).lte(4).gte(3), 142 | ]; 143 | const jsonParsedSchemas = zodNumberSchemas.map((schema) => 144 | parseNumberDef(schema._def, errorReferences()), 145 | ); 146 | for (const jsonParsedSchema of jsonParsedSchemas) { 147 | assert(jsonParsedSchema.errorMessage, undefined); 148 | } 149 | }); 150 | test("should not include error messages if error message isn't explicitly set to true in References constructor", (assert) => { 151 | const zodNumberSchemas = [ 152 | z.number().lt(5), 153 | z.number().gt(5), 154 | z.number().gte(5), 155 | z.number().lte(5), 156 | z.number().multipleOf(5), 157 | z.number().int(), 158 | ]; 159 | for (const schema of zodNumberSchemas) { 160 | const jsonParsedSchema = parseNumberDef(schema._def, getRefs()); 161 | assert(jsonParsedSchema.errorMessage, undefined); 162 | } 163 | }); 164 | }); 165 | -------------------------------------------------------------------------------- /test/parsers/union.test.ts: -------------------------------------------------------------------------------- 1 | import { JSONSchema7Type } from "json-schema"; 2 | import { z } from "zod/v3"; 3 | import { parseUnionDef } from "../../src/parsers/union.js"; 4 | import { getRefs } from "../../src/Refs.js"; 5 | import { suite } from "../suite.js"; 6 | import deref from "local-ref-resolver"; 7 | 8 | suite("Unions", (test) => { 9 | test("Should be possible to get a simple type array from a union of only unvalidated primitives", (assert) => { 10 | const parsedSchema = parseUnionDef( 11 | z.union([z.string(), z.number(), z.boolean(), z.null()])._def, 12 | getRefs(), 13 | ); 14 | const jsonSchema: JSONSchema7Type = { 15 | type: ["string", "number", "boolean", "null"], 16 | }; 17 | assert(parsedSchema, jsonSchema); 18 | }); 19 | 20 | test("Should be possible to get a simple type array with enum values from a union of literals", (assert) => { 21 | const parsedSchema = parseUnionDef( 22 | z.union([ 23 | z.literal("string"), 24 | z.literal(123), 25 | z.literal(true), 26 | z.literal(null), 27 | z.literal(BigInt(50)), 28 | ])._def, 29 | getRefs(), 30 | ); 31 | const jsonSchema = { 32 | type: ["string", "number", "boolean", "null", "integer"], 33 | enum: ["string", 123, true, null, BigInt(50)], 34 | }; 35 | assert(parsedSchema, jsonSchema); 36 | }); 37 | 38 | test("Should be possible to get an anyOf array with enum values from a union of literals", (assert) => { 39 | const parsedSchema = parseUnionDef( 40 | z.union([ 41 | z.literal(undefined), 42 | z.literal(Symbol("abc")), 43 | // @ts-expect-error Ok 44 | z.literal(function () {}), 45 | ])._def, 46 | getRefs(), 47 | ); 48 | const jsonSchema = { 49 | anyOf: [ 50 | { 51 | type: "object", 52 | }, 53 | { 54 | type: "object", 55 | }, 56 | { 57 | type: "object", 58 | }, 59 | ], 60 | }; 61 | assert(parsedSchema, jsonSchema); 62 | }); 63 | 64 | test("Should be possible to create a union with objects, arrays and validated primitives as an anyOf", (assert) => { 65 | const parsedSchema = parseUnionDef( 66 | z.union([ 67 | z.object({ herp: z.string(), derp: z.boolean() }), 68 | z.array(z.number()), 69 | z.string().min(3), 70 | z.number(), 71 | ])._def, 72 | getRefs(), 73 | ); 74 | const jsonSchema: JSONSchema7Type = { 75 | anyOf: [ 76 | { 77 | type: "object", 78 | properties: { 79 | herp: { 80 | type: "string", 81 | }, 82 | derp: { 83 | type: "boolean", 84 | }, 85 | }, 86 | required: ["herp", "derp"], 87 | additionalProperties: false, 88 | }, 89 | { 90 | type: "array", 91 | items: { 92 | type: "number", 93 | }, 94 | }, 95 | { 96 | type: "string", 97 | minLength: 3, 98 | }, 99 | { 100 | type: "number", 101 | }, 102 | ], 103 | }; 104 | assert(parsedSchema, jsonSchema); 105 | }); 106 | 107 | test("should be possible to deref union schemas", (assert) => { 108 | const recurring = z.object({ foo: z.boolean() }); 109 | 110 | const union = z.union([recurring, recurring, recurring]); 111 | 112 | const jsonSchema = parseUnionDef(union._def, getRefs()); 113 | 114 | assert(jsonSchema, { 115 | anyOf: [ 116 | { 117 | type: "object", 118 | properties: { 119 | foo: { 120 | type: "boolean", 121 | }, 122 | }, 123 | required: ["foo"], 124 | additionalProperties: false, 125 | }, 126 | { 127 | $ref: "#/anyOf/0", 128 | }, 129 | { 130 | $ref: "#/anyOf/0", 131 | }, 132 | ], 133 | }); 134 | 135 | const resolvedSchema = deref(jsonSchema); 136 | assert(resolvedSchema.anyOf[0], resolvedSchema.anyOf[1]); 137 | assert(resolvedSchema.anyOf[1], resolvedSchema.anyOf[2]); 138 | }); 139 | 140 | test("nullable primitives should come out fine", (assert) => { 141 | const union = z.union([z.string(), z.null()]); 142 | 143 | const jsonSchema = parseUnionDef(union._def, getRefs()); 144 | 145 | assert(jsonSchema, { 146 | type: ["string", "null"], 147 | }); 148 | }); 149 | 150 | test("should join a union of Zod enums into a single enum", (assert) => { 151 | const union = z.union([z.enum(["a", "b", "c"]), z.enum(["c", "d", "e"])]); 152 | 153 | const jsonSchema = parseUnionDef(union._def, getRefs()); 154 | 155 | assert(jsonSchema, { 156 | type: "string", 157 | enum: ["a", "b", "c", "d", "e"], 158 | }); 159 | }); 160 | 161 | test("should work with discriminated union type", (assert) => { 162 | const discUnion = z.discriminatedUnion("kek", [ 163 | z.object({ kek: z.literal("A"), lel: z.boolean() }), 164 | z.object({ kek: z.literal("B"), lel: z.number() }), 165 | ]); 166 | 167 | const jsonSchema = parseUnionDef(discUnion._def, getRefs()); 168 | 169 | assert(jsonSchema, { 170 | anyOf: [ 171 | { 172 | type: "object", 173 | properties: { 174 | kek: { 175 | type: "string", 176 | const: "A", 177 | }, 178 | lel: { 179 | type: "boolean", 180 | }, 181 | }, 182 | required: ["kek", "lel"], 183 | additionalProperties: false, 184 | }, 185 | { 186 | type: "object", 187 | properties: { 188 | kek: { 189 | type: "string", 190 | const: "B", 191 | }, 192 | lel: { 193 | type: "number", 194 | }, 195 | }, 196 | required: ["kek", "lel"], 197 | additionalProperties: false, 198 | }, 199 | ], 200 | }); 201 | }); 202 | 203 | test("should not ignore descriptions in literal unions", (assert) => { 204 | assert( 205 | [ 206 | parseUnionDef( 207 | z.union([z.literal(true), z.literal("herp"), z.literal(3)])._def, 208 | getRefs(), 209 | ), 210 | parseUnionDef( 211 | z.union([ 212 | z.literal(true), 213 | z.literal("herp").describe("derp"), 214 | z.literal(3), 215 | ])._def, 216 | getRefs(), 217 | ), 218 | ], 219 | [ 220 | { type: ["boolean", "string", "number"], enum: [true, "herp", 3] }, 221 | { 222 | anyOf: [ 223 | { type: "boolean", const: true }, 224 | { type: "string", const: "herp", description: "derp" }, 225 | { type: "number", const: 3 }, 226 | ], 227 | }, 228 | ], 229 | ); 230 | }); 231 | }); 232 | -------------------------------------------------------------------------------- /test/parseDef.test.ts: -------------------------------------------------------------------------------- 1 | import { JSONSchema7Type } from "json-schema"; 2 | import { z } from "zod/v3"; 3 | import { parseDef } from "../src/parseDef.js"; 4 | import Ajv from "ajv"; 5 | import { getRefs } from "../src/Refs.js"; 6 | const ajv = new Ajv(); 7 | 8 | import { suite } from "./suite.js"; 9 | 10 | suite("Basic parsing", (test) => { 11 | test("should return a proper json schema with some common types without validation", (assert) => { 12 | const zodSchema = z.object({ 13 | requiredString: z.string(), 14 | optionalString: z.string().optional(), 15 | literalString: z.literal("literalStringValue"), 16 | stringArray: z.array(z.string()), 17 | stringEnum: z.enum(["stringEnumOptionA", "stringEnumOptionB"]), 18 | tuple: z.tuple([z.string(), z.number(), z.boolean()]), 19 | record: z.record(z.boolean()), 20 | requiredNumber: z.number(), 21 | optionalNumber: z.number().optional(), 22 | numberOrNull: z.number().nullable(), 23 | numberUnion: z.union([z.literal(1), z.literal(2), z.literal(3)]), 24 | mixedUnion: z.union([ 25 | z.literal("abc"), 26 | z.literal(123), 27 | z.object({ nowItGetsAnnoying: z.literal(true) }), 28 | ]), 29 | objectOrNull: z.object({ myString: z.string() }).nullable(), 30 | passthrough: z.object({ myString: z.string() }).passthrough(), 31 | }); 32 | const expectedJsonSchema: JSONSchema7Type = { 33 | type: "object", 34 | properties: { 35 | requiredString: { 36 | type: "string", 37 | }, 38 | optionalString: { 39 | type: "string", 40 | }, 41 | literalString: { 42 | type: "string", 43 | const: "literalStringValue", 44 | }, 45 | stringArray: { 46 | type: "array", 47 | items: { 48 | type: "string", 49 | }, 50 | }, 51 | stringEnum: { 52 | type: "string", 53 | enum: ["stringEnumOptionA", "stringEnumOptionB"], 54 | }, 55 | tuple: { 56 | type: "array", 57 | minItems: 3, 58 | items: [ 59 | { 60 | type: "string", 61 | }, 62 | { 63 | type: "number", 64 | }, 65 | { 66 | type: "boolean", 67 | }, 68 | ], 69 | maxItems: 3, 70 | }, 71 | record: { 72 | type: "object", 73 | additionalProperties: { 74 | type: "boolean", 75 | }, 76 | }, 77 | requiredNumber: { 78 | type: "number", 79 | }, 80 | optionalNumber: { 81 | type: "number", 82 | }, 83 | numberOrNull: { 84 | type: ["number", "null"], 85 | }, 86 | numberUnion: { 87 | type: "number", 88 | enum: [1, 2, 3], 89 | }, 90 | mixedUnion: { 91 | anyOf: [ 92 | { 93 | type: "string", 94 | const: "abc", 95 | }, 96 | { 97 | type: "number", 98 | const: 123, 99 | }, 100 | { 101 | type: "object", 102 | properties: { 103 | nowItGetsAnnoying: { 104 | type: "boolean", 105 | const: true, 106 | }, 107 | }, 108 | required: ["nowItGetsAnnoying"], 109 | additionalProperties: false, 110 | }, 111 | ], 112 | }, 113 | objectOrNull: { 114 | anyOf: [ 115 | { 116 | type: "object", 117 | properties: { 118 | myString: { 119 | type: "string", 120 | }, 121 | }, 122 | required: ["myString"], 123 | additionalProperties: false, 124 | }, 125 | { 126 | type: "null", 127 | }, 128 | ], 129 | }, 130 | passthrough: { 131 | type: "object", 132 | properties: { 133 | myString: { 134 | type: "string", 135 | }, 136 | }, 137 | required: ["myString"], 138 | additionalProperties: true, 139 | }, 140 | }, 141 | required: [ 142 | "requiredString", 143 | "literalString", 144 | "stringArray", 145 | "stringEnum", 146 | "tuple", 147 | "record", 148 | "requiredNumber", 149 | "numberOrNull", 150 | "numberUnion", 151 | "mixedUnion", 152 | "objectOrNull", 153 | "passthrough", 154 | ], 155 | additionalProperties: false, 156 | }; 157 | const parsedSchema = parseDef(zodSchema._def, getRefs()); 158 | assert(parsedSchema, expectedJsonSchema); 159 | assert(ajv.validateSchema(parsedSchema!), true); 160 | }); 161 | 162 | test("should handle a nullable string properly", (assert) => { 163 | const shorthand = z.string().nullable(); 164 | const union = z.union([z.string(), z.null()]); 165 | 166 | const expected = { type: ["string", "null"] }; 167 | 168 | assert(parseDef(shorthand._def, getRefs()), expected); 169 | assert(parseDef(union._def, getRefs()), expected); 170 | }); 171 | 172 | test("should be possible to use branded string", (assert) => { 173 | const schema = z.string().brand<"x">(); 174 | const parsedSchema = parseDef(schema._def, getRefs()); 175 | 176 | const expectedSchema = { 177 | type: "string", 178 | }; 179 | assert(parsedSchema, expectedSchema); 180 | }); 181 | 182 | test("should be possible to use readonly", (assert) => { 183 | const parsedSchema = parseDef(z.object({}).readonly()._def, getRefs()); 184 | const jsonSchema: JSONSchema7Type = { 185 | type: "object", 186 | properties: {}, 187 | additionalProperties: false, 188 | }; 189 | assert(parsedSchema, jsonSchema); 190 | }); 191 | 192 | test("should be possible to use catch", (assert) => { 193 | const parsedSchema = parseDef(z.number().catch(5)._def, getRefs()); 194 | const jsonSchema: JSONSchema7Type = { 195 | type: "number", 196 | }; 197 | assert(parsedSchema, jsonSchema); 198 | }); 199 | 200 | test("should be possible to use pipeline", (assert) => { 201 | const schema = z.number().pipe(z.number().int()); 202 | 203 | assert(parseDef(schema._def, getRefs()), { 204 | allOf: [{ type: "number" }, { type: "integer" }], 205 | }); 206 | }); 207 | 208 | test("should get undefined for function", (assert) => { 209 | const parsedSchema = parseDef(z.function()._def, getRefs()); 210 | const jsonSchema = undefined; 211 | assert(parsedSchema, jsonSchema); 212 | }); 213 | 214 | test("should get undefined for void", (assert) => { 215 | const parsedSchema = parseDef(z.void()._def, getRefs()); 216 | const jsonSchema = undefined; 217 | assert(parsedSchema, jsonSchema); 218 | }); 219 | 220 | test("nested lazy", (assert) => { 221 | const zodSchema = z.lazy(() => z.lazy(() => z.string())); 222 | const expected = { 223 | type: "string", 224 | }; 225 | const parsed = parseDef(zodSchema._def, getRefs()); 226 | assert(parsed, expected); 227 | }); 228 | }); 229 | -------------------------------------------------------------------------------- /test/parsers/intersection.test.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod/v3"; 2 | import { parseIntersectionDef } from "../../src/parsers/intersection.js"; 3 | import { getRefs } from "../../src/Refs.js"; 4 | import { suite } from "../suite.js"; 5 | 6 | suite("intersections", (test) => { 7 | test("should be possible to use intersections", (assert) => { 8 | const intersection = z.intersection(z.string().min(1), z.string().max(3)); 9 | 10 | const jsonSchema = parseIntersectionDef(intersection._def, getRefs()); 11 | 12 | assert(jsonSchema, { 13 | allOf: [ 14 | { 15 | type: "string", 16 | minLength: 1, 17 | }, 18 | { 19 | type: "string", 20 | maxLength: 3, 21 | }, 22 | ], 23 | }); 24 | }); 25 | 26 | test("should be possible to deref intersections", (assert) => { 27 | const schema = z.string(); 28 | const intersection = z.intersection(schema, schema); 29 | const jsonSchema = parseIntersectionDef(intersection._def, getRefs()); 30 | 31 | assert(jsonSchema, { 32 | allOf: [ 33 | { 34 | type: "string", 35 | }, 36 | { 37 | $ref: "#/allOf/0", 38 | }, 39 | ], 40 | }); 41 | }); 42 | 43 | test("should intersect complex objects correctly", (assert) => { 44 | const schema1 = z.object({ 45 | foo: z.string(), 46 | }); 47 | const schema2 = z.object({ 48 | bar: z.string(), 49 | }); 50 | const intersection = z.intersection(schema1, schema2); 51 | const jsonSchema = parseIntersectionDef( 52 | intersection._def, 53 | getRefs({ target: "jsonSchema2019-09" }), 54 | ); 55 | 56 | assert(jsonSchema, { 57 | allOf: [ 58 | { 59 | properties: { 60 | foo: { 61 | type: "string", 62 | }, 63 | }, 64 | required: ["foo"], 65 | type: "object", 66 | }, 67 | { 68 | properties: { 69 | bar: { 70 | type: "string", 71 | }, 72 | }, 73 | required: ["bar"], 74 | type: "object", 75 | }, 76 | ], 77 | unevaluatedProperties: false, 78 | }); 79 | }); 80 | 81 | test("should return `unevaluatedProperties` only if all sub-schemas has additionalProperties set to false", (assert) => { 82 | const schema1 = z.object({ 83 | foo: z.string(), 84 | }); 85 | const schema2 = z 86 | .object({ 87 | bar: z.string(), 88 | }) 89 | .passthrough(); 90 | const intersection = z.intersection(schema1, schema2); 91 | const jsonSchema = parseIntersectionDef( 92 | intersection._def, 93 | getRefs({ target: "jsonSchema2019-09" }), 94 | ); 95 | 96 | assert(jsonSchema, { 97 | allOf: [ 98 | { 99 | properties: { 100 | foo: { 101 | type: "string", 102 | }, 103 | }, 104 | required: ["foo"], 105 | type: "object", 106 | }, 107 | { 108 | properties: { 109 | bar: { 110 | type: "string", 111 | }, 112 | }, 113 | required: ["bar"], 114 | type: "object", 115 | additionalProperties: true, 116 | }, 117 | ], 118 | }); 119 | }); 120 | 121 | test("should intersect multiple complex objects correctly", (assert) => { 122 | const schema1 = z.object({ 123 | foo: z.string(), 124 | }); 125 | const schema2 = z.object({ 126 | bar: z.string(), 127 | }); 128 | const schema3 = z.object({ 129 | baz: z.string(), 130 | }); 131 | const intersection = schema1.and(schema2).and(schema3); 132 | const jsonSchema = parseIntersectionDef( 133 | intersection._def, 134 | getRefs({ target: "jsonSchema2019-09" }), 135 | ); 136 | 137 | assert(jsonSchema, { 138 | allOf: [ 139 | { 140 | properties: { 141 | foo: { 142 | type: "string", 143 | }, 144 | }, 145 | required: ["foo"], 146 | type: "object", 147 | }, 148 | { 149 | properties: { 150 | bar: { 151 | type: "string", 152 | }, 153 | }, 154 | required: ["bar"], 155 | type: "object", 156 | }, 157 | { 158 | properties: { 159 | baz: { 160 | type: "string", 161 | }, 162 | }, 163 | required: ["baz"], 164 | type: "object", 165 | }, 166 | ], 167 | unevaluatedProperties: false, 168 | }); 169 | }); 170 | 171 | test("should return `unevaluatedProperties` only if all of the multiple sub-schemas have additionalProperties set to false", (assert) => { 172 | const schema1 = z.object({ 173 | foo: z.string(), 174 | }); 175 | const schema2 = z.object({ 176 | bar: z.string(), 177 | }); 178 | const schema3 = z 179 | .object({ 180 | baz: z.string(), 181 | }) 182 | .passthrough(); 183 | const intersection = schema1.and(schema2).and(schema3); 184 | const jsonSchema = parseIntersectionDef( 185 | intersection._def, 186 | getRefs({ target: "jsonSchema2019-09" }), 187 | ); 188 | 189 | assert(jsonSchema, { 190 | allOf: [ 191 | { 192 | properties: { 193 | foo: { 194 | type: "string", 195 | }, 196 | }, 197 | required: ["foo"], 198 | type: "object", 199 | }, 200 | { 201 | properties: { 202 | bar: { 203 | type: "string", 204 | }, 205 | }, 206 | required: ["bar"], 207 | type: "object", 208 | }, 209 | { 210 | additionalProperties: true, 211 | properties: { 212 | baz: { 213 | type: "string", 214 | }, 215 | }, 216 | required: ["baz"], 217 | type: "object", 218 | }, 219 | ], 220 | }); 221 | }); 222 | 223 | test("should return `unevaluatedProperties` only if all of the multiple sub-schemas have additionalProperties set to false (not jsonSchema2019-09)", (assert) => { 224 | const schema1 = z.object({ 225 | foo: z.string(), 226 | }); 227 | const schema2 = z.object({ 228 | bar: z.string(), 229 | }); 230 | const schema3 = z 231 | .object({ 232 | baz: z.string(), 233 | }) 234 | .passthrough(); 235 | const intersection = schema1.and(schema2).and(schema3); 236 | const jsonSchema = parseIntersectionDef(intersection._def, getRefs()); 237 | 238 | assert(jsonSchema, { 239 | allOf: [ 240 | { 241 | properties: { 242 | foo: { 243 | type: "string", 244 | }, 245 | }, 246 | required: ["foo"], 247 | type: "object", 248 | }, 249 | { 250 | properties: { 251 | bar: { 252 | type: "string", 253 | }, 254 | }, 255 | required: ["bar"], 256 | type: "object", 257 | }, 258 | { 259 | additionalProperties: true, 260 | properties: { 261 | baz: { 262 | type: "string", 263 | }, 264 | }, 265 | required: ["baz"], 266 | type: "object", 267 | }, 268 | ], 269 | }); 270 | }); 271 | }); 272 | -------------------------------------------------------------------------------- /test/issues.test.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod/v3"; 2 | import { zodToJsonSchema } from "../src/zodToJsonSchema"; 3 | import { suite } from "./suite"; 4 | import Ajv from "ajv"; 5 | import errorMessages from "ajv-errors"; 6 | 7 | suite("Issue tests", (test) => { 8 | const ajv = errorMessages(new Ajv({ allErrors: true })); 9 | 10 | test("@158", (assert) => { 11 | const schema = z.object({ 12 | test: z 13 | .string() 14 | .optional() 15 | .superRefine(async (value, ctx) => { 16 | await new Promise((resolve) => setTimeout(resolve, 100)); 17 | if (value === "fail") { 18 | ctx.addIssue({ 19 | code: z.ZodIssueCode.custom, 20 | message: "This is a test error", 21 | }); 22 | } 23 | }), 24 | }); 25 | 26 | const output = zodToJsonSchema(schema); 27 | 28 | const expected = { 29 | $schema: "http://json-schema.org/draft-07/schema#", 30 | type: "object", 31 | properties: { test: { type: "string" } }, 32 | additionalProperties: false, 33 | }; 34 | 35 | assert(output, expected) 36 | 37 | }); 38 | 39 | test("@175", (assert) => { 40 | const schema = z.object({ 41 | phoneNumber: z.number().optional(), 42 | name: z.string(), 43 | never: z.never().optional(), 44 | any: z.any().optional(), 45 | }); 46 | 47 | const output = zodToJsonSchema(schema, { target: "openAi" }); 48 | 49 | const expected = { 50 | $schema: "https://json-schema.org/draft/2019-09/schema#", 51 | type: "object", 52 | required: ["phoneNumber", "name", "any"], 53 | properties: { 54 | phoneNumber: { type: ["number", "null"] }, 55 | name: { type: "string" }, 56 | any: { 57 | $ref: "#/definitions/OpenAiAnyType", 58 | }, 59 | }, 60 | additionalProperties: false, 61 | definitions: { 62 | OpenAiAnyType: { 63 | type: ["string", "number", "integer", "boolean", "array", "null"], 64 | items: { 65 | $ref: "#/definitions/OpenAiAnyType", 66 | }, 67 | }, 68 | }, 69 | }; 70 | 71 | assert(output, expected); 72 | }); 73 | 74 | test("@94", (assert) => { 75 | const topicSchema = z.object({ 76 | topics: z 77 | .array( 78 | z.object({ 79 | topic: z.string().describe("The topic of the position"), 80 | }), 81 | ) 82 | .describe("An array of topics"), 83 | }); 84 | 85 | const res = zodToJsonSchema(topicSchema); 86 | 87 | assert(res, { 88 | $schema: "http://json-schema.org/draft-07/schema#", 89 | type: "object", 90 | required: ["topics"], 91 | properties: { 92 | topics: { 93 | type: "array", 94 | items: { 95 | type: "object", 96 | required: ["topic"], 97 | properties: { 98 | topic: { 99 | type: "string", 100 | description: "The topic of the position", 101 | }, 102 | }, 103 | additionalProperties: false, 104 | }, 105 | description: "An array of topics", 106 | }, 107 | }, 108 | additionalProperties: false, 109 | }); 110 | }); 111 | 112 | test("@154", (assert) => { 113 | const urlRegex = 114 | /^((([A-Za-z]{3,9}:(?:\/\/)?)(?:[-;:&=+$,\w]+@)?[A-Za-z0-9.-]+|(?:www\.|[-;:&=+$,\w]+@)[A-Za-z0-9.-]+)((?:\/[+~%,/.\w\-_]*)?\??(?:[-+=&;%@.\w:()_]*)#?(?:[.!/\\\w]*))?)/; 115 | 116 | const URLSchema = z 117 | .string() 118 | .min(1) 119 | .max(1000) 120 | .regex(urlRegex, { message: "Please enter a valid URL" }) 121 | .brand("url"); 122 | 123 | const jsonSchemaJs = zodToJsonSchema(URLSchema, { errorMessages: true }); 124 | const jsonSchema = JSON.parse(JSON.stringify(jsonSchemaJs)); 125 | 126 | // Basic conversion checks 127 | { 128 | const expected = { 129 | type: "string", 130 | minLength: 1, 131 | maxLength: 1000, 132 | pattern: 133 | "^((([A-Za-z]{3,9}:(?:\\/\\/)?)(?:[-;:&=+$,\\w]+@)?[A-Za-z0-9.-]+|(?:www\\.|[-;:&=+$,\\w]+@)[A-Za-z0-9.-]+)((?:\\/[+~%,/.\\w\\-_]*)?\\??(?:[-+=&;%@.\\w:()_]*)#?(?:[.!/\\\\\\w]*))?)", 134 | errorMessage: { pattern: "Please enter a valid URL" }, 135 | $schema: "http://json-schema.org/draft-07/schema#", 136 | }; 137 | 138 | assert(jsonSchema, expected); 139 | } 140 | 141 | // Ajv checks 142 | { 143 | const ajvSchema = ajv.compile(jsonSchema); 144 | 145 | // @ts-expect-error 146 | function assertAjvErrors(input: unknown, errorKeywords: string[] | null) { 147 | assert(ajvSchema(input), !errorKeywords); 148 | assert(ajvSchema.errors?.map((e) => e.keyword) ?? null, errorKeywords); 149 | } 150 | 151 | assertAjvErrors( 152 | "https://github.com/StefanTerdell/zod-to-json-schema/issues/154", 153 | null, 154 | ); 155 | assertAjvErrors("", ["minLength", "errorMessage"]); 156 | assertAjvErrors("invalid url", ["errorMessage"]); 157 | assertAjvErrors( 158 | "http://www.ok-url-but-too-long.com/" + "x".repeat(1000), 159 | ["maxLength"], 160 | ); 161 | assertAjvErrors("invalid url and too long" + "x".repeat(1000), [ 162 | "maxLength", 163 | "errorMessage", 164 | ]); 165 | } 166 | }); 167 | 168 | test("should be possible to use lazy recursion @162", (assert) => { 169 | const A: any = z.object({ 170 | ref1: z.lazy(() => B), 171 | }); 172 | 173 | const B = z.object({ 174 | ref1: A, 175 | }); 176 | 177 | const result = zodToJsonSchema(A); 178 | 179 | const expected = { 180 | $schema: "http://json-schema.org/draft-07/schema#", 181 | type: "object", 182 | properties: { 183 | ref1: { 184 | type: "object", 185 | properties: { 186 | ref1: { 187 | $ref: "#", 188 | }, 189 | }, 190 | required: ["ref1"], 191 | additionalProperties: false, 192 | }, 193 | }, 194 | required: ["ref1"], 195 | additionalProperties: false, 196 | }; 197 | 198 | assert(result, expected); 199 | }); 200 | 201 | test("No additionalProperties @168", (assert) => { 202 | const extractedDataSchema = z 203 | .object({ 204 | document_type: z.string().nullable().optional(), 205 | vendor: z 206 | .object({ 207 | name: z.string().nullable().optional(), 208 | address: z.string().nullable().optional(), 209 | country: z.string().nullable().optional(), 210 | phone_number: z.string().nullable().optional(), 211 | website: z.string().nullable().optional(), 212 | }) 213 | .passthrough() 214 | .optional(), 215 | }) 216 | .passthrough(); 217 | 218 | const expected = { 219 | type: "object", 220 | properties: { 221 | document_type: { 222 | type: "string", 223 | nullable: true, 224 | }, 225 | vendor: { 226 | type: "object", 227 | properties: { 228 | name: { 229 | type: "string", 230 | nullable: true, 231 | }, 232 | address: { 233 | type: "string", 234 | nullable: true, 235 | }, 236 | country: { 237 | type: "string", 238 | nullable: true, 239 | }, 240 | phone_number: { 241 | type: "string", 242 | nullable: true, 243 | }, 244 | website: { 245 | type: "string", 246 | nullable: true, 247 | }, 248 | }, 249 | }, 250 | }, 251 | }; 252 | 253 | // With passthrough:undefined 254 | { 255 | const aiResponseJsonSchema = zodToJsonSchema(extractedDataSchema, { 256 | target: "openApi3", 257 | removeAdditionalStrategy: "strict", 258 | allowedAdditionalProperties: undefined, 259 | strictUnions: true, 260 | }); 261 | 262 | assert(aiResponseJsonSchema, expected); 263 | } 264 | 265 | // Using postProcess 266 | { 267 | const aiResponseJsonSchema = zodToJsonSchema(extractedDataSchema, { 268 | target: "openApi3", 269 | removeAdditionalStrategy: "passthrough", 270 | strictUnions: true, 271 | postProcess: (jsonSchema) => 272 | jsonSchema && 273 | Object.fromEntries( 274 | Object.entries(jsonSchema).filter( 275 | ([key]) => key !== "additionalProperties", 276 | ), 277 | ), 278 | }); 279 | 280 | assert(aiResponseJsonSchema, expected); 281 | } 282 | }); 283 | }); 284 | -------------------------------------------------------------------------------- /test/openApiMode.test.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod/v3"; 2 | import { zodToJsonSchema } from "../src/zodToJsonSchema.js"; 3 | import { suite } from "./suite.js"; 4 | 5 | suite("Open API target", (test) => { 6 | test("should use nullable boolean property and not use $schema property", (assert) => { 7 | const editCompanySchema = z.object({ 8 | companyId: z.string().nullable(), 9 | name: z.string().nullable().optional(), 10 | something: z.literal("hej"), 11 | }); 12 | 13 | const swaggerSchema = zodToJsonSchema(editCompanySchema, { 14 | target: "openApi3", 15 | }); 16 | 17 | const expectedSchema = { 18 | // $schema: "http://json-schema.org/draft-07/schema#", 19 | additionalProperties: false, 20 | properties: { 21 | companyId: { type: "string", nullable: true }, 22 | name: { type: "string", nullable: true }, 23 | something: { type: "string", enum: ["hej"] }, 24 | }, 25 | required: ["companyId", "something"], 26 | type: "object", 27 | }; 28 | 29 | assert(swaggerSchema, expectedSchema); 30 | }); 31 | 32 | test("should not use the enumNames keyword from the records parser when an enum is present", (assert) => { 33 | const recordSchema = z.record(z.enum(["a", "b", "c"]), z.boolean()); 34 | 35 | const swaggerSchema = zodToJsonSchema(recordSchema, { 36 | target: "openApi3", 37 | }); 38 | 39 | const expectedSchema = { 40 | type: "object", 41 | required: ["a", "b", "c"], 42 | properties: { 43 | a: { type: "boolean" }, 44 | b: { $ref: "#/properties/a" }, 45 | c: { $ref: "#/properties/a" }, 46 | }, 47 | additionalProperties: false, 48 | }; 49 | 50 | assert(swaggerSchema, expectedSchema); 51 | }); 52 | 53 | test("should properly reference nullable schemas in an array", (assert) => { 54 | const legalReasonSchema = z 55 | .object({ 56 | reason: z.enum(["FOO", "BAR"]), 57 | }) 58 | .strict(); 59 | 60 | const identityRequestSchema = z 61 | .object({ 62 | alias: z 63 | .object({ 64 | legalReason: legalReasonSchema.nullish(), // reused here 65 | }) 66 | .strict(), 67 | requiredLegalReasonTypes: z 68 | .array(legalReasonSchema.shape.reason) 69 | .nullish(), // reused here 70 | }) 71 | .strict(); 72 | 73 | const result = zodToJsonSchema(identityRequestSchema, { 74 | target: "openApi3", 75 | }); 76 | 77 | const expected = { 78 | type: "object", 79 | properties: { 80 | alias: { 81 | type: "object", 82 | properties: { 83 | legalReason: { 84 | type: "object", 85 | properties: { reason: { type: "string", enum: ["FOO", "BAR"] } }, 86 | required: ["reason"], 87 | additionalProperties: false, 88 | nullable: true, 89 | }, 90 | }, 91 | additionalProperties: false, 92 | }, 93 | requiredLegalReasonTypes: { 94 | type: "array", 95 | items: { 96 | $ref: "#/properties/alias/properties/legalReason/properties/reason", 97 | }, 98 | nullable: true, 99 | }, 100 | }, 101 | required: ["alias"], 102 | additionalProperties: false, 103 | }; 104 | 105 | assert(result, expected); 106 | }); 107 | 108 | test("should properly reference nullable schemas", (assert) => { 109 | const pictureSchema = z 110 | .object({ 111 | id: z.number().int().positive(), 112 | filename: z.string(), 113 | }) 114 | .strict(); 115 | 116 | const userSchema = z 117 | .object({ 118 | id: z.number().int().positive(), 119 | name: z.string().min(2), 120 | photo: pictureSchema, 121 | cover: pictureSchema.nullable(), 122 | }) 123 | .strict(); 124 | 125 | const result = zodToJsonSchema(userSchema, { 126 | target: "openApi3", 127 | }); 128 | 129 | const expected = { 130 | type: "object", 131 | properties: { 132 | id: { 133 | type: "integer", 134 | exclusiveMinimum: true, 135 | minimum: 0, 136 | }, 137 | name: { 138 | type: "string", 139 | minLength: 2, 140 | }, 141 | photo: { 142 | type: "object", 143 | properties: { 144 | id: { 145 | type: "integer", 146 | exclusiveMinimum: true, 147 | minimum: 0, 148 | }, 149 | filename: { 150 | type: "string", 151 | }, 152 | }, 153 | required: ["id", "filename"], 154 | additionalProperties: false, 155 | }, 156 | cover: { 157 | allOf: [ 158 | { 159 | $ref: "#/properties/photo", 160 | }, 161 | ], 162 | nullable: true, 163 | }, 164 | }, 165 | required: ["id", "name", "photo", "cover"], 166 | additionalProperties: false, 167 | }; 168 | 169 | assert(result, expected); 170 | }); 171 | 172 | test("should properly reference nullable schemas from definitions with metadata", (assert) => { 173 | const pictureSchema = z 174 | .object({ 175 | id: z.number().int().positive(), 176 | filename: z.string(), 177 | }) 178 | .describe("A picture") 179 | .strict(); 180 | 181 | const userSchema = z 182 | .object({ 183 | id: z.number().int().positive(), 184 | name: z.string().min(2), 185 | photo: pictureSchema, 186 | cover: pictureSchema.nullable(), 187 | }) 188 | .strict(); 189 | 190 | const result = zodToJsonSchema(userSchema, { 191 | target: "openApi3", 192 | definitions: { 193 | Picture: pictureSchema, 194 | }, 195 | }); 196 | 197 | const expected = { 198 | type: "object", 199 | properties: { 200 | id: { type: "integer", exclusiveMinimum: true, minimum: 0 }, 201 | name: { type: "string", minLength: 2 }, 202 | photo: { $ref: "#/definitions/Picture" }, 203 | cover: { 204 | allOf: [{ $ref: "#/definitions/Picture" }], 205 | nullable: true, 206 | description: "A picture", 207 | }, 208 | }, 209 | required: ["id", "name", "photo", "cover"], 210 | additionalProperties: false, 211 | definitions: { 212 | Picture: { 213 | type: "object", 214 | properties: { 215 | id: { type: "integer", exclusiveMinimum: true, minimum: 0 }, 216 | filename: { type: "string" }, 217 | }, 218 | required: ["id", "filename"], 219 | additionalProperties: false, 220 | description: "A picture", 221 | }, 222 | }, 223 | }; 224 | 225 | assert(result, expected); 226 | }); 227 | 228 | test("should properly reference nullable schemas from definitions and maintain valid nested references", (assert) => { 229 | const pictureSchema = z 230 | .object({ 231 | id: z.number().int().positive(), 232 | filename: z.string(), 233 | }) 234 | .strict(); 235 | 236 | const coverSchema = pictureSchema.nullable(); 237 | 238 | const userSchema = z 239 | .object({ 240 | id: z.number().int().positive(), 241 | name: z.string().min(2), 242 | cover: coverSchema, 243 | group: z.object({ 244 | id: z.number().int().positive(), 245 | name: z.string().min(2), 246 | cover: coverSchema, 247 | }), 248 | }) 249 | .strict(); 250 | 251 | const result = zodToJsonSchema(userSchema, { 252 | target: "openApi3", 253 | definitions: { 254 | Picture: pictureSchema, 255 | }, 256 | }); 257 | 258 | const expected = { 259 | type: "object", 260 | properties: { 261 | id: { 262 | type: "integer", 263 | exclusiveMinimum: true, 264 | minimum: 0, 265 | }, 266 | name: { 267 | type: "string", 268 | minLength: 2, 269 | }, 270 | cover: { 271 | allOf: [ 272 | { 273 | $ref: "#/definitions/Picture", 274 | }, 275 | ], 276 | nullable: true, 277 | }, 278 | group: { 279 | type: "object", 280 | properties: { 281 | id: { 282 | type: "integer", 283 | exclusiveMinimum: true, 284 | minimum: 0, 285 | }, 286 | name: { 287 | type: "string", 288 | minLength: 2, 289 | }, 290 | cover: { 291 | $ref: "#/properties/cover", 292 | }, 293 | }, 294 | required: ["id", "name", "cover"], 295 | additionalProperties: false, 296 | }, 297 | }, 298 | required: ["id", "name", "cover", "group"], 299 | additionalProperties: false, 300 | definitions: { 301 | Picture: { 302 | type: "object", 303 | properties: { 304 | id: { 305 | type: "integer", 306 | exclusiveMinimum: true, 307 | minimum: 0, 308 | }, 309 | filename: { 310 | type: "string", 311 | }, 312 | }, 313 | required: ["id", "filename"], 314 | additionalProperties: false, 315 | }, 316 | }, 317 | }; 318 | 319 | assert(result, expected); 320 | }); 321 | 322 | test("should properly reference nullable schemas from definitions and maintain valid nested references with metadata", (assert) => { 323 | const pictureSchema = z 324 | .object({ 325 | id: z.number().int().positive(), 326 | filename: z.string(), 327 | }) 328 | .strict(); 329 | 330 | const coverSchema = pictureSchema.nullable(); 331 | 332 | const userSchema = z 333 | .object({ 334 | id: z.number().int().positive(), 335 | name: z.string().min(2), 336 | cover: coverSchema.describe("A user cover"), 337 | group: z.object({ 338 | id: z.number().int().positive(), 339 | name: z.string().min(2), 340 | cover: coverSchema, 341 | }), 342 | }) 343 | .strict(); 344 | 345 | const result = zodToJsonSchema(userSchema, { 346 | target: "openApi3", 347 | definitions: { 348 | Picture: pictureSchema, 349 | }, 350 | }); 351 | 352 | const expected = { 353 | type: "object", 354 | properties: { 355 | id: { 356 | type: "integer", 357 | exclusiveMinimum: true, 358 | minimum: 0, 359 | }, 360 | name: { 361 | type: "string", 362 | minLength: 2, 363 | }, 364 | cover: { 365 | allOf: [ 366 | { 367 | $ref: "#/definitions/Picture", 368 | }, 369 | ], 370 | nullable: true, 371 | description: "A user cover", 372 | }, 373 | group: { 374 | type: "object", 375 | properties: { 376 | id: { 377 | type: "integer", 378 | exclusiveMinimum: true, 379 | minimum: 0, 380 | }, 381 | name: { 382 | type: "string", 383 | minLength: 2, 384 | }, 385 | cover: { 386 | allOf: [ 387 | { 388 | $ref: "#/definitions/Picture", 389 | }, 390 | ], 391 | nullable: true, 392 | }, 393 | }, 394 | required: ["id", "name", "cover"], 395 | additionalProperties: false, 396 | }, 397 | }, 398 | required: ["id", "name", "cover", "group"], 399 | additionalProperties: false, 400 | definitions: { 401 | Picture: { 402 | type: "object", 403 | properties: { 404 | id: { 405 | type: "integer", 406 | exclusiveMinimum: true, 407 | minimum: 0, 408 | }, 409 | filename: { 410 | type: "string", 411 | }, 412 | }, 413 | required: ["id", "filename"], 414 | additionalProperties: false, 415 | }, 416 | }, 417 | }; 418 | 419 | assert(result, expected); 420 | }); 421 | }); 422 | -------------------------------------------------------------------------------- /src/parsers/string.ts: -------------------------------------------------------------------------------- 1 | import { ZodStringDef } from "zod/v3"; 2 | import { ErrorMessages, setResponseValueAndErrors } from "../errorMessages.js"; 3 | import { Refs } from "../Refs.js"; 4 | 5 | let emojiRegex: RegExp | undefined = undefined; 6 | 7 | /** 8 | * Generated from the regular expressions found here as of 2024-05-22: 9 | * https://github.com/colinhacks/zod/blob/master/src/types.ts. 10 | * 11 | * Expressions with /i flag have been changed accordingly. 12 | */ 13 | export const zodPatterns = { 14 | /** 15 | * `c` was changed to `[cC]` to replicate /i flag 16 | */ 17 | cuid: /^[cC][^\s-]{8,}$/, 18 | cuid2: /^[0-9a-z]+$/, 19 | ulid: /^[0-9A-HJKMNP-TV-Z]{26}$/, 20 | /** 21 | * `a-z` was added to replicate /i flag 22 | */ 23 | email: 24 | /^(?!\.)(?!.*\.\.)([a-zA-Z0-9_'+\-\.]*)[a-zA-Z0-9_+-]@([a-zA-Z0-9][a-zA-Z0-9\-]*\.)+[a-zA-Z]{2,}$/, 25 | /** 26 | * Constructed a valid Unicode RegExp 27 | * 28 | * Lazily instantiate since this type of regex isn't supported 29 | * in all envs (e.g. React Native). 30 | * 31 | * See: 32 | * https://github.com/colinhacks/zod/issues/2433 33 | * Fix in Zod: 34 | * https://github.com/colinhacks/zod/commit/9340fd51e48576a75adc919bff65dbc4a5d4c99b 35 | */ 36 | emoji: () => { 37 | if (emojiRegex === undefined) { 38 | emojiRegex = RegExp( 39 | "^(\\p{Extended_Pictographic}|\\p{Emoji_Component})+$", 40 | "u", 41 | ); 42 | } 43 | return emojiRegex; 44 | }, 45 | /** 46 | * Unused 47 | */ 48 | uuid: /^[0-9a-fA-F]{8}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{12}$/, 49 | /** 50 | * Unused 51 | */ 52 | ipv4: /^(?:(?: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])$/, 53 | ipv4Cidr: 54 | /^(?:(?: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])\/(3[0-2]|[12]?[0-9])$/, 55 | /** 56 | * Unused 57 | */ 58 | ipv6: /^(([a-f0-9]{1,4}:){7}|::([a-f0-9]{1,4}:){0,6}|([a-f0-9]{1,4}:){1}:([a-f0-9]{1,4}:){0,5}|([a-f0-9]{1,4}:){2}:([a-f0-9]{1,4}:){0,4}|([a-f0-9]{1,4}:){3}:([a-f0-9]{1,4}:){0,3}|([a-f0-9]{1,4}:){4}:([a-f0-9]{1,4}:){0,2}|([a-f0-9]{1,4}:){5}:([a-f0-9]{1,4}:){0,1})([a-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})))$/, 59 | ipv6Cidr: 60 | /^(([0-9a-fA-F]{1,4}:){7,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}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))\/(12[0-8]|1[01][0-9]|[1-9]?[0-9])$/, 61 | base64: /^([0-9a-zA-Z+/]{4})*(([0-9a-zA-Z+/]{2}==)|([0-9a-zA-Z+/]{3}=))?$/, 62 | base64url: 63 | /^([0-9a-zA-Z-_]{4})*(([0-9a-zA-Z-_]{2}(==)?)|([0-9a-zA-Z-_]{3}(=)?))?$/, 64 | nanoid: /^[a-zA-Z0-9_-]{21}$/, 65 | jwt: /^[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+\.[A-Za-z0-9-_]*$/, 66 | } as const; 67 | 68 | export type JsonSchema7StringType = { 69 | type: "string"; 70 | minLength?: number; 71 | maxLength?: number; 72 | format?: 73 | | "email" 74 | | "idn-email" 75 | | "uri" 76 | | "uuid" 77 | | "date-time" 78 | | "ipv4" 79 | | "ipv6" 80 | | "date" 81 | | "time" 82 | | "duration"; 83 | pattern?: string; 84 | allOf?: { 85 | pattern: string; 86 | errorMessage?: ErrorMessages<{ pattern: string }>; 87 | }[]; 88 | anyOf?: { 89 | format: string; 90 | errorMessage?: ErrorMessages<{ format: string }>; 91 | }[]; 92 | errorMessage?: ErrorMessages; 93 | contentEncoding?: string; 94 | }; 95 | 96 | export function parseStringDef( 97 | def: ZodStringDef, 98 | refs: Refs, 99 | ): JsonSchema7StringType { 100 | const res: JsonSchema7StringType = { 101 | type: "string", 102 | }; 103 | 104 | if (def.checks) { 105 | for (const check of def.checks) { 106 | switch (check.kind) { 107 | case "min": 108 | setResponseValueAndErrors( 109 | res, 110 | "minLength", 111 | typeof res.minLength === "number" 112 | ? Math.max(res.minLength, check.value) 113 | : check.value, 114 | check.message, 115 | refs, 116 | ); 117 | break; 118 | case "max": 119 | setResponseValueAndErrors( 120 | res, 121 | "maxLength", 122 | typeof res.maxLength === "number" 123 | ? Math.min(res.maxLength, check.value) 124 | : check.value, 125 | check.message, 126 | refs, 127 | ); 128 | 129 | break; 130 | case "email": 131 | switch (refs.emailStrategy) { 132 | case "format:email": 133 | addFormat(res, "email", check.message, refs); 134 | break; 135 | case "format:idn-email": 136 | addFormat(res, "idn-email", check.message, refs); 137 | break; 138 | case "pattern:zod": 139 | addPattern(res, zodPatterns.email, check.message, refs); 140 | break; 141 | } 142 | 143 | break; 144 | case "url": 145 | addFormat(res, "uri", check.message, refs); 146 | break; 147 | case "uuid": 148 | addFormat(res, "uuid", check.message, refs); 149 | break; 150 | case "regex": 151 | addPattern(res, check.regex, check.message, refs); 152 | break; 153 | case "cuid": 154 | addPattern(res, zodPatterns.cuid, check.message, refs); 155 | break; 156 | case "cuid2": 157 | addPattern(res, zodPatterns.cuid2, check.message, refs); 158 | break; 159 | case "startsWith": 160 | addPattern( 161 | res, 162 | RegExp(`^${escapeLiteralCheckValue(check.value, refs)}`), 163 | check.message, 164 | refs, 165 | ); 166 | break; 167 | case "endsWith": 168 | addPattern( 169 | res, 170 | RegExp(`${escapeLiteralCheckValue(check.value, refs)}$`), 171 | check.message, 172 | refs, 173 | ); 174 | break; 175 | case "datetime": 176 | addFormat(res, "date-time", check.message, refs); 177 | break; 178 | case "date": 179 | addFormat(res, "date", check.message, refs); 180 | break; 181 | case "time": 182 | addFormat(res, "time", check.message, refs); 183 | break; 184 | case "duration": 185 | addFormat(res, "duration", check.message, refs); 186 | break; 187 | case "length": 188 | setResponseValueAndErrors( 189 | res, 190 | "minLength", 191 | typeof res.minLength === "number" 192 | ? Math.max(res.minLength, check.value) 193 | : check.value, 194 | check.message, 195 | refs, 196 | ); 197 | setResponseValueAndErrors( 198 | res, 199 | "maxLength", 200 | typeof res.maxLength === "number" 201 | ? Math.min(res.maxLength, check.value) 202 | : check.value, 203 | check.message, 204 | refs, 205 | ); 206 | break; 207 | case "includes": { 208 | addPattern( 209 | res, 210 | RegExp(escapeLiteralCheckValue(check.value, refs)), 211 | check.message, 212 | refs, 213 | ); 214 | break; 215 | } 216 | case "ip": { 217 | if (check.version !== "v6") { 218 | addFormat(res, "ipv4", check.message, refs); 219 | } 220 | if (check.version !== "v4") { 221 | addFormat(res, "ipv6", check.message, refs); 222 | } 223 | break; 224 | } 225 | case "base64url": 226 | addPattern(res, zodPatterns.base64url, check.message, refs); 227 | break; 228 | case "jwt": 229 | addPattern(res, zodPatterns.jwt, check.message, refs); 230 | break; 231 | case "cidr": { 232 | if (check.version !== "v6") { 233 | addPattern(res, zodPatterns.ipv4Cidr, check.message, refs); 234 | } 235 | if (check.version !== "v4") { 236 | addPattern(res, zodPatterns.ipv6Cidr, check.message, refs); 237 | } 238 | break; 239 | } 240 | case "emoji": 241 | addPattern(res, zodPatterns.emoji(), check.message, refs); 242 | break; 243 | case "ulid": { 244 | addPattern(res, zodPatterns.ulid, check.message, refs); 245 | break; 246 | } 247 | case "base64": { 248 | switch (refs.base64Strategy) { 249 | case "format:binary": { 250 | addFormat(res, "binary" as any, check.message, refs); 251 | break; 252 | } 253 | 254 | case "contentEncoding:base64": { 255 | setResponseValueAndErrors( 256 | res, 257 | "contentEncoding", 258 | "base64", 259 | check.message, 260 | refs, 261 | ); 262 | break; 263 | } 264 | 265 | case "pattern:zod": { 266 | addPattern(res, zodPatterns.base64, check.message, refs); 267 | break; 268 | } 269 | } 270 | break; 271 | } 272 | case "nanoid": { 273 | addPattern(res, zodPatterns.nanoid, check.message, refs); 274 | } 275 | case "toLowerCase": 276 | case "toUpperCase": 277 | case "trim": 278 | break; 279 | default: 280 | ((_: never) => {})(check); 281 | } 282 | } 283 | } 284 | 285 | return res; 286 | } 287 | 288 | function escapeLiteralCheckValue(literal: string, refs: Refs): string { 289 | return refs.patternStrategy === "escape" 290 | ? escapeNonAlphaNumeric(literal) 291 | : literal; 292 | } 293 | 294 | const ALPHA_NUMERIC = new Set( 295 | "ABCDEFGHIJKLMNOPQRSTUVXYZabcdefghijklmnopqrstuvxyz0123456789", 296 | ); 297 | 298 | function escapeNonAlphaNumeric(source: string) { 299 | let result = ""; 300 | 301 | for (let i = 0; i < source.length; i++) { 302 | if (!ALPHA_NUMERIC.has(source[i])) { 303 | result += "\\"; 304 | } 305 | 306 | result += source[i]; 307 | } 308 | 309 | return result; 310 | } 311 | 312 | // Adds a "format" keyword to the schema. If a format exists, both formats will be joined in an allOf-node, along with subsequent ones. 313 | function addFormat( 314 | schema: JsonSchema7StringType, 315 | value: Required["format"], 316 | message: string | undefined, 317 | refs: Refs, 318 | ) { 319 | if (schema.format || schema.anyOf?.some((x) => x.format)) { 320 | if (!schema.anyOf) { 321 | schema.anyOf = []; 322 | } 323 | 324 | if (schema.format) { 325 | schema.anyOf!.push({ 326 | format: schema.format, 327 | ...(schema.errorMessage && 328 | refs.errorMessages && { 329 | errorMessage: { format: schema.errorMessage.format }, 330 | }), 331 | }); 332 | delete schema.format; 333 | if (schema.errorMessage) { 334 | delete schema.errorMessage.format; 335 | if (Object.keys(schema.errorMessage).length === 0) { 336 | delete schema.errorMessage; 337 | } 338 | } 339 | } 340 | 341 | schema.anyOf!.push({ 342 | format: value, 343 | ...(message && 344 | refs.errorMessages && { errorMessage: { format: message } }), 345 | }); 346 | } else { 347 | setResponseValueAndErrors(schema, "format", value, message, refs); 348 | } 349 | } 350 | 351 | // Adds a "pattern" keyword to the schema. If a pattern exists, both patterns will be joined in an allOf-node, along with subsequent ones. 352 | function addPattern( 353 | schema: JsonSchema7StringType, 354 | regex: RegExp, 355 | message: string | undefined, 356 | refs: Refs, 357 | ) { 358 | if (schema.pattern || schema.allOf?.some((x) => x.pattern)) { 359 | if (!schema.allOf) { 360 | schema.allOf = []; 361 | } 362 | 363 | if (schema.pattern) { 364 | schema.allOf!.push({ 365 | pattern: schema.pattern, 366 | ...(schema.errorMessage && 367 | refs.errorMessages && { 368 | errorMessage: { pattern: schema.errorMessage.pattern }, 369 | }), 370 | }); 371 | delete schema.pattern; 372 | if (schema.errorMessage) { 373 | delete schema.errorMessage.pattern; 374 | if (Object.keys(schema.errorMessage).length === 0) { 375 | delete schema.errorMessage; 376 | } 377 | } 378 | } 379 | 380 | schema.allOf!.push({ 381 | pattern: stringifyRegExpWithFlags(regex, refs), 382 | ...(message && 383 | refs.errorMessages && { errorMessage: { pattern: message } }), 384 | }); 385 | } else { 386 | setResponseValueAndErrors( 387 | schema, 388 | "pattern", 389 | stringifyRegExpWithFlags(regex, refs), 390 | message, 391 | refs, 392 | ); 393 | } 394 | } 395 | 396 | // Mutate z.string.regex() in a best attempt to accommodate for regex flags when applyRegexFlags is true 397 | function stringifyRegExpWithFlags(regex: RegExp, refs: Refs): string { 398 | if (!refs.applyRegexFlags || !regex.flags) { 399 | return regex.source; 400 | } 401 | 402 | // Currently handled flags 403 | const flags = { 404 | i: regex.flags.includes("i"), // Case-insensitive 405 | m: regex.flags.includes("m"), // `^` and `$` matches adjacent to newline characters 406 | s: regex.flags.includes("s"), // `.` matches newlines 407 | }; 408 | 409 | // The general principle here is to step through each character, one at a time, applying mutations as flags require. We keep track when the current character is escaped, and when it's inside a group /like [this]/ or (also) a range like /[a-z]/. The following is fairly brittle imperative code; edit at your peril! 410 | const source = flags.i ? regex.source.toLowerCase() : regex.source; 411 | let pattern = ""; 412 | let isEscaped = false; 413 | let inCharGroup = false; 414 | let inCharRange = false; 415 | 416 | for (let i = 0; i < source.length; i++) { 417 | if (isEscaped) { 418 | pattern += source[i]; 419 | isEscaped = false; 420 | continue; 421 | } 422 | 423 | if (flags.i) { 424 | if (inCharGroup) { 425 | if (source[i].match(/[a-z]/)) { 426 | if (inCharRange) { 427 | pattern += source[i]; 428 | pattern += `${source[i - 2]}-${source[i]}`.toUpperCase(); 429 | inCharRange = false; 430 | } else if (source[i + 1] === "-" && source[i + 2]?.match(/[a-z]/)) { 431 | pattern += source[i]; 432 | inCharRange = true; 433 | } else { 434 | pattern += `${source[i]}${source[i].toUpperCase()}`; 435 | } 436 | continue; 437 | } 438 | } else if (source[i].match(/[a-z]/)) { 439 | pattern += `[${source[i]}${source[i].toUpperCase()}]`; 440 | continue; 441 | } 442 | } 443 | 444 | if (flags.m) { 445 | if (source[i] === "^") { 446 | pattern += `(^|(?<=[\r\n]))`; 447 | continue; 448 | } else if (source[i] === "$") { 449 | pattern += `($|(?=[\r\n]))`; 450 | continue; 451 | } 452 | } 453 | 454 | if (flags.s && source[i] === ".") { 455 | pattern += inCharGroup ? `${source[i]}\r\n` : `[${source[i]}\r\n]`; 456 | continue; 457 | } 458 | 459 | pattern += source[i]; 460 | if (source[i] === "\\") { 461 | isEscaped = true; 462 | } else if (inCharGroup && source[i] === "]") { 463 | inCharGroup = false; 464 | } else if (!inCharGroup && source[i] === "[") { 465 | inCharGroup = true; 466 | } 467 | } 468 | 469 | try { 470 | new RegExp(pattern); 471 | } catch { 472 | console.warn( 473 | `Could not convert regex pattern at ${refs.currentPath.join( 474 | "/", 475 | )} to a flag-independent form! Falling back to the flag-ignorant source`, 476 | ); 477 | return regex.source; 478 | } 479 | 480 | return pattern; 481 | } 482 | --------------------------------------------------------------------------------