From 24f82904353e41c50d267a84e51a7c7087245e4f Mon Sep 17 00:00:00 2001 From: Ian Macalinao Date: Mon, 20 Jul 2026 10:39:41 +0800 Subject: [PATCH 1/2] feat(zod): respect an explicit `type` in schema metadata MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Zod copies every `.meta()` field on top of its structural conversion but never reconciles them, so a schema such as `z.union([...]).meta({ type: 'string', format, pattern })` converts to `{ anyOf: [...], type: 'string', format, pattern }` — the intended string schema polluted with a redundant, contradictory `anyOf`. When metadata pins a concrete JSON Schema `type`, treat it as authoritative and drop the leftover structural composition keywords (`anyOf`/`oneOf`/ `allOf`). Scalar and object shapes are unaffected: zod already overwrites a structural `type` on merge, and object schemas keep their `properties`. This lets libraries (e.g. temporal-zod) express a validator's JSON Schema directly via `.meta()` instead of shipping an external converter interceptor. --- packages/zod/src/converter.test.ts | 50 ++++++++++++++++++++++++++++++ packages/zod/src/converter.ts | 20 +++++++++++- 2 files changed, 69 insertions(+), 1 deletion(-) diff --git a/packages/zod/src/converter.test.ts b/packages/zod/src/converter.test.ts index a292678b6..539471f42 100644 --- a/packages/zod/src/converter.test.ts +++ b/packages/zod/src/converter.test.ts @@ -227,4 +227,54 @@ 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, + ]) + }) + }) }) diff --git a/packages/zod/src/converter.ts b/packages/zod/src/converter.ts index e06f11a80..93fd2ccb4 100644 --- a/packages/zod/src/converter.ts +++ b/packages/zod/src/converter.ts @@ -30,6 +30,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 +70,23 @@ export class ZodToJsonSchemaConverter implements JsonSchemaConverter { ctx.jsonSchema['x-native-type'] = JsonSchemaXNativeType.Map } + // Respect an explicit 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 concrete `type`, treat it as authoritative and drop the leftover + // structural composition keywords. Scalar/object shapes are untouched: + // zod already overwrites a structural `type` on merge, and object schemas + // keep their `properties`. + const meta = registry.get(ctx.zodSchema) as { type?: unknown } | undefined + if (meta !== undefined && typeof meta.type === 'string') { + delete ctx.jsonSchema.anyOf + delete ctx.jsonSchema.oneOf + delete ctx.jsonSchema.allOf + } + this.options.override?.(ctx) }, }) @@ -77,7 +96,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 From 283c32a98667101c06344529b2c8c3c8510340b2 Mon Sep 17 00:00:00 2001 From: Ian Macalinao Date: Mon, 20 Jul 2026 10:45:24 +0800 Subject: [PATCH 2/2] refactor(zod): scope metadata `type` handling to scalar types Only strip the leftover `anyOf`/`oneOf`/`allOf` when the pinned `type` is a primitive scalar (string/number/integer/boolean/null). For `object`/`array` the composition branches carry real structure the metadata does not restate, so dropping them would silently discard information rather than remove contradictory noise. --- packages/zod/src/converter.test.ts | 20 ++++++++++++++++++++ packages/zod/src/converter.ts | 24 ++++++++++++++++++------ 2 files changed, 38 insertions(+), 6 deletions(-) diff --git a/packages/zod/src/converter.test.ts b/packages/zod/src/converter.test.ts index 539471f42..16cd8b903 100644 --- a/packages/zod/src/converter.test.ts +++ b/packages/zod/src/converter.test.ts @@ -276,5 +276,25 @@ describe('zodToJsonSchemaConverter', () => { 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 93fd2ccb4..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 = {}) { } @@ -70,18 +77,23 @@ export class ZodToJsonSchemaConverter implements JsonSchemaConverter { ctx.jsonSchema['x-native-type'] = JsonSchemaXNativeType.Map } - // Respect an explicit JSON Schema `type` declared through `.meta()`. + // 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 concrete `type`, treat it as authoritative and drop the leftover - // structural composition keywords. Scalar/object shapes are untouched: - // zod already overwrites a structural `type` on merge, and object schemas - // keep their `properties`. + // 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 && typeof meta.type === 'string') { + if (meta !== undefined && SCALAR_JSON_SCHEMA_TYPES.has(meta.type as string)) { delete ctx.jsonSchema.anyOf delete ctx.jsonSchema.oneOf delete ctx.jsonSchema.allOf