From 2e89be20bee19fba01f464cded74c6f39ca5fd67 Mon Sep 17 00:00:00 2001 From: Sigmabrogz Date: Mon, 16 Mar 2026 08:45:36 +0000 Subject: [PATCH 1/3] fix(zod): generate OpenAPI 3.0 compatible enum schema --- packages/zod/src/converter.test.ts | 4 ++-- packages/zod/src/converter.ts | 4 ++-- packages/zod/src/zod4/converter.native.test.ts | 4 ++-- packages/zod/src/zod4/converter.structure.test.ts | 6 +++--- packages/zod/src/zod4/converter.ts | 2 +- 5 files changed, 10 insertions(+), 10 deletions(-) diff --git a/packages/zod/src/converter.test.ts b/packages/zod/src/converter.test.ts index aa93e52a3..1d14233e1 100644 --- a/packages/zod/src/converter.test.ts +++ b/packages/zod/src/converter.test.ts @@ -193,11 +193,11 @@ const nativeCases: SchemaTestCase[] = [ }, { schema: z.enum(['a', 'b']), - input: [true, { enum: ['a', 'b'] }], + input: [true, { type: 'string', enum: ['a', 'b'] }], }, { schema: z.nativeEnum(ExampleEnum), - input: [true, { enum: ['a', 'b'] }], + input: [true, { type: 'string', enum: ['a', 'b'] }], }, ] diff --git a/packages/zod/src/converter.ts b/packages/zod/src/converter.ts index 7f0f5221d..9e863e65e 100644 --- a/packages/zod/src/converter.ts +++ b/packages/zod/src/converter.ts @@ -343,13 +343,13 @@ export class ZodToJsonSchemaConverter implements ConditionalSchemaConverter { case ZodFirstPartyTypeKind.ZodEnum: { const schema_ = schema as ZodEnum<[string, ...string[]]> - return [true, { enum: schema_._def.values }] + return [true, { type: 'string', enum: schema_._def.values }] } case ZodFirstPartyTypeKind.ZodNativeEnum: { const schema_ = schema as ZodNativeEnum - return [true, { enum: Object.values(schema_._def.values) }] + return [true, { type: typeof Object.values(schema_._def.values)[0] === 'number' ? 'number' : 'string', enum: Object.values(schema_._def.values) }] } case ZodFirstPartyTypeKind.ZodArray: { diff --git a/packages/zod/src/zod4/converter.native.test.ts b/packages/zod/src/zod4/converter.native.test.ts index 1133692fd..2f062959b 100644 --- a/packages/zod/src/zod4/converter.native.test.ts +++ b/packages/zod/src/zod4/converter.native.test.ts @@ -90,12 +90,12 @@ testSchemaConverter([ { name: 'enum(["a", "b"])', schema: z.enum(['a', 'b']), - input: [true, { enum: ['a', 'b'] }], + input: [true, { type: 'string', enum: ['a', 'b'] }], }, { name: 'enum(ExampleEnum)', schema: z.enum(ExampleEnum), - input: [true, { enum: ['a', 'b'] }], + input: [true, { type: 'string', enum: ['a', 'b'] }], }, { name: 'file()', diff --git a/packages/zod/src/zod4/converter.structure.test.ts b/packages/zod/src/zod4/converter.structure.test.ts index 334d55610..fe1b6e2df 100644 --- a/packages/zod/src/zod4/converter.structure.test.ts +++ b/packages/zod/src/zod4/converter.structure.test.ts @@ -43,17 +43,17 @@ testSchemaConverter([ { name: 'tuple([z.enum(["a", "b"])])', schema: z.tuple([z.enum(['a', 'b'])]), - input: [true, { type: 'array', prefixItems: [{ enum: ['a', 'b'] }] }], + input: [true, { type: 'array', prefixItems: [{ type: 'string', enum: ['a', 'b'] }] }], }, { name: 'tuple([z.enum(["a", "b"])], z.string())', schema: z.tuple([z.enum(['a', 'b'])], z.string()), - input: [true, { type: 'array', prefixItems: [{ enum: ['a', 'b'] }], items: { type: 'string' } }], + input: [true, { type: 'array', prefixItems: [{ type: 'string', enum: ['a', 'b'] }], items: { type: 'string' } }], }, { name: 'zm.tuple([zm.enum(["a", "b"])], zm.string()).check(zm.minLength(4), zm.maxLength(10))', schema: zm.tuple([zm.enum(['a', 'b'])], zm.string()).check(zm.minLength(4), zm.maxLength(10)), - input: [true, { type: 'array', prefixItems: [{ enum: ['a', 'b'] }], items: { type: 'string' }, minItems: 4, maxItems: 10 }], + input: [true, { type: 'array', prefixItems: [{ type: 'string', enum: ['a', 'b'] }], items: { type: 'string' }, minItems: 4, maxItems: 10 }], }, { name: 'set(z.string())', diff --git a/packages/zod/src/zod4/converter.ts b/packages/zod/src/zod4/converter.ts index 9347b6242..dab96698a 100644 --- a/packages/zod/src/zod4/converter.ts +++ b/packages/zod/src/zod4/converter.ts @@ -430,7 +430,7 @@ export class ZodToJsonSchemaConverter implements ConditionalSchemaConverter { case 'enum': { const enum_ = schema as $ZodEnum - return [true, { enum: Object.values(enum_._zod.def.entries) }] + return [true, { type: 'string', enum: Object.values(enum_._zod.def.entries) }] } case 'literal': { From 0c0ce103606d07b42f5e914d79775cd16ab9f71a Mon Sep 17 00:00:00 2001 From: Sigmabrogz Date: Mon, 16 Mar 2026 12:31:55 +0000 Subject: [PATCH 2/3] fix(zod): detect mixed and numeric enums correctly for nativeEnum --- packages/zod/src/coercer.test.ts | 35 +++++++++++++++++++ packages/zod/src/coercer.ts | 14 ++++++-- packages/zod/src/converter.test.ts | 18 ++++++++++ packages/zod/src/converter.ts | 19 ++++++++-- packages/zod/src/zod4/coercer.native.test.ts | 10 ++++++ .../zod/src/zod4/converter.native.test.ts | 20 +++++++++++ packages/zod/src/zod4/converter.ts | 20 ++++++++++- 7 files changed, 131 insertions(+), 5 deletions(-) diff --git a/packages/zod/src/coercer.test.ts b/packages/zod/src/coercer.test.ts index 13298ddd6..3f0ef245f 100644 --- a/packages/zod/src/coercer.test.ts +++ b/packages/zod/src/coercer.test.ts @@ -15,6 +15,16 @@ enum TestEnum { STRING = 'string', } +enum NumericEnum { + A = 1, + B = 2, +} + +enum MixedEnum { + A = 1, + B = 'b', +} + const nativeCases: TestCase[] = [ { schema: z.number(), @@ -141,6 +151,31 @@ const nativeCases: TestCase[] = [ input: '123n', expected: '123n', }, + { + schema: z.nativeEnum(TestEnum), + input: 'NUMBER', + expected: 'NUMBER', + }, + { + schema: z.nativeEnum(NumericEnum), + input: '1', + expected: 1, + }, + { + schema: z.nativeEnum(NumericEnum), + input: 'A', + expected: 'A', // invalid, should just return value since coercion failed, OR it shouldn't coerce 'A' to 1! wait, does Zod accept 'A'? NO. + }, + { + schema: z.nativeEnum(MixedEnum), + input: '1', + expected: 1, + }, + { + schema: z.nativeEnum(MixedEnum), + input: 'b', + expected: 'b', + }, ] const combinationCases: TestCase[] = [ diff --git a/packages/zod/src/coercer.ts b/packages/zod/src/coercer.ts index 7088c19fc..4390632b9 100644 --- a/packages/zod/src/coercer.ts +++ b/packages/zod/src/coercer.ts @@ -28,6 +28,15 @@ import { guard, isObject } from '@orpc/shared' import { ZodFirstPartyTypeKind } from 'zod/v3' import { getCustomZodDef } from './schemas/base' +function getValidEnumValues(obj: any): any[] { + const validKeys = Object.keys(obj).filter((k: any) => typeof obj[obj[k]] !== 'number') + const filtered: any = {} + for (const k of validKeys) { + filtered[k] = obj[k] + } + return Object.values(filtered) +} + export class ZodSmartCoercionPlugin implements StandardHandlerPlugin { init(options: StandardHandlerOptions): void { options.clientInterceptors ??= [] @@ -126,13 +135,14 @@ function zodCoerceInternal( case ZodFirstPartyTypeKind.ZodNativeEnum: { const schema_ = schema as ZodNativeEnum + const values = getValidEnumValues(schema_._def.values) - if (Object.values(schema_._def.values).includes(value as any)) { + if (values.includes(value as any)) { return value } if (typeof value === 'string') { - for (const expectedValue of Object.values(schema_._def.values)) { + for (const expectedValue of values) { if (expectedValue.toString() === value) { return expectedValue } diff --git a/packages/zod/src/converter.test.ts b/packages/zod/src/converter.test.ts index 1d14233e1..00dec5034 100644 --- a/packages/zod/src/converter.test.ts +++ b/packages/zod/src/converter.test.ts @@ -154,6 +154,16 @@ enum ExampleEnum { B = 'b', } +enum NumericEnum { + A = 1, + B = 2, +} + +enum MixedEnum { + A = 1, + B = 'b', +} + const nativeCases: SchemaTestCase[] = [ { schema: z.boolean(), @@ -199,6 +209,14 @@ const nativeCases: SchemaTestCase[] = [ schema: z.nativeEnum(ExampleEnum), input: [true, { type: 'string', enum: ['a', 'b'] }], }, + { + schema: z.nativeEnum(NumericEnum), + input: [true, { type: 'number', enum: [1, 2] }], + }, + { + schema: z.nativeEnum(MixedEnum), + input: [true, { enum: [1, 'b'] }], + }, ] const combinationCases: SchemaTestCase[] = [ diff --git a/packages/zod/src/converter.ts b/packages/zod/src/converter.ts index 9e863e65e..59c141aea 100644 --- a/packages/zod/src/converter.ts +++ b/packages/zod/src/converter.ts @@ -74,6 +74,15 @@ export interface ZodToJsonSchemaOptions { unsupportedJsonSchema?: Exclude } +function getValidEnumValues(obj: any): any[] { + const validKeys = Object.keys(obj).filter((k: any) => typeof obj[obj[k]] !== 'number') + const filtered: any = {} + for (const k of validKeys) { + filtered[k] = obj[k] + } + return Object.values(filtered) +} + export class ZodToJsonSchemaConverter implements ConditionalSchemaConverter { private readonly maxLazyDepth: Exclude private readonly maxStructureDepth: Exclude @@ -348,8 +357,14 @@ export class ZodToJsonSchemaConverter implements ConditionalSchemaConverter { case ZodFirstPartyTypeKind.ZodNativeEnum: { const schema_ = schema as ZodNativeEnum - - return [true, { type: typeof Object.values(schema_._def.values)[0] === 'number' ? 'number' : 'string', enum: Object.values(schema_._def.values) }] + const values = getValidEnumValues(schema_._def.values) + const hasString = values.some(v => typeof v === 'string') + const hasNumber = values.some(v => typeof v === 'number') + const type = hasString && hasNumber ? undefined : hasNumber ? 'number' : 'string' + const json: any = { enum: values } + if (type) + json.type = type + return [true, json] } case ZodFirstPartyTypeKind.ZodArray: { diff --git a/packages/zod/src/zod4/coercer.native.test.ts b/packages/zod/src/zod4/coercer.native.test.ts index 2b5297046..85c729827 100644 --- a/packages/zod/src/zod4/coercer.native.test.ts +++ b/packages/zod/src/zod4/coercer.native.test.ts @@ -6,6 +6,16 @@ enum TestEnum { STRING = 'string', } +enum NumericEnum { + A = 1, + B = 2, +} + +enum MixedEnum { + A = 1, + B = 'b', +} + testSchemaSmartCoercion([ { name: 'number - 12345', diff --git a/packages/zod/src/zod4/converter.native.test.ts b/packages/zod/src/zod4/converter.native.test.ts index 2f062959b..8534e428e 100644 --- a/packages/zod/src/zod4/converter.native.test.ts +++ b/packages/zod/src/zod4/converter.native.test.ts @@ -6,6 +6,16 @@ enum ExampleEnum { B = 'b', } +enum NumericEnum { + A = 1, + B = 2, +} + +enum MixedEnum { + A = 1, + B = 'b', +} + testSchemaConverter([ { name: 'boolean', @@ -97,6 +107,16 @@ testSchemaConverter([ schema: z.enum(ExampleEnum), input: [true, { type: 'string', enum: ['a', 'b'] }], }, + { + name: 'enum(NumericEnum)', + schema: z.enum(NumericEnum), + input: [true, { type: 'number', enum: [1, 2] }], + }, + { + name: 'enum(MixedEnum)', + schema: z.enum(MixedEnum), + input: [true, { enum: [1, 'b'] }], + }, { name: 'file()', schema: z.file(), diff --git a/packages/zod/src/zod4/converter.ts b/packages/zod/src/zod4/converter.ts index dab96698a..91ee27344 100644 --- a/packages/zod/src/zod4/converter.ts +++ b/packages/zod/src/zod4/converter.ts @@ -85,6 +85,17 @@ export interface ZodToJsonSchemaConverterOptions { >[] } +function getValidEnumValues(obj: any): any[] { + if (Array.isArray(obj)) + return obj + const validKeys = Object.keys(obj).filter((k: any) => typeof obj[obj[k]] !== 'number') + const filtered: any = {} + for (const k of validKeys) { + filtered[k] = obj[k] + } + return Object.values(filtered) +} + export class ZodToJsonSchemaConverter implements ConditionalSchemaConverter { private readonly maxLazyDepth: Exclude private readonly maxStructureDepth: Exclude @@ -430,7 +441,14 @@ export class ZodToJsonSchemaConverter implements ConditionalSchemaConverter { case 'enum': { const enum_ = schema as $ZodEnum - return [true, { type: 'string', enum: Object.values(enum_._zod.def.entries) }] + const values = getValidEnumValues(enum_._zod.def.entries) + const hasString = values.some(v => typeof v === 'string') + const hasNumber = values.some(v => typeof v === 'number') + const type = hasString && hasNumber ? undefined : hasNumber ? 'number' : 'string' + const json: any = { enum: values } + if (type) + json.type = type + return [true, json] } case 'literal': { From 62ce3315da710008518dc0dad06f12aa02f414a6 Mon Sep 17 00:00:00 2001 From: Sigmabrogz Date: Mon, 16 Mar 2026 20:08:56 +0000 Subject: [PATCH 3/3] fix(zod): extract getValidEnumValues util and add missing coercer tests - Extracted getValidEnumValues to shared util module - Added coercion tests for NumericEnum and MixedEnum to complete coverage Fixes nitpicks from CodeRabbit review. --- packages/zod/src/coercer.ts | 9 +-------- packages/zod/src/converter.ts | 11 ++--------- packages/zod/src/util.ts | 10 ++++++++++ packages/zod/src/zod4/coercer.native.test.ts | 18 ++++++++++++++++++ 4 files changed, 31 insertions(+), 17 deletions(-) create mode 100644 packages/zod/src/util.ts diff --git a/packages/zod/src/coercer.ts b/packages/zod/src/coercer.ts index 4390632b9..ed2dd2f93 100644 --- a/packages/zod/src/coercer.ts +++ b/packages/zod/src/coercer.ts @@ -28,14 +28,7 @@ import { guard, isObject } from '@orpc/shared' import { ZodFirstPartyTypeKind } from 'zod/v3' import { getCustomZodDef } from './schemas/base' -function getValidEnumValues(obj: any): any[] { - const validKeys = Object.keys(obj).filter((k: any) => typeof obj[obj[k]] !== 'number') - const filtered: any = {} - for (const k of validKeys) { - filtered[k] = obj[k] - } - return Object.values(filtered) -} +import { getValidEnumValues } from './util' export class ZodSmartCoercionPlugin implements StandardHandlerPlugin { init(options: StandardHandlerOptions): void { diff --git a/packages/zod/src/converter.ts b/packages/zod/src/converter.ts index 59c141aea..042b237e1 100644 --- a/packages/zod/src/converter.ts +++ b/packages/zod/src/converter.ts @@ -40,6 +40,8 @@ import { ZodFirstPartyTypeKind } from 'zod/v3' import { getCustomJsonSchema } from './custom-json-schema' import { getCustomZodDef } from './schemas/base' +import { getValidEnumValues } from './util' + export interface ZodToJsonSchemaOptions { /** * Max depth of lazy type @@ -74,15 +76,6 @@ export interface ZodToJsonSchemaOptions { unsupportedJsonSchema?: Exclude } -function getValidEnumValues(obj: any): any[] { - const validKeys = Object.keys(obj).filter((k: any) => typeof obj[obj[k]] !== 'number') - const filtered: any = {} - for (const k of validKeys) { - filtered[k] = obj[k] - } - return Object.values(filtered) -} - export class ZodToJsonSchemaConverter implements ConditionalSchemaConverter { private readonly maxLazyDepth: Exclude private readonly maxStructureDepth: Exclude diff --git a/packages/zod/src/util.ts b/packages/zod/src/util.ts new file mode 100644 index 000000000..8693933fe --- /dev/null +++ b/packages/zod/src/util.ts @@ -0,0 +1,10 @@ +export function getValidEnumValues(obj: any): any[] { + const validKeys = Object.keys(obj).filter( + (k: any) => typeof obj[obj[k]] !== 'number', + ) + const filtered: any = {} + for (const k of validKeys) { + filtered[k] = obj[k] + } + return Object.values(filtered) +} diff --git a/packages/zod/src/zod4/coercer.native.test.ts b/packages/zod/src/zod4/coercer.native.test.ts index 85c729827..04d3aae06 100644 --- a/packages/zod/src/zod4/coercer.native.test.ts +++ b/packages/zod/src/zod4/coercer.native.test.ts @@ -172,6 +172,24 @@ testSchemaSmartCoercion([ schema: z.enum(TestEnum), input: '123n', }, + { + name: 'nativeEnum(NumericEnum) - 1', + schema: z.enum(NumericEnum), + input: '1', + expected: 1, + }, + { + name: 'nativeEnum(MixedEnum) - 1', + schema: z.enum(MixedEnum), + input: '1', + expected: 1, + }, + { + name: 'nativeEnum(MixedEnum) - b', + schema: z.enum(MixedEnum), + input: 'b', + expected: 'b', + }, { name: 'enum - 123', schema: z.enum(['123', '456']),