diff --git a/packages/zod/src/converter.test.ts b/packages/zod/src/converter.test.ts index a292678b6..16cd8b903 100644 --- a/packages/zod/src/converter.test.ts +++ b/packages/zod/src/converter.test.ts @@ -227,4 +227,74 @@ describe('zodToJsonSchemaConverter', () => { expect(converter.convert(schema, 'input')).toEqual([jsonSchema, false]) }) }) + + describe('respects an explicit `type` in metadata', () => { + it('drops the leftover `anyOf` when a union pins a scalar type', () => { + const schema = z.union([z.string(), z.number()]).meta({ + type: 'string', + format: 'date-time', + pattern: '^x$', + }) + + expect(converter.convert(schema, 'input')).toEqual([ + { + type: 'string', + format: 'date-time', + pattern: '^x$', + }, + false, + ]) + }) + + it('applies to unions nested inside an object', () => { + const schema = z.object({ + createdAt: z.union([z.string(), z.number()]).meta({ type: 'string', format: 'date-time' }), + }) + + expect(converter.convert(schema, 'input')).toEqual([ + { + type: 'object', + properties: { + createdAt: { type: 'string', format: 'date-time' }, + }, + required: ['createdAt'], + }, + false, + ]) + }) + + it('leaves object schemas untouched when metadata restates `type`', () => { + const schema = z.object({ a: z.string() }).meta({ type: 'object', title: 'Foo' }) + + expect(converter.convert(schema, 'input')).toEqual([ + { + type: 'object', + properties: { a: { type: 'string' } }, + required: ['a'], + title: 'Foo', + }, + false, + ]) + }) + + it('keeps the `anyOf` when a non-scalar `type` is pinned on a union', () => { + // `object`/`array` branches carry real structure the metadata does not + // restate, so the composition must survive rather than be discarded. + const schema = z.union([ + z.object({ a: z.string() }), + z.object({ b: z.number() }), + ]).meta({ type: 'object' }) + + expect(converter.convert(schema, 'input')).toEqual([ + { + type: 'object', + anyOf: [ + { type: 'object', properties: { a: { type: 'string' } }, required: ['a'] }, + { type: 'object', properties: { b: { type: 'number' } }, required: ['b'] }, + ], + }, + false, + ]) + }) + }) }) diff --git a/packages/zod/src/converter.ts b/packages/zod/src/converter.ts index e06f11a80..441bebc3f 100644 --- a/packages/zod/src/converter.ts +++ b/packages/zod/src/converter.ts @@ -5,6 +5,13 @@ import { globalRegistry, toJSONSchema } from 'zod/v4/core' export interface ZodToJsonSchemaConverterOptions extends Omit {} +/** + * JSON Schema `type` values that pin a single, non-composite shape. A schema + * whose metadata declares one of these types cannot also be a meaningful + * `anyOf`/`oneOf`/`allOf`, so those leftover composition keywords are dropped. + */ +const SCALAR_JSON_SCHEMA_TYPES = new Set(['string', 'number', 'integer', 'boolean', 'null']) + export class ZodToJsonSchemaConverter implements JsonSchemaConverter { constructor(private readonly options: ZodToJsonSchemaConverterOptions = {}) { } @@ -30,6 +37,8 @@ export class ZodToJsonSchemaConverter implements JsonSchemaConverter { } private convertZod(schema: $ZodType, direction: JsonSchemaConverterDirection): ZodJsonSchema.JSONSchema { + const registry = this.options.metadata ?? globalRegistry + const jsonSchema = toJSONSchema(schema, { unrepresentable: 'any', ...this.options, @@ -68,6 +77,28 @@ export class ZodToJsonSchemaConverter implements JsonSchemaConverter { ctx.jsonSchema['x-native-type'] = JsonSchemaXNativeType.Map } + // Respect an explicit scalar JSON Schema `type` declared through `.meta()`. + // + // Zod copies every metadata field on top of the structural conversion but + // does not reconcile them: `z.union([...]).meta({ type: 'string', ... })` + // becomes `{ anyOf: [...], type: 'string', ... }` — the intended string + // schema polluted with a redundant, contradictory `anyOf`. When metadata + // pins a scalar `type`, treat it as authoritative and drop the leftover + // structural composition keywords. + // + // Restricted to scalars on purpose. Zod already overwrites a structural + // scalar `type` on merge, so the only unreconciled leftover is the + // composition wrapper. `object`/`array` are excluded: there the branches + // carry real structure the metadata does not restate (e.g. a union of + // objects pinned to `type: 'object'`), so stripping them would silently + // discard information rather than remove contradictory noise. + const meta = registry.get(ctx.zodSchema) as { type?: unknown } | undefined + if (meta !== undefined && SCALAR_JSON_SCHEMA_TYPES.has(meta.type as string)) { + delete ctx.jsonSchema.anyOf + delete ctx.jsonSchema.oneOf + delete ctx.jsonSchema.allOf + } + this.options.override?.(ctx) }, }) @@ -77,7 +108,6 @@ export class ZodToJsonSchemaConverter implements JsonSchemaConverter { const { $schema, ...rest } = jsonSchema // workaround until https://github.com/colinhacks/zod/issues/6026 is merged - const registry = this.options.metadata ?? globalRegistry const { id } = registry.get(schema) || {} if (typeof id === 'string' && rest.$ref === undefined) { const { $defs = {}, ...restWithoutDefs } = rest