import type * as JSONSchema from "../core/json-schema.js"; import { type $ZodRegistry, globalRegistry } from "../core/registries.js"; import { assignProp, isPlainObject } from "../core/util.js"; import * as _checks from "./checks.js"; import * as _iso from "./iso.js"; import * as _schemas from "./schemas.js"; import type { ZodNumber, ZodString, ZodType } from "./schemas.js"; // Local z object to avoid circular dependency with ../index.js const z = { ..._schemas, ..._checks, iso: _iso, }; type JSONSchemaVersion = "draft-2020-12" | "draft-7" | "draft-4" | "openapi-3.0"; interface FromJSONSchemaParams { defaultTarget?: JSONSchemaVersion; registry?: $ZodRegistry; } interface ConversionContext { version: JSONSchemaVersion; defs: Record; refs: Map; processing: Set; rootSchema: JSONSchema.JSONSchema; registry: $ZodRegistry; } // Keys that are recognized and handled by the conversion logic const RECOGNIZED_KEYS = /*@__PURE__*/ new Set([ // Schema identification "$schema", "$ref", "$defs", "definitions", // Core schema keywords "$id", "id", "$comment", "$anchor", "$vocabulary", "$dynamicRef", "$dynamicAnchor", // Type "type", "enum", "const", // Composition "anyOf", "oneOf", "allOf", "not", // Object "properties", "required", "additionalProperties", "patternProperties", "propertyNames", "minProperties", "maxProperties", // Array "items", "prefixItems", "additionalItems", "minItems", "maxItems", "uniqueItems", "contains", "minContains", "maxContains", // String "minLength", "maxLength", "pattern", "format", // Number "minimum", "maximum", "exclusiveMinimum", "exclusiveMaximum", "multipleOf", // Already handled metadata "description", "default", // Content "contentEncoding", "contentMediaType", "contentSchema", // Unsupported (error-throwing) "unevaluatedItems", "unevaluatedProperties", "if", "then", "else", "dependentSchemas", "dependentRequired", // OpenAPI "nullable", "readOnly", ]); function detectVersion(schema: JSONSchema.JSONSchema, defaultTarget?: JSONSchemaVersion): JSONSchemaVersion { const $schema = schema.$schema; if ($schema === "https://json-schema.org/draft/2020-12/schema") { return "draft-2020-12"; } if ($schema === "http://json-schema.org/draft-07/schema#") { return "draft-7"; } if ($schema === "http://json-schema.org/draft-04/schema#") { return "draft-4"; } // Use defaultTarget if provided, otherwise default to draft-2020-12 return defaultTarget ?? "draft-2020-12"; } // Positional schemas constrain the elements that are present; only minItems makes them required. function applyMinItems(items: ZodType[], minItems: number): ZodType[] { return items.map((item, index) => (index < minItems ? item : item.optional())); } // Inverse of the encoding applied to $ref pointer segments in to-json-schema.ts. Per RFC 6901 the `~1` replacement must run before `~0`. function decodeJSONPointerSegment(segment: string): string { return segment.replace(/~1/g, "/").replace(/~0/g, "~"); } function resolveRef(ref: string, ctx: ConversionContext): JSONSchema.JSONSchema { if (!ref.startsWith("#")) { throw new Error("External $ref is not supported, only local refs (#/...) are allowed"); } const path = ref.slice(1).split("/").filter(Boolean); // Handle root reference "#" if (path.length === 0) { return ctx.rootSchema; } const defsKey = ctx.version === "draft-2020-12" ? "$defs" : "definitions"; if (path[0] === defsKey) { const key = path[1] === undefined ? undefined : decodeJSONPointerSegment(path[1]); if (!key || !ctx.defs[key]) { throw new Error(`Reference not found: ${ref}`); } return ctx.defs[key]!; } throw new Error(`Reference not found: ${ref}`); } /** * Enforces the keywords that constrain an object's own keys — `propertyNames`, * `minProperties`, `maxProperties` — before `objectSchema` runs. The guard has * to see the raw input: an object parse drops `__proto__` and can add keys from * a property `default`, so its output is not the set of names the instance * actually carried. */ function checkObjectGuards( objectSchema: ZodType, guards: { keySchema?: ZodType | undefined; minProperties?: number | undefined; maxProperties?: number | undefined } ): ZodType { // An identity transform, not z.any(), so `toJSONSchema` reports the object on both the input and the output side of the pipe. const guard = z .transform((value: unknown) => value) .check((payload) => { const value = payload.value; if (typeof value !== "object" || value === null || Array.isArray(value)) return; const keys = Object.getOwnPropertyNames(value); if (guards.minProperties !== undefined && keys.length < guards.minProperties) { payload.issues.push({ origin: "object", code: "too_small", minimum: guards.minProperties, inclusive: true, message: `Too small: expected object to have >=${guards.minProperties} properties`, input: value, inst: objectSchema, continue: true, }); } if (guards.maxProperties !== undefined && keys.length > guards.maxProperties) { payload.issues.push({ origin: "object", code: "too_big", maximum: guards.maxProperties, inclusive: true, message: `Too big: expected object to have <=${guards.maxProperties} properties`, input: value, inst: objectSchema, continue: true, }); } if (guards.keySchema) { for (const key of keys) { const result = guards.keySchema.safeParse(key); if (result.success) continue; payload.issues.push({ code: "invalid_key", origin: "record", issues: result.error.issues, input: key, path: [key], continue: true, }); } } }); return guard.pipe(objectSchema); } /** * An injective string for JSON data: two values share a key exactly when `uniqueItems` calls them * equal, so duplicate detection is a Map lookup rather than a pairwise walk. Strings and keys are * length-prefixed so no value can spell another one. `null` means "never equal to anything" — a * cycle or a NaN, neither of which JSON can express. */ function canonicalKey(value: unknown, seen: Set): string | null { if (value === null) return "z"; const type = typeof value; if (type !== "object") { if (type === "number" && Number.isNaN(value)) return null; const raw = String(value); return `${type[0]}${raw.length}:${raw}`; } if (seen.has(value as object)) return null; seen.add(value as object); try { if (Array.isArray(value)) { const parts: string[] = []; for (const item of value) { const key = canonicalKey(item, seen); if (key === null) return null; parts.push(key); } return `a${parts.length}:[${parts.join(",")}]`; } const keys = Object.keys(value as object).sort(); const parts: string[] = []; for (const k of keys) { const key = canonicalKey((value as any)[k], seen); if (key === null) return null; parts.push(`${k.length}:${k}=${key}`); } return `o${parts.length}:{${parts.join(",")}}`; } finally { seen.delete(value as object); } } // keywords whose value is a schema or an array of them const SCHEMA_KEYWORDS = /*@__PURE__*/ new Set([ "items", "prefixItems", "additionalItems", "additionalProperties", "contains", "propertyNames", "not", "if", "then", "else", "allOf", "anyOf", "oneOf", "unevaluatedItems", "unevaluatedProperties", "contentSchema", ]); // keywords whose value maps names to schemas. draft-7 `dependencies` also allows an array of property names, which holds no schema to walk const SCHEMA_MAP_KEYWORDS = /*@__PURE__*/ new Set([ "properties", "patternProperties", "dependentSchemas", "dependencies", "$defs", "definitions", ]); /** * True when a subschema holds a `$ref` anywhere a schema can appear. Only those positions are * walked: `$ref` under `default` or an `x-` annotation is instance data with an ordinary key, and * reading it as a reference would drop a constraint that resolves perfectly well. */ function containsRef(value: unknown): boolean { if (typeof value !== "object" || value === null) return false; if (Array.isArray(value)) return value.some(containsRef); if (typeof (value as any).$ref === "string") return true; return Object.entries(value).some(([key, sub]) => { if (SCHEMA_KEYWORDS.has(key)) return containsRef(sub); if (!SCHEMA_MAP_KEYWORDS.has(key) || typeof sub !== "object" || sub === null) return false; return Object.values(sub).some(containsRef); }); } function plural(n: number): string { return n === 1 ? "element" : "elements"; } /** * Enforces `uniqueItems` and the `contains` family before `arraySchema` runs, for the same reason * `checkObjectGuards` runs pre-pipe: an item parse can apply a nested `default`, so the parsed * array is not the instance the keywords are defined over. A guard issue aborts the pipe, so * `minItems`/`maxItems` are not also reported on the same parse — instance correctness is worth * more than co-reporting two issues. */ function checkArrayGuards( arraySchema: ZodType, guards: { uniqueItems?: boolean | undefined; containsSchema?: ZodType | undefined; minContains?: number | undefined; maxContains?: number | undefined; } ): ZodType { // An identity transform, not z.any(), so `toJSONSchema` reports the array on both the input and the output side of the pipe. const guard = z .transform((value: unknown) => value) .check((payload) => { const items = payload.value; if (!Array.isArray(items)) return; if (guards.uniqueItems === true) { const firstSeen = new Map(); for (let i = 0; i < items.length; i++) { const key = canonicalKey(items[i], new Set()); if (key === null) continue; const first = firstSeen.get(key); if (first === undefined) { firstSeen.set(key, i); continue; } payload.issues.push({ code: "custom", message: `Array items must be unique: element at index ${i} duplicates the one at index ${first}`, input: items, path: [i], continue: true, }); } } if (guards.containsSchema) { const minContains = guards.minContains ?? 1; // one past the ceiling already proves the failure, and nothing above it changes the verdict const ceiling = guards.maxContains !== undefined ? guards.maxContains + 1 : Number.POSITIVE_INFINITY; let matches = 0; for (const item of items) { if (guards.containsSchema.safeParse(item).success && ++matches >= ceiling) break; } if (matches < minContains) { // the scan ran to the end, so this count is exact payload.issues.push({ code: "custom", message: `Array must contain at least ${minContains} matching ${plural(minContains)}; found ${matches}`, input: items, continue: true, }); } if (guards.maxContains !== undefined && matches > guards.maxContains) { payload.issues.push({ code: "custom", message: `Array must contain at most ${guards.maxContains} matching ${plural(guards.maxContains)}`, input: items, continue: true, }); } } }); return guard.pipe(arraySchema); } function getTupleRest(restSchema: JSONSchema._JSONSchema | undefined, ctx: ConversionContext): ZodType | undefined { if (restSchema === false) { return undefined; } if (restSchema === undefined || restSchema === true) { return z.any(); } return convertSchema(restSchema, ctx); } // the RFC 3339 full-time keyword, narrower than `z.iso.time()`; local because sharing it via `timeSource` pins that builder into every bundle (+139 B gzipped on a mini `z.boolean()`) const fullTime = /^(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|[+-](?:[01]\d|2[0-3]):[0-5]\d)$/; function convertBaseSchema(schema: JSONSchema.JSONSchema, ctx: ConversionContext): ZodType { // Handle unsupported features if (schema.not !== undefined) { // Special case: { not: {} } represents never if (typeof schema.not === "object" && Object.keys(schema.not).length === 0) { return z.never(); } throw new Error("not is not supported in Zod (except { not: {} } for never)"); } if (schema.unevaluatedItems !== undefined) { throw new Error("unevaluatedItems is not supported"); } if (schema.unevaluatedProperties !== undefined) { throw new Error("unevaluatedProperties is not supported"); } if (schema.if !== undefined || schema.then !== undefined || schema.else !== undefined) { throw new Error("Conditional schemas (if/then/else) are not supported"); } if (schema.dependentSchemas !== undefined || schema.dependentRequired !== undefined) { throw new Error("dependentSchemas and dependentRequired are not supported"); } // Handle $ref if (schema.$ref) { const refPath = schema.$ref; if (ctx.refs.has(refPath)) { return ctx.refs.get(refPath)!; } if (ctx.processing.has(refPath)) { // Circular reference - use lazy return z.lazy(() => { if (!ctx.refs.has(refPath)) { throw new Error(`Circular reference not resolved: ${refPath}`); } return ctx.refs.get(refPath)!; }); } ctx.processing.add(refPath); const resolved = resolveRef(refPath, ctx); const zodSchema = convertSchema(resolved, ctx); ctx.refs.set(refPath, zodSchema); ctx.processing.delete(refPath); return zodSchema; } // Handle enum if (schema.enum !== undefined) { const enumValues = schema.enum; // Special case: OpenAPI 3.0 null representation { type: "string", nullable: true, enum: [null] } if ( ctx.version === "openapi-3.0" && schema.nullable === true && enumValues.length === 1 && enumValues[0] === null ) { return z.null(); } if (enumValues.length === 0) { return z.never(); } if (enumValues.length === 1) { return z.literal(enumValues[0]!); } // Check if all values are strings if (enumValues.every((v) => typeof v === "string")) { return z.enum(enumValues as [string, ...string[]]); } // Mixed types - use union of literals const literalSchemas = enumValues.map((v) => z.literal(v)); if (literalSchemas.length < 2) { return literalSchemas[0]!; } return z.union([literalSchemas[0]!, literalSchemas[1]!, ...literalSchemas.slice(2)] as [ ZodType, ZodType, ...ZodType[], ]); } // Handle const if (schema.const !== undefined) { return z.literal(schema.const); } // Handle type const type = schema.type; if (Array.isArray(type)) { // Expand type array into anyOf union const typeSchemas = type.map((t) => { const typeSchema: JSONSchema.JSONSchema = { ...schema, type: t }; return convertBaseSchema(typeSchema, ctx); }); if (typeSchemas.length === 0) { return z.never(); } if (typeSchemas.length === 1) { return typeSchemas[0]!; } return z.union(typeSchemas as [ZodType, ZodType, ...ZodType[]]); } if (!type) { // No type specified - empty schema (any) return z.any(); } let zodSchema: ZodType; switch (type) { case "string": { let stringSchema: ZodString = z.string(); // Apply format using .check() with Zod format functions if (schema.format) { const format = schema.format; // Map common formats to Zod check functions if (format === "email") { stringSchema = stringSchema.check(z.email()); } else if (format === "uri" || format === "uri-reference") { stringSchema = stringSchema.check(z.url()); } else if (format === "uuid" || format === "guid") { stringSchema = stringSchema.check(z.uuid()); } else if (format === "date-time") { stringSchema = stringSchema.check(z.iso.datetime({ offset: true })); } else if (format === "date") { stringSchema = stringSchema.check(z.iso.date()); } else if (format === "time") { stringSchema = stringSchema.check(z.regex(fullTime)); } else if (format === "duration") { stringSchema = stringSchema.check(z.iso.duration()); } else if (format === "hostname") { stringSchema = stringSchema.check(z.hostname()); } else if (format === "ipv4") { stringSchema = stringSchema.check(z.ipv4()); } else if (format === "ipv6") { stringSchema = stringSchema.check(z.ipv6()); } else if (format === "mac") { stringSchema = stringSchema.check(z.mac()); } else if (format === "cidr") { stringSchema = stringSchema.check(z.cidrv4()); } else if (format === "cidr-v6") { stringSchema = stringSchema.check(z.cidrv6()); } else if (format === "base64") { stringSchema = stringSchema.check(z.base64()); } else if (format === "base64url") { stringSchema = stringSchema.check(z.base64url()); } else if (format === "e164") { stringSchema = stringSchema.check(z.e164()); } else if (format === "credit_card") { stringSchema = stringSchema.check(z.creditCard()); } else if (format === "iban") { stringSchema = stringSchema.check(z.iban()); } else if (format === "jwt") { stringSchema = stringSchema.check(z.jwt()); } else if (format === "emoji") { stringSchema = stringSchema.check(z.emoji()); } else if (format === "nanoid") { stringSchema = stringSchema.check(z.nanoid()); } else if (format === "cuid") { stringSchema = stringSchema.check(z.cuid()); } else if (format === "cuid2") { stringSchema = stringSchema.check(z.cuid2()); } else if (format === "ulid") { stringSchema = stringSchema.check(z.ulid()); } else if (format === "xid") { stringSchema = stringSchema.check(z.xid()); } else if (format === "ksuid") { stringSchema = stringSchema.check(z.ksuid()); } // Note: json-string format is not currently supported by Zod // Custom formats are ignored - keep as plain string } // Apply constraints if (typeof schema.minLength === "number") { stringSchema = stringSchema.min(schema.minLength); } if (typeof schema.maxLength === "number") { stringSchema = stringSchema.max(schema.maxLength); } if (schema.pattern) { // JSON Schema patterns are not implicitly anchored (match anywhere in string) stringSchema = stringSchema.regex(new RegExp(schema.pattern)); } zodSchema = stringSchema; break; } case "number": case "integer": { let numberSchema: ZodNumber = type === "integer" ? z.number().int() : z.number(); // Apply constraints // In draft-04, `exclusiveMinimum: true` makes the sibling `minimum` exclusive rather than an independent bound, so the inclusive `.min()` is skipped; emitting it too would be dominated by the exclusive check and report a second, weaker issue. if (typeof schema.minimum === "number" && schema.exclusiveMinimum !== true) { numberSchema = numberSchema.min(schema.minimum); } if (typeof schema.maximum === "number" && schema.exclusiveMaximum !== true) { numberSchema = numberSchema.max(schema.maximum); } if (typeof schema.exclusiveMinimum === "number") { numberSchema = numberSchema.gt(schema.exclusiveMinimum); } else if (schema.exclusiveMinimum === true && typeof schema.minimum === "number") { numberSchema = numberSchema.gt(schema.minimum); } if (typeof schema.exclusiveMaximum === "number") { numberSchema = numberSchema.lt(schema.exclusiveMaximum); } else if (schema.exclusiveMaximum === true && typeof schema.maximum === "number") { numberSchema = numberSchema.lt(schema.maximum); } if (typeof schema.multipleOf === "number") { numberSchema = numberSchema.multipleOf(schema.multipleOf); } zodSchema = numberSchema; break; } case "boolean": { zodSchema = z.boolean(); break; } case "null": { zodSchema = z.null(); break; } case "object": { const shape: Record = {}; const properties = schema.properties || {}; const requiredSet = new Set(schema.required || []); const additionalSchema = typeof schema.additionalProperties === "object" ? convertSchema(schema.additionalProperties as JSONSchema.JSONSchema, ctx) : undefined; // Convert properties - mark optional ones for (const [key, propSchema] of Object.entries(properties)) { const propZodSchema = convertSchema(propSchema as JSONSchema.JSONSchema, ctx); // If not in required array, make it optional. assignProp so a __proto__ key becomes an own property instead of hitting the inherited setter assignProp(shape, key, requiredSet.has(key) ? propZodSchema : propZodSchema.optional()); } // Handle patternProperties if (schema.patternProperties) { // patternProperties: keys matching pattern must satisfy corresponding schema. Use loose records so non-matching keys pass through const patternProps = schema.patternProperties; const patternKeys = Object.keys(patternProps); const looseRecords: ZodType[] = []; for (const pattern of patternKeys) { const patternValue = convertSchema(patternProps[pattern] as JSONSchema.JSONSchema, ctx); const keySchema = z.string().regex(new RegExp(pattern)); looseRecords.push(z.looseRecord(keySchema, patternValue)); } // Build intersection: object schema + all pattern property records const schemasToIntersect: ZodType[] = []; if (Object.keys(shape).length > 0) { // Use passthrough so patternProperties can validate additional keys schemasToIntersect.push(z.object(shape).passthrough()); } schemasToIntersect.push(...looseRecords); if (schemasToIntersect.length === 0) { zodSchema = z.object({}).passthrough(); } else if (schemasToIntersect.length === 1) { zodSchema = schemasToIntersect[0]!; } else { // Chain intersections: (A & B) & C & D ... let result = z.intersection(schemasToIntersect[0]!, schemasToIntersect[1]!); for (let i = 2; i < schemasToIntersect.length; i++) { result = z.intersection(result, schemasToIntersect[i]!); } zodSchema = result; } // When additionalProperties is false, reject keys that are neither defined in properties nor matched by any patternProperty. if (schema.additionalProperties === false) { const propertyKeys = Object.keys(shape); const patterns = patternKeys.map((p) => new RegExp(p)); const basePatternSchema = zodSchema; zodSchema = zodSchema.check((payload) => { if (!isPlainObject(payload.value)) return; const unrecognized: string[] = []; for (const key of Object.keys(payload.value)) { if (propertyKeys.includes(key)) continue; if (patterns.some((regex) => regex.test(key))) continue; unrecognized.push(key); } if (unrecognized.length) { payload.issues.push({ code: "unrecognized_keys", keys: unrecognized, input: payload.value, inst: basePatternSchema, }); } }); } } else { // Handle additionalProperties. In JSON Schema, additionalProperties defaults to true (allow any extra properties). In Zod, objects strip unknown keys by default, so we need to handle this explicitly const objectSchema = z.object(shape); if (schema.additionalProperties === false) { // Strict mode - no extra properties allowed zodSchema = objectSchema.strict(); } else if (additionalSchema) { // Extra properties must match the specified schema zodSchema = objectSchema.catchall(additionalSchema); } else { // additionalProperties is true or undefined - allow any extra properties (passthrough) zodSchema = objectSchema.passthrough(); } } // propertyNames constrains key *names* only, and says nothing about which keys are required or how their values validate. Layering it on top of the result keeps properties/patternProperties/additionalProperties composing underneath. `true` allows every name, so it needs no guard. const hasKeyGuard = schema.propertyNames !== undefined && schema.propertyNames !== true; const minProperties = typeof schema.minProperties === "number" ? schema.minProperties : undefined; const maxProperties = typeof schema.maxProperties === "number" ? schema.maxProperties : undefined; if (hasKeyGuard || minProperties !== undefined || maxProperties !== undefined) { let keySchema: ZodType | undefined; if (hasKeyGuard) { // Keys are always strings, so a propertyNames subschema that omits `type` still constrains them — without this it would convert to z.any(). const keyJSONSchema = typeof schema.propertyNames === "object" && schema.propertyNames.type === undefined ? { type: "string", ...schema.propertyNames } : schema.propertyNames; keySchema = convertSchema(keyJSONSchema as JSONSchema.JSONSchema, ctx); } zodSchema = checkObjectGuards(zodSchema, { keySchema, minProperties, maxProperties }); } break; } case "array": { // Check if this is a tuple (prefixItems or items as array) const prefixItems = schema.prefixItems; const items = schema.items; if (prefixItems && Array.isArray(prefixItems)) { // Tuple with prefixItems (draft-2020-12) const minItems = typeof schema.minItems === "number" ? schema.minItems : 0; const tupleItems = prefixItems.map((item) => convertSchema(item as JSONSchema.JSONSchema, ctx)); const positionalItems = applyMinItems(tupleItems, minItems); const rest = !Array.isArray(items) ? getTupleRest(items, ctx) : undefined; const tupleSchema = z.tuple(positionalItems as [ZodType, ...ZodType[]]); zodSchema = rest ? tupleSchema.rest(rest) : tupleSchema; // Apply minItems/maxItems constraints to tuples if (typeof schema.minItems === "number") { zodSchema = zodSchema.check(z.minLength(schema.minItems)); } if (typeof schema.maxItems === "number") { zodSchema = zodSchema.check(z.maxLength(schema.maxItems)); } } else if (Array.isArray(items)) { // Tuple with items array (draft-7) const minItems = typeof schema.minItems === "number" ? schema.minItems : 0; const tupleItems = items.map((item) => convertSchema(item as JSONSchema.JSONSchema, ctx)); const positionalItems = applyMinItems(tupleItems, minItems); const rest = getTupleRest(schema.additionalItems, ctx); const tupleSchema = z.tuple(positionalItems as [ZodType, ...ZodType[]]); zodSchema = rest ? tupleSchema.rest(rest) : tupleSchema; // Apply minItems/maxItems constraints to tuples if (typeof schema.minItems === "number") { zodSchema = zodSchema.check(z.minLength(schema.minItems)); } if (typeof schema.maxItems === "number") { zodSchema = zodSchema.check(z.maxLength(schema.maxItems)); } } else if (items !== undefined) { // Regular array const element = convertSchema(items as JSONSchema.JSONSchema, ctx); let arraySchema = z.array(element); // Apply constraints if (typeof schema.minItems === "number") { arraySchema = arraySchema.min(schema.minItems); } if (typeof schema.maxItems === "number") { arraySchema = arraySchema.max(schema.maxItems); } zodSchema = arraySchema; } else { // No items specified - array of any zodSchema = z.array(z.any()); } // minContains/maxContains only constrain anything when `contains` itself is present if (schema.uniqueItems === true || schema.contains !== undefined) { zodSchema = checkArrayGuards(zodSchema, { uniqueItems: schema.uniqueItems === true, containsSchema: schema.contains !== undefined ? convertSchema(schema.contains as JSONSchema.JSONSchema, ctx) : undefined, minContains: typeof schema.minContains === "number" ? schema.minContains : undefined, maxContains: typeof schema.maxContains === "number" ? schema.maxContains : undefined, }); } break; } default: throw new Error(`Unsupported type: ${type}`); } return zodSchema; } function convertSchema(schema: JSONSchema.JSONSchema | boolean, ctx: ConversionContext): ZodType { if (typeof schema === "boolean") { return schema ? z.any() : z.never(); } // Convert base schema first (ignoring composition keywords) let baseSchema = convertBaseSchema(schema, ctx); const hasExplicitType = schema.type || schema.enum !== undefined || schema.const !== undefined; // Process composition keywords LAST (they can appear together) // Handle anyOf - wrap base schema with union if (schema.anyOf && Array.isArray(schema.anyOf)) { const options = schema.anyOf.map((s) => convertSchema(s, ctx)); const anyOfUnion = z.union(options as [ZodType, ZodType, ...ZodType[]]); baseSchema = hasExplicitType ? z.intersection(baseSchema, anyOfUnion) : anyOfUnion; } // Handle oneOf - exclusive union (exactly one must match) if (schema.oneOf && Array.isArray(schema.oneOf)) { const options = schema.oneOf.map((s) => convertSchema(s, ctx)); const oneOfUnion = z.xor(options as [ZodType, ZodType, ...ZodType[]]); baseSchema = hasExplicitType ? z.intersection(baseSchema, oneOfUnion) : oneOfUnion; } // Handle allOf - wrap base schema with intersection if (schema.allOf && Array.isArray(schema.allOf)) { if (schema.allOf.length === 0) { baseSchema = hasExplicitType ? baseSchema : z.any(); } else { let result = hasExplicitType ? baseSchema : convertSchema(schema.allOf[0]!, ctx); const startIdx = hasExplicitType ? 0 : 1; for (let i = startIdx; i < schema.allOf.length; i++) { result = z.intersection(result, convertSchema(schema.allOf[i]!, ctx)); } baseSchema = result; } } // Handle nullable (OpenAPI 3.0) if (schema.nullable === true && ctx.version === "openapi-3.0") { baseSchema = z.nullable(baseSchema); } // Handle readOnly if (schema.readOnly === true) { baseSchema = z.readonly(baseSchema); } // Apply `default` so it wraps the fully-composed schema. This ensures `parse(undefined) -> default` works regardless of which branch of `convertBaseSchema` produced the inner schema (enum/const/not/typed/etc.). if (schema.default !== undefined) { baseSchema = baseSchema.default(schema.default); } // Collect non-description annotation metadata into the user-supplied registry. Description is handled separately below via `.describe()` to preserve the contract that `schema.description` reads from globalRegistry. const extraMeta: Record = {}; const coreMetadataKeys = ["$id", "id", "$comment", "$anchor", "$vocabulary", "$dynamicRef", "$dynamicAnchor"]; for (const key of coreMetadataKeys) { if (key in schema) { extraMeta[key] = schema[key]; } } const contentMetadataKeys = ["contentEncoding", "contentMediaType", "contentSchema"]; for (const key of contentMetadataKeys) { if (key in schema) { extraMeta[key] = schema[key]; } } // `propertyNames` and `contains` are enforced by a guard, which `toJSONSchema` cannot infer, so the original keyword is carried as metadata to keep the round-trip lossless. Only where it was actually applied: on any other type it is inert, and on a `$ref` the metadata would land on the target every reference shares. A carried subschema is copied verbatim while the root `$defs` stay behind, so one holding a `$ref` is dropped rather than emitted as a dangling reference. if (schema.type === "object" && schema.$ref === undefined) { if (schema.propertyNames !== undefined && !containsRef(schema.propertyNames)) { extraMeta.propertyNames = schema.propertyNames; } for (const key of ["minProperties", "maxProperties"] as const) { if (schema[key] !== undefined) extraMeta[key] = schema[key]; } } if (schema.type === "array" && schema.$ref === undefined) { if (schema.contains !== undefined && !containsRef(schema.contains)) { extraMeta.contains = schema.contains; } for (const key of ["uniqueItems", "minContains", "maxContains"] as const) { if (schema[key] !== undefined) extraMeta[key] = schema[key]; } } for (const key of Object.keys(schema)) { if (!RECOGNIZED_KEYS.has(key)) { assignProp(extraMeta, key, schema[key]); } } if (Object.keys(extraMeta).length > 0) { ctx.registry.add(baseSchema, extraMeta); } // Apply description last. `.describe()` clones the schema and sets `_zod.parent` on the clone, so registry lookups on the returned reference still resolve `extraMeta` via parent inheritance. if (schema.description) { baseSchema = baseSchema.describe(schema.description); } return baseSchema; } /** * Converts a JSON Schema to a Zod schema. This function should be considered semi-experimental. It's behavior is liable to change. */ export function fromJSONSchema(schema: JSONSchema.JSONSchema | boolean, params?: FromJSONSchemaParams): ZodType { // Handle boolean schemas if (typeof schema === "boolean") { return schema ? z.any() : z.never(); } // Normalize input via a JSON round-trip. This guarantees the converter walks a plain, finite, JSON-valid object graph: cyclic inputs fail here, getter/Proxy-based properties are materialized into static values, and class instances collapse to plain objects. let normalized: JSONSchema.JSONSchema; try { normalized = JSON.parse(JSON.stringify(schema)); } catch { throw new Error("fromJSONSchema input is not valid JSON (possibly cyclic); use $defs/$ref for recursive schemas"); } const version = detectVersion(normalized, params?.defaultTarget); const defs = (normalized.$defs || normalized.definitions || {}) as Record; const ctx: ConversionContext = { version, defs, refs: new Map(), processing: new Set(), rootSchema: normalized, registry: params?.registry ?? globalRegistry, }; return convertSchema(normalized, ctx); }