Skip to content
26 changes: 22 additions & 4 deletions packages/zod/src/converter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
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(ExampleEnumString),
input: [true, { type: 'string', enum: ['a', 'b'] }],
},
{
schema: z.nativeEnum(ExampleEnumNumber),
input: [true, { type: 'number', enum: [1, 2] }],
},
{
schema: z.nativeEnum(ExampleEnum),
input: [true, { enum: ['a', 'b'] }],
schema: z.nativeEnum(ExampleEnumMixed),
input: [true, { enum: [1, 'a'] }],
},
]

Expand Down
26 changes: 24 additions & 2 deletions packages/zod/src/converter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -342,14 +342,25 @@ 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, { enum: schema_._def.values }]
return [true, json]
}

case ZodFirstPartyTypeKind.ZodNativeEnum: {
const schema_ = schema as ZodNativeEnum<EnumLike>
const values = getEnumValues(schema_._def.values)
const json: any = { enum: values }

if (values.every(v => typeof v === 'string')) {
json.type = 'string'
}
else if (values.every(v => Number.isFinite(v))) {
json.type = 'number'
}

return [true, { enum: Object.values(schema_._def.values) }]
return [true, json]
}

case ZodFirstPartyTypeKind.ZodArray: {
Expand Down Expand Up @@ -689,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
}
30 changes: 25 additions & 5 deletions packages/zod/src/zod4/converter.native.test.ts
Original file line number Diff line number Diff line change
@@ -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',
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(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(ExampleEnum)',
schema: z.enum(ExampleEnum),
input: [true, { enum: ['a', 'b'] }],
name: 'enum(ExampleEnumMixed)',
schema: z.enum(ExampleEnumMixed),
input: [true, { enum: [1, 'a'] }],
},
{
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
25 changes: 24 additions & 1 deletion packages/zod/src/zod4/converter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -430,7 +430,17 @@ export class ZodToJsonSchemaConverter implements ConditionalSchemaConverter {

case 'enum': {
const enum_ = schema as $ZodEnum
return [true, { enum: 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')) {
json.type = 'string'
}
else if (values.every(v => Number.isFinite(v))) {
json.type = 'number'
}

return [true, json]
}

case 'literal': {
Expand Down Expand Up @@ -637,3 +647,16 @@ export class ZodToJsonSchemaConverter implements ConditionalSchemaConverter {
: undefined
}
}

type EnumValue = string | number // | bigint | boolean | symbol;
type EnumLike = Readonly<Record<string, EnumValue>>
/**
* 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
}
Loading