Skip to content
35 changes: 35 additions & 0 deletions packages/zod/src/coercer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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[] = [
Expand Down
14 changes: 12 additions & 2 deletions packages/zod/src/coercer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<TContext extends Context> implements StandardHandlerPlugin<TContext> {
init(options: StandardHandlerOptions<TContext>): void {
options.clientInterceptors ??= []
Expand Down Expand Up @@ -126,13 +135,14 @@ function zodCoerceInternal(

case ZodFirstPartyTypeKind.ZodNativeEnum: {
const schema_ = schema as ZodNativeEnum<EnumLike>
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
}
Expand Down
22 changes: 20 additions & 2 deletions packages/zod/src/converter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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'] }],
},
]

Expand Down
21 changes: 18 additions & 3 deletions packages/zod/src/converter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,15 @@ export interface ZodToJsonSchemaOptions {
unsupportedJsonSchema?: Exclude<JSONSchema, boolean>
}

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<ZodToJsonSchemaOptions['maxLazyDepth'], undefined>
private readonly maxStructureDepth: Exclude<ZodToJsonSchemaOptions['maxStructureDepth'], undefined>
Expand Down Expand Up @@ -343,13 +352,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<EnumLike>

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: {
Expand Down
10 changes: 10 additions & 0 deletions packages/zod/src/zod4/coercer.native.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,16 @@ enum TestEnum {
STRING = 'string',
}

enum NumericEnum {
A = 1,
B = 2,
}

enum MixedEnum {
A = 1,
B = 'b',
}

testSchemaSmartCoercion([
{
name: 'number - 12345',
Expand Down
24 changes: 22 additions & 2 deletions packages/zod/src/zod4/converter.native.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,16 @@ enum ExampleEnum {
B = 'b',
}

enum NumericEnum {
A = 1,
B = 2,
}

enum MixedEnum {
A = 1,
B = 'b',
}

testSchemaConverter([
{
name: 'boolean',
Expand Down Expand Up @@ -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()',
Expand Down
6 changes: 3 additions & 3 deletions packages/zod/src/zod4/converter.structure.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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())',
Expand Down
20 changes: 19 additions & 1 deletion packages/zod/src/zod4/converter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

export class ZodToJsonSchemaConverter implements ConditionalSchemaConverter {
private readonly maxLazyDepth: Exclude<ZodToJsonSchemaConverterOptions['maxLazyDepth'], undefined>
private readonly maxStructureDepth: Exclude<ZodToJsonSchemaConverterOptions['maxStructureDepth'], undefined>
Expand Down Expand Up @@ -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': {
Expand Down