Skip to content

Commit 3816dda

Browse files
authored
fix(zod): improve json schema compatibility by add type field and fix native enum (#1478)
1 parent a45fc95 commit 3816dda

File tree

5 files changed

+98
-15
lines changed

5 files changed

+98
-15
lines changed

packages/zod/src/converter.test.ts

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -149,11 +149,21 @@ const numberCases: SchemaTestCase[] = [
149149
},
150150
]
151151

152-
enum ExampleEnum {
152+
enum ExampleEnumString {
153153
A = 'a',
154154
B = 'b',
155155
}
156156

157+
enum ExampleEnumNumber {
158+
A = 1,
159+
B = 2,
160+
}
161+
162+
enum ExampleEnumMixed {
163+
A = 1,
164+
B = 'a',
165+
}
166+
157167
const nativeCases: SchemaTestCase[] = [
158168
{
159169
schema: z.boolean(),
@@ -193,11 +203,19 @@ const nativeCases: SchemaTestCase[] = [
193203
},
194204
{
195205
schema: z.enum(['a', 'b']),
196-
input: [true, { enum: ['a', 'b'] }],
206+
input: [true, { type: 'string', enum: ['a', 'b'] }],
207+
},
208+
{
209+
schema: z.nativeEnum(ExampleEnumString),
210+
input: [true, { type: 'string', enum: ['a', 'b'] }],
211+
},
212+
{
213+
schema: z.nativeEnum(ExampleEnumNumber),
214+
input: [true, { type: 'number', enum: [1, 2] }],
197215
},
198216
{
199-
schema: z.nativeEnum(ExampleEnum),
200-
input: [true, { enum: ['a', 'b'] }],
217+
schema: z.nativeEnum(ExampleEnumMixed),
218+
input: [true, { enum: [1, 'a'] }],
201219
},
202220
]
203221

packages/zod/src/converter.ts

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -342,14 +342,25 @@ export class ZodToJsonSchemaConverter implements ConditionalSchemaConverter {
342342

343343
case ZodFirstPartyTypeKind.ZodEnum: {
344344
const schema_ = schema as ZodEnum<[string, ...string[]]>
345+
const values = schema_._def.values
346+
const json: any = { enum: values, type: 'string' }
345347

346-
return [true, { enum: schema_._def.values }]
348+
return [true, json]
347349
}
348350

349351
case ZodFirstPartyTypeKind.ZodNativeEnum: {
350352
const schema_ = schema as ZodNativeEnum<EnumLike>
353+
const values = getEnumValues(schema_._def.values)
354+
const json: any = { enum: values }
355+
356+
if (values.every(v => typeof v === 'string')) {
357+
json.type = 'string'
358+
}
359+
else if (values.every(v => Number.isFinite(v))) {
360+
json.type = 'number'
361+
}
351362

352-
return [true, { enum: Object.values(schema_._def.values) }]
363+
return [true, json]
353364
}
354365

355366
case ZodFirstPartyTypeKind.ZodArray: {
@@ -689,3 +700,14 @@ export class ZodToJsonSchemaConverter implements ConditionalSchemaConverter {
689700
: { anyOf: [schema, { type: 'null' }] }
690701
}
691702
}
703+
704+
/**
705+
* https://github.com/colinhacks/zod/blob/main/packages/zod/src/v4/core/util.ts#L206C8-L212C2
706+
*/
707+
function getEnumValues(entries: EnumLike) {
708+
const numericValues = Object.values(entries).filter(v => typeof v === 'number')
709+
const values = Object.entries(entries)
710+
.filter(([k, _]) => !numericValues.includes(+k))
711+
.map(([_, v]) => v)
712+
return values
713+
}

packages/zod/src/zod4/converter.native.test.ts

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,21 @@
11
import * as z from 'zod/v4'
22
import { testSchemaConverter } from '../../tests/shared'
33

4-
enum ExampleEnum {
4+
enum ExampleEnumString {
55
A = 'a',
66
B = 'b',
77
}
88

9+
enum ExampleEnumNumber {
10+
A = 1,
11+
B = 2,
12+
}
13+
14+
enum ExampleEnumMixed {
15+
A = 1,
16+
B = 'a',
17+
}
18+
919
testSchemaConverter([
1020
{
1121
name: 'boolean',
@@ -90,12 +100,22 @@ testSchemaConverter([
90100
{
91101
name: 'enum(["a", "b"])',
92102
schema: z.enum(['a', 'b']),
93-
input: [true, { enum: ['a', 'b'] }],
103+
input: [true, { type: 'string', enum: ['a', 'b'] }],
104+
},
105+
{
106+
name: 'enum(ExampleEnumString)',
107+
schema: z.enum(ExampleEnumString),
108+
input: [true, { type: 'string', enum: ['a', 'b'] }],
109+
},
110+
{
111+
name: 'enum(ExampleEnumNumber)',
112+
schema: z.enum(ExampleEnumNumber),
113+
input: [true, { type: 'number', enum: [1, 2] }],
94114
},
95115
{
96-
name: 'enum(ExampleEnum)',
97-
schema: z.enum(ExampleEnum),
98-
input: [true, { enum: ['a', 'b'] }],
116+
name: 'enum(ExampleEnumMixed)',
117+
schema: z.enum(ExampleEnumMixed),
118+
input: [true, { enum: [1, 'a'] }],
99119
},
100120
{
101121
name: 'file()',

packages/zod/src/zod4/converter.structure.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -43,17 +43,17 @@ testSchemaConverter([
4343
{
4444
name: 'tuple([z.enum(["a", "b"])])',
4545
schema: z.tuple([z.enum(['a', 'b'])]),
46-
input: [true, { type: 'array', prefixItems: [{ enum: ['a', 'b'] }] }],
46+
input: [true, { type: 'array', prefixItems: [{ type: 'string', enum: ['a', 'b'] }] }],
4747
},
4848
{
4949
name: 'tuple([z.enum(["a", "b"])], z.string())',
5050
schema: z.tuple([z.enum(['a', 'b'])], z.string()),
51-
input: [true, { type: 'array', prefixItems: [{ enum: ['a', 'b'] }], items: { type: 'string' } }],
51+
input: [true, { type: 'array', prefixItems: [{ type: 'string', enum: ['a', 'b'] }], items: { type: 'string' } }],
5252
},
5353
{
5454
name: 'zm.tuple([zm.enum(["a", "b"])], zm.string()).check(zm.minLength(4), zm.maxLength(10))',
5555
schema: zm.tuple([zm.enum(['a', 'b'])], zm.string()).check(zm.minLength(4), zm.maxLength(10)),
56-
input: [true, { type: 'array', prefixItems: [{ enum: ['a', 'b'] }], items: { type: 'string' }, minItems: 4, maxItems: 10 }],
56+
input: [true, { type: 'array', prefixItems: [{ type: 'string', enum: ['a', 'b'] }], items: { type: 'string' }, minItems: 4, maxItems: 10 }],
5757
},
5858
{
5959
name: 'set(z.string())',

packages/zod/src/zod4/converter.ts

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -430,7 +430,17 @@ export class ZodToJsonSchemaConverter implements ConditionalSchemaConverter {
430430

431431
case 'enum': {
432432
const enum_ = schema as $ZodEnum
433-
return [true, { enum: Object.values(enum_._zod.def.entries) }]
433+
const values = getEnumValues(enum_._zod.def.entries)
434+
const json: any = { enum: values }
435+
436+
if (values.every(v => typeof v === 'string')) {
437+
json.type = 'string'
438+
}
439+
else if (values.every(v => Number.isFinite(v))) {
440+
json.type = 'number'
441+
}
442+
443+
return [true, json]
434444
}
435445

436446
case 'literal': {
@@ -637,3 +647,16 @@ export class ZodToJsonSchemaConverter implements ConditionalSchemaConverter {
637647
: undefined
638648
}
639649
}
650+
651+
type EnumValue = string | number // | bigint | boolean | symbol;
652+
type EnumLike = Readonly<Record<string, EnumValue>>
653+
/**
654+
* https://github.com/colinhacks/zod/blob/main/packages/zod/src/v4/core/util.ts#L206C8-L212C2
655+
*/
656+
function getEnumValues(entries: EnumLike): EnumValue[] {
657+
const numericValues = Object.values(entries).filter(v => typeof v === 'number')
658+
const values = Object.entries(entries)
659+
.filter(([k, _]) => !numericValues.includes(+k))
660+
.map(([_, v]) => v)
661+
return values
662+
}

0 commit comments

Comments
 (0)