From 2e89be20bee19fba01f464cded74c6f39ca5fd67 Mon Sep 17 00:00:00 2001 From: Sigmabrogz Date: Mon, 16 Mar 2026 08:45:36 +0000 Subject: [PATCH 1/9] 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/9] 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/9] 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']), From 4b01d6f34c9a18d6f35d9d620d3d2d9b4762b9ea Mon Sep 17 00:00:00 2001 From: Sigmabrogz Date: Tue, 17 Mar 2026 11:23:16 +0000 Subject: [PATCH 4/9] fix(zod): add array check to getValidEnumValues --- packages/zod/src/util.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/zod/src/util.ts b/packages/zod/src/util.ts index 8693933fe..fbf428e2d 100644 --- a/packages/zod/src/util.ts +++ b/packages/zod/src/util.ts @@ -1,4 +1,7 @@ export function getValidEnumValues(obj: any): any[] { + if (Array.isArray(obj)) + return obj + const validKeys = Object.keys(obj).filter( (k: any) => typeof obj[obj[k]] !== 'number', ) From 57c0219b54b175e343714879a21b99a017a0cc8d Mon Sep 17 00:00:00 2001 From: Sigmabrogz Date: Tue, 17 Mar 2026 12:11:16 +0000 Subject: [PATCH 5/9] fix(zod): handle boolean and mixed enums correctly in type inference --- packages/zod/src/converter.ts | 7 ++++--- packages/zod/src/util.ts | 7 +++++++ packages/zod/src/zod4/converter.ts | 7 ++++--- 3 files changed, 15 insertions(+), 6 deletions(-) diff --git a/packages/zod/src/converter.ts b/packages/zod/src/converter.ts index 042b237e1..c15b7883e 100644 --- a/packages/zod/src/converter.ts +++ b/packages/zod/src/converter.ts @@ -351,9 +351,10 @@ export class ZodToJsonSchemaConverter implements ConditionalSchemaConverter { case ZodFirstPartyTypeKind.ZodNativeEnum: { const schema_ = schema as ZodNativeEnum 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 allString = values.every(v => typeof v === 'string') + const allNumber = values.every(v => typeof v === 'number') + const allBoolean = values.every(v => typeof v === 'boolean') + const type = allString ? 'string' : allNumber ? 'number' : allBoolean ? 'boolean' : undefined const json: any = { enum: values } if (type) json.type = type diff --git a/packages/zod/src/util.ts b/packages/zod/src/util.ts index fbf428e2d..a34b87b25 100644 --- a/packages/zod/src/util.ts +++ b/packages/zod/src/util.ts @@ -1,3 +1,10 @@ +/** + * Extracts valid enum values from an object, particularly TypeScript native enums. + * Early-returns arrays for array-backed enums, and filters out reverse numeric mappings. + * + * @param obj - The enum object or array to extract values from. + * @returns An array of valid enum values. + */ export function getValidEnumValues(obj: any): any[] { if (Array.isArray(obj)) return obj diff --git a/packages/zod/src/zod4/converter.ts b/packages/zod/src/zod4/converter.ts index 91ee27344..f7b0d7509 100644 --- a/packages/zod/src/zod4/converter.ts +++ b/packages/zod/src/zod4/converter.ts @@ -442,9 +442,10 @@ export class ZodToJsonSchemaConverter implements ConditionalSchemaConverter { case 'enum': { const enum_ = schema as $ZodEnum 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 allString = values.every(v => typeof v === 'string') + const allNumber = values.every(v => typeof v === 'number') + const allBoolean = values.every(v => typeof v === 'boolean') + const type = allString ? 'string' : allNumber ? 'number' : allBoolean ? 'boolean' : undefined const json: any = { enum: values } if (type) json.type = type From 23dc5bd5ad672de0ee366668c1bdeacde866f472 Mon Sep 17 00:00:00 2001 From: Sigmabrogz Date: Tue, 17 Mar 2026 16:11:30 +0000 Subject: [PATCH 6/9] fix(zod): address review feedback, simplify type inference without filtering --- packages/zod/src/coercer.test.ts | 35 ------------------- packages/zod/src/coercer.ts | 7 ++-- packages/zod/src/converter.test.ts | 22 ++---------- packages/zod/src/converter.ts | 17 +++++---- packages/zod/src/util.ts | 20 ----------- packages/zod/src/zod4/coercer.native.test.ts | 28 --------------- .../zod/src/zod4/converter.native.test.ts | 24 ++----------- .../zod/src/zod4/converter.structure.test.ts | 6 ++-- packages/zod/src/zod4/converter.ts | 18 ++-------- 9 files changed, 19 insertions(+), 158 deletions(-) delete mode 100644 packages/zod/src/util.ts diff --git a/packages/zod/src/coercer.test.ts b/packages/zod/src/coercer.test.ts index 3f0ef245f..13298ddd6 100644 --- a/packages/zod/src/coercer.test.ts +++ b/packages/zod/src/coercer.test.ts @@ -15,16 +15,6 @@ enum TestEnum { STRING = 'string', } -enum NumericEnum { - A = 1, - B = 2, -} - -enum MixedEnum { - A = 1, - B = 'b', -} - const nativeCases: TestCase[] = [ { schema: z.number(), @@ -151,31 +141,6 @@ 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 ed2dd2f93..7088c19fc 100644 --- a/packages/zod/src/coercer.ts +++ b/packages/zod/src/coercer.ts @@ -28,8 +28,6 @@ import { guard, isObject } from '@orpc/shared' import { ZodFirstPartyTypeKind } from 'zod/v3' import { getCustomZodDef } from './schemas/base' -import { getValidEnumValues } from './util' - export class ZodSmartCoercionPlugin implements StandardHandlerPlugin { init(options: StandardHandlerOptions): void { options.clientInterceptors ??= [] @@ -128,14 +126,13 @@ function zodCoerceInternal( case ZodFirstPartyTypeKind.ZodNativeEnum: { const schema_ = schema as ZodNativeEnum - const values = getValidEnumValues(schema_._def.values) - if (values.includes(value as any)) { + if (Object.values(schema_._def.values).includes(value as any)) { return value } if (typeof value === 'string') { - for (const expectedValue of values) { + for (const expectedValue of Object.values(schema_._def.values)) { if (expectedValue.toString() === value) { return expectedValue } diff --git a/packages/zod/src/converter.test.ts b/packages/zod/src/converter.test.ts index 00dec5034..aa93e52a3 100644 --- a/packages/zod/src/converter.test.ts +++ b/packages/zod/src/converter.test.ts @@ -154,16 +154,6 @@ enum ExampleEnum { B = 'b', } -enum NumericEnum { - A = 1, - B = 2, -} - -enum MixedEnum { - A = 1, - B = 'b', -} - const nativeCases: SchemaTestCase[] = [ { schema: z.boolean(), @@ -203,19 +193,11 @@ const nativeCases: SchemaTestCase[] = [ }, { schema: z.enum(['a', 'b']), - input: [true, { type: 'string', enum: ['a', 'b'] }], + input: [true, { enum: ['a', 'b'] }], }, { 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'] }], + input: [true, { enum: ['a', 'b'] }], }, ] diff --git a/packages/zod/src/converter.ts b/packages/zod/src/converter.ts index c15b7883e..76b51528c 100644 --- a/packages/zod/src/converter.ts +++ b/packages/zod/src/converter.ts @@ -40,8 +40,6 @@ 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 @@ -344,17 +342,18 @@ export class ZodToJsonSchemaConverter implements ConditionalSchemaConverter { case ZodFirstPartyTypeKind.ZodEnum: { const schema_ = schema as ZodEnum<[string, ...string[]]> - - return [true, { type: 'string', enum: schema_._def.values }] + const values = schema_._def.values + const type = values.every(v => typeof v === 'string') ? 'string' : values.every(v => typeof v === 'number' && Number.isFinite(v)) ? 'number' : undefined + const json: any = { enum: values } + if (type) + json.type = type + return [true, json] } case ZodFirstPartyTypeKind.ZodNativeEnum: { const schema_ = schema as ZodNativeEnum - const values = getValidEnumValues(schema_._def.values) - const allString = values.every(v => typeof v === 'string') - const allNumber = values.every(v => typeof v === 'number') - const allBoolean = values.every(v => typeof v === 'boolean') - const type = allString ? 'string' : allNumber ? 'number' : allBoolean ? 'boolean' : undefined + const values = Object.values(schema_._def.values) + const type = values.every(v => typeof v === 'string') ? 'string' : values.every(v => typeof v === 'number' && Number.isFinite(v)) ? 'number' : undefined const json: any = { enum: values } if (type) json.type = type diff --git a/packages/zod/src/util.ts b/packages/zod/src/util.ts deleted file mode 100644 index a34b87b25..000000000 --- a/packages/zod/src/util.ts +++ /dev/null @@ -1,20 +0,0 @@ -/** - * Extracts valid enum values from an object, particularly TypeScript native enums. - * Early-returns arrays for array-backed enums, and filters out reverse numeric mappings. - * - * @param obj - The enum object or array to extract values from. - * @returns An array of valid enum values. - */ -export 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) -} diff --git a/packages/zod/src/zod4/coercer.native.test.ts b/packages/zod/src/zod4/coercer.native.test.ts index 04d3aae06..2b5297046 100644 --- a/packages/zod/src/zod4/coercer.native.test.ts +++ b/packages/zod/src/zod4/coercer.native.test.ts @@ -6,16 +6,6 @@ enum TestEnum { STRING = 'string', } -enum NumericEnum { - A = 1, - B = 2, -} - -enum MixedEnum { - A = 1, - B = 'b', -} - testSchemaSmartCoercion([ { name: 'number - 12345', @@ -172,24 +162,6 @@ 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']), diff --git a/packages/zod/src/zod4/converter.native.test.ts b/packages/zod/src/zod4/converter.native.test.ts index 8534e428e..1133692fd 100644 --- a/packages/zod/src/zod4/converter.native.test.ts +++ b/packages/zod/src/zod4/converter.native.test.ts @@ -6,16 +6,6 @@ enum ExampleEnum { B = 'b', } -enum NumericEnum { - A = 1, - B = 2, -} - -enum MixedEnum { - A = 1, - B = 'b', -} - testSchemaConverter([ { name: 'boolean', @@ -100,22 +90,12 @@ testSchemaConverter([ { name: 'enum(["a", "b"])', schema: z.enum(['a', 'b']), - input: [true, { type: 'string', enum: ['a', 'b'] }], + input: [true, { enum: ['a', 'b'] }], }, { name: 'enum(ExampleEnum)', 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'] }], + input: [true, { 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 fe1b6e2df..334d55610 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: [{ type: 'string', enum: ['a', 'b'] }] }], + input: [true, { type: 'array', prefixItems: [{ 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: [{ type: 'string', enum: ['a', 'b'] }], items: { type: 'string' } }], + input: [true, { type: 'array', prefixItems: [{ 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: [{ type: 'string', enum: ['a', 'b'] }], items: { type: 'string' }, minItems: 4, maxItems: 10 }], + input: [true, { type: 'array', prefixItems: [{ 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 f7b0d7509..518a34921 100644 --- a/packages/zod/src/zod4/converter.ts +++ b/packages/zod/src/zod4/converter.ts @@ -85,17 +85,6 @@ 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 @@ -441,11 +430,8 @@ export class ZodToJsonSchemaConverter implements ConditionalSchemaConverter { case 'enum': { const enum_ = schema as $ZodEnum - const values = getValidEnumValues(enum_._zod.def.entries) - const allString = values.every(v => typeof v === 'string') - const allNumber = values.every(v => typeof v === 'number') - const allBoolean = values.every(v => typeof v === 'boolean') - const type = allString ? 'string' : allNumber ? 'number' : allBoolean ? 'boolean' : undefined + const values = Object.values(enum_._zod.def.entries) + const type = values.every(v => typeof v === 'string') ? 'string' : values.every(v => typeof v === 'number' && Number.isFinite(v)) ? 'number' : undefined const json: any = { enum: values } if (type) json.type = type From aff7eb801694318e01880416485d34cba825186b Mon Sep 17 00:00:00 2001 From: Sigmabrogz Date: Tue, 17 Mar 2026 22:06:53 +0000 Subject: [PATCH 7/9] fix(zod): restore string types in tests --- packages/zod/src/converter.test.ts | 4 ++-- packages/zod/src/zod4/converter.native.test.ts | 4 ++-- packages/zod/src/zod4/converter.structure.test.ts | 6 +++--- 3 files changed, 7 insertions(+), 7 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/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())', From 45f315ebc7ed970044a7ee235bc5414a9f5f770b Mon Sep 17 00:00:00 2001 From: Dinh Le Date: Wed, 18 Mar 2026 10:32:21 +0700 Subject: [PATCH 8/9] improve --- packages/zod/src/converter.ts | 11 ++++++++--- packages/zod/src/zod4/converter.ts | 11 ++++++++--- 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/packages/zod/src/converter.ts b/packages/zod/src/converter.ts index 76b51528c..6633fbe10 100644 --- a/packages/zod/src/converter.ts +++ b/packages/zod/src/converter.ts @@ -343,10 +343,15 @@ export class ZodToJsonSchemaConverter implements ConditionalSchemaConverter { case ZodFirstPartyTypeKind.ZodEnum: { const schema_ = schema as ZodEnum<[string, ...string[]]> const values = schema_._def.values - const type = values.every(v => typeof v === 'string') ? 'string' : values.every(v => typeof v === 'number' && Number.isFinite(v)) ? 'number' : undefined const json: any = { enum: values } - if (type) - json.type = type + + if (values.every(v => typeof v === 'string')) { + json.type = 'string' + } + else if (values.every(v => Number.isFinite(v))) { + json.type = 'number' + } + return [true, json] } diff --git a/packages/zod/src/zod4/converter.ts b/packages/zod/src/zod4/converter.ts index 518a34921..759fd6fbe 100644 --- a/packages/zod/src/zod4/converter.ts +++ b/packages/zod/src/zod4/converter.ts @@ -431,10 +431,15 @@ export class ZodToJsonSchemaConverter implements ConditionalSchemaConverter { case 'enum': { const enum_ = schema as $ZodEnum const values = Object.values(enum_._zod.def.entries) - const type = values.every(v => typeof v === 'string') ? 'string' : values.every(v => typeof v === 'number' && Number.isFinite(v)) ? 'number' : undefined const json: any = { enum: values } - if (type) - json.type = type + + if (values.every(v => typeof v === 'string')) { + json.type = 'string' + } + else if (values.every(v => Number.isFinite(v))) { + json.type = 'number' + } + return [true, json] } From ede1856565dfdb0e0e2bd395365ce151e5483e2c Mon Sep 17 00:00:00 2001 From: Dinh Le Date: Wed, 18 Mar 2026 10:56:49 +0700 Subject: [PATCH 9/9] improve & fix native enum --- packages/zod/src/converter.test.ts | 22 ++++++++++++-- packages/zod/src/converter.ts | 29 ++++++++++++------- .../zod/src/zod4/converter.native.test.ts | 26 +++++++++++++++-- packages/zod/src/zod4/converter.ts | 15 +++++++++- 4 files changed, 76 insertions(+), 16 deletions(-) diff --git a/packages/zod/src/converter.test.ts b/packages/zod/src/converter.test.ts index 1d14233e1..950064907 100644 --- a/packages/zod/src/converter.test.ts +++ b/packages/zod/src/converter.test.ts @@ -149,11 +149,21 @@ const numberCases: SchemaTestCase[] = [ }, ] -enum ExampleEnum { +enum ExampleEnumString { A = 'a', B = 'b', } +enum ExampleEnumNumber { + A = 1, + B = 2, +} + +enum ExampleEnumMixed { + A = 1, + B = 'a', +} + const nativeCases: SchemaTestCase[] = [ { schema: z.boolean(), @@ -196,9 +206,17 @@ const nativeCases: SchemaTestCase[] = [ input: [true, { type: 'string', enum: ['a', 'b'] }], }, { - schema: z.nativeEnum(ExampleEnum), + schema: z.nativeEnum(ExampleEnumString), input: [true, { type: 'string', enum: ['a', 'b'] }], }, + { + schema: z.nativeEnum(ExampleEnumNumber), + input: [true, { type: 'number', enum: [1, 2] }], + }, + { + schema: z.nativeEnum(ExampleEnumMixed), + input: [true, { enum: [1, 'a'] }], + }, ] const combinationCases: SchemaTestCase[] = [ diff --git a/packages/zod/src/converter.ts b/packages/zod/src/converter.ts index 6633fbe10..34435ac6c 100644 --- a/packages/zod/src/converter.ts +++ b/packages/zod/src/converter.ts @@ -343,6 +343,14 @@ export class ZodToJsonSchemaConverter implements ConditionalSchemaConverter { case ZodFirstPartyTypeKind.ZodEnum: { const schema_ = schema as ZodEnum<[string, ...string[]]> const values = schema_._def.values + const json: any = { enum: values, type: 'string' } + + return [true, json] + } + + case ZodFirstPartyTypeKind.ZodNativeEnum: { + const schema_ = schema as ZodNativeEnum + const values = getEnumValues(schema_._def.values) const json: any = { enum: values } if (values.every(v => typeof v === 'string')) { @@ -355,16 +363,6 @@ export class ZodToJsonSchemaConverter implements ConditionalSchemaConverter { return [true, json] } - case ZodFirstPartyTypeKind.ZodNativeEnum: { - const schema_ = schema as ZodNativeEnum - const values = Object.values(schema_._def.values) - const type = values.every(v => typeof v === 'string') ? 'string' : values.every(v => typeof v === 'number' && Number.isFinite(v)) ? 'number' : undefined - const json: any = { enum: values } - if (type) - json.type = type - return [true, json] - } - case ZodFirstPartyTypeKind.ZodArray: { const schema_ = schema as ZodArray const def = schema_._def @@ -702,3 +700,14 @@ export class ZodToJsonSchemaConverter implements ConditionalSchemaConverter { : { anyOf: [schema, { type: 'null' }] } } } + +/** + * https://github.com/colinhacks/zod/blob/main/packages/zod/src/v4/core/util.ts#L206C8-L212C2 + */ +function getEnumValues(entries: EnumLike) { + const numericValues = Object.values(entries).filter(v => typeof v === 'number') + const values = Object.entries(entries) + .filter(([k, _]) => !numericValues.includes(+k)) + .map(([_, v]) => v) + return values +} diff --git a/packages/zod/src/zod4/converter.native.test.ts b/packages/zod/src/zod4/converter.native.test.ts index 2f062959b..eb0553618 100644 --- a/packages/zod/src/zod4/converter.native.test.ts +++ b/packages/zod/src/zod4/converter.native.test.ts @@ -1,11 +1,21 @@ import * as z from 'zod/v4' import { testSchemaConverter } from '../../tests/shared' -enum ExampleEnum { +enum ExampleEnumString { A = 'a', B = 'b', } +enum ExampleEnumNumber { + A = 1, + B = 2, +} + +enum ExampleEnumMixed { + A = 1, + B = 'a', +} + testSchemaConverter([ { name: 'boolean', @@ -93,10 +103,20 @@ testSchemaConverter([ input: [true, { type: 'string', enum: ['a', 'b'] }], }, { - name: 'enum(ExampleEnum)', - schema: z.enum(ExampleEnum), + name: 'enum(ExampleEnumString)', + schema: z.enum(ExampleEnumString), input: [true, { type: 'string', enum: ['a', 'b'] }], }, + { + name: 'enum(ExampleEnumNumber)', + schema: z.enum(ExampleEnumNumber), + input: [true, { type: 'number', enum: [1, 2] }], + }, + { + name: 'enum(ExampleEnumMixed)', + schema: z.enum(ExampleEnumMixed), + input: [true, { enum: [1, 'a'] }], + }, { name: 'file()', schema: z.file(), diff --git a/packages/zod/src/zod4/converter.ts b/packages/zod/src/zod4/converter.ts index 759fd6fbe..b9d1db897 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 - const values = Object.values(enum_._zod.def.entries) + const values = getEnumValues(enum_._zod.def.entries) const json: any = { enum: values } if (values.every(v => typeof v === 'string')) { @@ -647,3 +647,16 @@ export class ZodToJsonSchemaConverter implements ConditionalSchemaConverter { : undefined } } + +type EnumValue = string | number // | bigint | boolean | symbol; +type EnumLike = Readonly> +/** + * https://github.com/colinhacks/zod/blob/main/packages/zod/src/v4/core/util.ts#L206C8-L212C2 + */ +function getEnumValues(entries: EnumLike): EnumValue[] { + const numericValues = Object.values(entries).filter(v => typeof v === 'number') + const values = Object.entries(entries) + .filter(([k, _]) => !numericValues.includes(+k)) + .map(([_, v]) => v) + return values +}