Skip to content

Commit dbb05ef

Browse files
authored
Add JSON Schema draft-04 output (#4811)
* fix: include draft-4 schema type * Fix
1 parent e25303e commit dbb05ef

File tree

4 files changed

+73
-16
lines changed

4 files changed

+73
-16
lines changed

‎packages/docs/content/json-schema.mdx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -214,8 +214,9 @@ Below is a quick reference for each supported parameter. Each one is explained i
214214
interface ToJSONSchemaParams {
215215
/** The JSON Schema version to target.
216216
* - `"draft-2020-12"` — Default. JSON Schema Draft 2020-12
217-
* - `"draft-7"` — JSON Schema Draft 7 */
218-
target?: "draft-7" | "draft-2020-12";
217+
* - `"draft-7"` — JSON Schema Draft 7
218+
* - `"draft-4"` — JSON Schema Draft 4 */
219+
target?: "draft-4" | "draft-7" | "draft-2020-12";
219220

220221
/** A registry used to look up metadata for each schema.
221222
* Any schema with an `id` property will be extracted as a $def. */
@@ -250,6 +251,7 @@ To set the target JSON Schema version, use the `target` parameter. By default, Z
250251
```ts
251252
z.toJSONSchema(schema, { target: "draft-7" });
252253
z.toJSONSchema(schema, { target: "draft-2020-12" });
254+
z.toJSONSchema(schema, { target: "draft-4" });
253255
```
254256

255257
### `metadata`

‎packages/zod/src/v4/classic/tests/to-json-schema.test.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -539,6 +539,19 @@ describe("toJSONSchema", () => {
539539
`);
540540
});
541541

542+
test("number constraints draft-4", () => {
543+
expect(z.toJSONSchema(z.number().gt(5).lt(10), { target: "draft-4" })).toMatchInlineSnapshot(`
544+
{
545+
"$schema": "http://json-schema.org/draft-04/schema#",
546+
"exclusiveMaximum": true,
547+
"exclusiveMinimum": true,
548+
"maximum": 10,
549+
"minimum": 5,
550+
"type": "number",
551+
}
552+
`);
553+
});
554+
542555
test("arrays", () => {
543556
expect(z.toJSONSchema(z.array(z.string()))).toMatchInlineSnapshot(`
544557
{
@@ -745,6 +758,19 @@ describe("toJSONSchema", () => {
745758
`);
746759
});
747760

761+
test("literal draft-4", () => {
762+
const a = z.literal("hello");
763+
expect(z.toJSONSchema(a, { target: "draft-4" })).toMatchInlineSnapshot(`
764+
{
765+
"$schema": "http://json-schema.org/draft-04/schema#",
766+
"enum": [
767+
"hello",
768+
],
769+
"type": "string",
770+
}
771+
`);
772+
});
773+
748774
// pipe
749775
test("pipe", () => {
750776
const schema = z

‎packages/zod/src/v4/core/json-schema.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,10 @@ export type Schema =
4545
export type _JSONSchema = boolean | JSONSchema;
4646
export type JSONSchema = {
4747
[k: string]: unknown;
48-
$schema?: "https://json-schema.org/draft/2020-12/schema" | "http://json-schema.org/draft-07/schema#";
48+
$schema?:
49+
| "https://json-schema.org/draft/2020-12/schema"
50+
| "http://json-schema.org/draft-07/schema#"
51+
| "http://json-schema.org/draft-04/schema#";
4952
$id?: string;
5053
$anchor?: string;
5154
$ref?: string;
@@ -75,9 +78,9 @@ export type JSONSchema = {
7578
not?: _JSONSchema;
7679
multipleOf?: number;
7780
maximum?: number;
78-
exclusiveMaximum?: number;
81+
exclusiveMaximum?: number | boolean;
7982
minimum?: number;
80-
exclusiveMinimum?: number;
83+
exclusiveMinimum?: number | boolean;
8184
maxLength?: number;
8285
minLength?: number;
8386
pattern?: string;

‎packages/zod/src/v4/core/to-json-schema.ts

Lines changed: 37 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,9 @@ interface JSONSchemaGeneratorParams {
1010
metadata?: $ZodRegistry<Record<string, any>>;
1111
/** The JSON Schema version to target.
1212
* - `"draft-2020-12"` — Default. JSON Schema Draft 2020-12
13-
* - `"draft-7"` — JSON Schema Draft 7 */
14-
target?: "draft-7" | "draft-2020-12";
13+
* - `"draft-7"` — JSON Schema Draft 7
14+
* - `"draft-4"` — JSON Schema Draft 4 */
15+
target?: "draft-4" | "draft-7" | "draft-2020-12";
1516
/** How to handle unrepresentable types.
1617
* - `"throw"` — Default. Unrepresentable types throw an error
1718
* - `"any"` — Unrepresentable types become `{}` */
@@ -71,7 +72,7 @@ interface Seen {
7172

7273
export class JSONSchemaGenerator {
7374
metadataRegistry: $ZodRegistry<Record<string, any>>;
74-
target: "draft-7" | "draft-2020-12";
75+
target: "draft-4" | "draft-7" | "draft-2020-12";
7576
unrepresentable: "throw" | "any";
7677
override: (ctx: {
7778
zodSchema: schemas.$ZodTypes;
@@ -163,7 +164,7 @@ export class JSONSchemaGenerator {
163164
else if (regexes.length > 1) {
164165
result.schema.allOf = [
165166
...regexes.map((regex) => ({
166-
...(this.target === "draft-7" ? ({ type: "string" } as const) : {}),
167+
...(this.target === "draft-7" || this.target === "draft-4" ? ({ type: "string" } as const) : {}),
167168
pattern: regex.source,
168169
})),
169170
];
@@ -178,19 +179,33 @@ export class JSONSchemaGenerator {
178179
if (typeof format === "string" && format.includes("int")) json.type = "integer";
179180
else json.type = "number";
180181

181-
if (typeof exclusiveMinimum === "number") json.exclusiveMinimum = exclusiveMinimum;
182+
if (typeof exclusiveMinimum === "number") {
183+
if (this.target === "draft-4") {
184+
json.minimum = exclusiveMinimum;
185+
json.exclusiveMinimum = true;
186+
} else {
187+
json.exclusiveMinimum = exclusiveMinimum;
188+
}
189+
}
182190
if (typeof minimum === "number") {
183191
json.minimum = minimum;
184-
if (typeof exclusiveMinimum === "number") {
192+
if (typeof exclusiveMinimum === "number" && this.target !== "draft-4") {
185193
if (exclusiveMinimum >= minimum) delete json.minimum;
186194
else delete json.exclusiveMinimum;
187195
}
188196
}
189197

190-
if (typeof exclusiveMaximum === "number") json.exclusiveMaximum = exclusiveMaximum;
198+
if (typeof exclusiveMaximum === "number") {
199+
if (this.target === "draft-4") {
200+
json.maximum = exclusiveMaximum;
201+
json.exclusiveMaximum = true;
202+
} else {
203+
json.exclusiveMaximum = exclusiveMaximum;
204+
}
205+
}
191206
if (typeof maximum === "number") {
192207
json.maximum = maximum;
193-
if (typeof exclusiveMaximum === "number") {
208+
if (typeof exclusiveMaximum === "number" && this.target !== "draft-4") {
194209
if (exclusiveMaximum <= maximum) delete json.maximum;
195210
else delete json.exclusiveMaximum;
196211
}
@@ -379,7 +394,12 @@ export class JSONSchemaGenerator {
379394
case "record": {
380395
const json: JSONSchema.ObjectSchema = _json as any;
381396
json.type = "object";
382-
json.propertyNames = this.process(def.keyType, { ...params, path: [...params.path, "propertyNames"] });
397+
if (this.target !== "draft-4") {
398+
json.propertyNames = this.process(def.keyType, {
399+
...params,
400+
path: [...params.path, "propertyNames"],
401+
});
402+
}
383403
json.additionalProperties = this.process(def.valueType, {
384404
...params,
385405
path: [...params.path, "additionalProperties"],
@@ -432,7 +452,11 @@ export class JSONSchemaGenerator {
432452
} else if (vals.length === 1) {
433453
const val = vals[0]!;
434454
json.type = val === null ? ("null" as const) : (typeof val as any);
435-
json.const = val;
455+
if (this.target === "draft-4") {
456+
json.enum = [val];
457+
} else {
458+
json.const = val;
459+
}
436460
} else {
437461
if (vals.every((v) => typeof v === "number")) json.type = "number";
438462
if (vals.every((v) => typeof v === "string")) json.type = "string";
@@ -749,7 +773,7 @@ export class JSONSchemaGenerator {
749773

750774
// merge referenced schema into current
751775
const refSchema = this.seen.get(ref)!.schema;
752-
if (refSchema.$ref && params.target === "draft-7") {
776+
if (refSchema.$ref && (params.target === "draft-7" || params.target === "draft-4")) {
753777
schema.allOf = schema.allOf ?? [];
754778
schema.allOf.push(refSchema);
755779
} else {
@@ -776,6 +800,8 @@ export class JSONSchemaGenerator {
776800
result.$schema = "https://json-schema.org/draft/2020-12/schema";
777801
} else if (this.target === "draft-7") {
778802
result.$schema = "http://json-schema.org/draft-07/schema#";
803+
} else if (this.target === "draft-4") {
804+
result.$schema = "http://json-schema.org/draft-04/schema#";
779805
} else {
780806
// @ts-ignore
781807
console.warn(`Invalid target: ${this.target}`);

0 commit comments

Comments
 (0)