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..ed2dd2f93 100644 --- a/packages/zod/src/coercer.ts +++ b/packages/zod/src/coercer.ts @@ -28,6 +28,8 @@ 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 ??= [] @@ -126,13 +128,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 aa93e52a3..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(), @@ -193,11 +203,19 @@ 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'] }], + }, + { + schema: z.nativeEnum(NumericEnum), + input: [true, { type: 'number', enum: [1, 2] }], + }, + { + schema: z.nativeEnum(MixedEnum), + input: [true, { enum: [1, 'b'] }], }, ] diff --git a/packages/zod/src/converter.ts b/packages/zod/src/converter.ts index 7f0f5221d..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 @@ -343,13 +345,19 @@ 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) }] + 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/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 2b5297046..04d3aae06 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', @@ -162,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']), diff --git a/packages/zod/src/zod4/converter.native.test.ts b/packages/zod/src/zod4/converter.native.test.ts index 1133692fd..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', @@ -90,12 +100,22 @@ 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: '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()', 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..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, { 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': {