import type { $ZodDate, $ZodUndefined, $ZodUnion, JSONSchema, JSONSchemaGenerator, } from 'zod/v4/core' import { $ZodRegistry, $ZodType, toJSONSchema } from 'zod/v4/core' import type { OASVersion } from './json-to-oas' const getSchemaId = (id: string, io: 'input' | 'output') => { return io === 'input' ? `${id}Input` : id } const getReferenceUri = (id: string, io: 'input' | 'output') => { return `#/components/schemas/${getSchemaId(id, io)}` } function isZodDate(entity: unknown): entity is $ZodDate { return entity instanceof $ZodType && entity._zod.def.type === 'date' } function isZodUnion(entity: unknown): entity is $ZodUnion { return entity instanceof $ZodType && entity._zod.def.type === 'union' } function isZodUndefined(entity: unknown): entity is $ZodUndefined { return entity instanceof $ZodType && entity._zod.def.type === 'undefined' } const getOverride = ( ctx: { zodSchema: $ZodType jsonSchema: JSONSchema.BaseSchema }, io: 'input' | 'output', ) => { if (isZodUnion(ctx.zodSchema)) { // Filter unrepresentable types in unions // TODO: Should be fixed upstream and not merged in this plugin. // Remove when passed: https://github.com/colinhacks/zod/pull/5013 ctx.jsonSchema.anyOf = ctx.jsonSchema.anyOf?.filter((schema) => Object.keys(schema).length > 0) } if (isZodDate(ctx.zodSchema)) { // Allow dates to be represented as strings in output schemas if (io === 'output') { ctx.jsonSchema.type = 'string' ctx.jsonSchema.format = 'date-time' } } if (isZodUndefined(ctx.zodSchema)) { // Allow undefined to be represented as null in output schemas if (io === 'output') { ctx.jsonSchema.type = 'null' } } } export interface ZodToJsonConfig { target?: JSONSchemaGenerator['target'] } export const zodSchemaToJson: ( zodSchema: $ZodType, registry: $ZodRegistry<{ id?: string }>, io: 'input' | 'output', oasVersion: OASVersion, config?: ZodToJsonConfig, ) => JSONSchema.BaseSchema = (zodSchema, registry, io, oasVersion, config = {}) => { const defaultTarget = oasVersion === '3.0' ? 'openapi-3.0' : ('draft-2020-12' satisfies JSONSchemaGenerator['target']) const target = config?.target ?? defaultTarget const schemaRegistryEntry = registry.get(zodSchema) /** * Checks whether the provided schema is registered in the given registry. * If it is present and has an `id`, it can be referenced as component. * * @see https://github.com/turkerdev/fastify-type-provider-zod/issues/173 */ if (schemaRegistryEntry?.id) { return { $ref: getReferenceUri(schemaRegistryEntry.id, io), } } /** * Unfortunately, at the time of writing, there is no way to generate a schema with `$ref` * using `toJSONSchema` and a zod schema. * * As a workaround, we create a zod registry containing only the specific schema we want to convert. * * @see https://github.com/colinhacks/zod/issues/4281 */ const tempID = 'GEN' const tempRegistry = new $ZodRegistry<{ id?: string }>() tempRegistry.add(zodSchema, { id: tempID }) const { schemas: { [tempID]: result }, } = toJSONSchema(tempRegistry, { target, metadata: registry, io, unrepresentable: 'any', cycles: 'ref', reused: 'inline', /** * The uri option only allows customizing the base path of the `$ref`, and it automatically appends a path to it. * As a workaround, we set a placeholder that looks something like this: * * | marker | always added by zod | meta.id | * |__SCHEMA__PLACEHOLDER__| #/$defs/ | User | * * @example `__SCHEMA__PLACEHOLDER__#/$defs/User"` * @example `__SCHEMA__PLACEHOLDER__#/$defs/Group"` * * @see jsonSchemaReplaceRef * @see https://github.com/colinhacks/zod/issues/4750 */ uri: () => `__SCHEMA__PLACEHOLDER__`, override: (ctx) => getOverride(ctx, io), }) const jsonSchema = { ...result } delete jsonSchema.id // Helper to normalize whatever Zod put after the placeholder into just the ID const normalizeId = (raw: string) => raw.replace(/^#\/(?:\$defs|definitions|components\/schemas)\//, '') /** * Replace the previous generated placeholders with the final `$ref` value */ const jsonSchemaReplaceRef = JSON.stringify(jsonSchema).replaceAll( /"__SCHEMA__PLACEHOLDER__(?:(?:#\/(?:\$defs|definitions|components\/schemas)\/))?([^"]+)"/g, (_, raw) => { const id = normalizeId(raw) return `"${getReferenceUri(id, io)}"` }, ) return JSON.parse(jsonSchemaReplaceRef) as typeof result } export const zodRegistryToJson: ( registry: $ZodRegistry<{ id?: string }>, io: 'input' | 'output', config?: ZodToJsonConfig, ) => Record = (registry, io, config = {}) => { const { target = 'draft-2020-12' } = config const result = toJSONSchema(registry, { target, io, unrepresentable: 'any', cycles: 'ref', reused: 'inline', uri: (id) => getReferenceUri(id, io), override: (ctx) => getOverride(ctx, io), }).schemas const jsonSchemas: Record = {} for (const id in result) { const jsonSchema = { ...result[id] } delete jsonSchema.id jsonSchemas[getSchemaId(id, io)] = jsonSchema } return jsonSchemas }