Skip to content

Commit d469cd5

Browse files
committed
Refine JSON Schema helpers per schema
1 parent d3da530 commit d469cd5

File tree

2 files changed

+203
-0
lines changed

2 files changed

+203
-0
lines changed
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import type * as checks from "./checks.js";
2+
import type * as JSONSchema from "./json-schema.js";
3+
import { globalRegistry, type $ZodRegistry } from "./registries.js";
4+
import type * as schemas from "./schemas.js";
5+
6+
export interface JSONSchemaContext {
7+
readonly registry?: $ZodRegistry<Record<string, any>> | undefined;
8+
readonly seen?: Map<schemas.$ZodType, JSONSchema.BaseSchema> | undefined;
9+
}
10+
11+
export interface ResolvedJSONSchemaContext {
12+
registry: $ZodRegistry<Record<string, any>>;
13+
seen: Map<schemas.$ZodType, JSONSchema.BaseSchema>;
14+
}
15+
16+
export const stringFormatMap: Partial<Record<checks.$ZodStringFormats, string | undefined>> = {
17+
guid: "uuid",
18+
url: "uri",
19+
datetime: "date-time",
20+
json_string: "json-string",
21+
regex: undefined,
22+
};
23+
24+
function resolveContext(ctx?: JSONSchemaContext): ResolvedJSONSchemaContext {
25+
return {
26+
registry: ctx?.registry ?? globalRegistry,
27+
seen: ctx?.seen ?? new Map<schemas.$ZodType, JSONSchema.BaseSchema>(),
28+
};
29+
}
30+
31+
function addMetadata(schema: schemas.$ZodType, target: JSONSchema.BaseSchema, context: ResolvedJSONSchemaContext) {
32+
const meta = context.registry.get(schema);
33+
if (meta) Object.assign(target, meta);
34+
}
35+
36+
export function createJSONSchema<T extends JSONSchema.BaseSchema>(
37+
schema: schemas.$ZodType,
38+
ctx: JSONSchemaContext | undefined,
39+
build: (json: T, context: ResolvedJSONSchemaContext) => void
40+
): T {
41+
const context = resolveContext(ctx);
42+
const cached = context.seen.get(schema) as T | undefined;
43+
if (cached) return cached;
44+
45+
const json = {} as T;
46+
context.seen.set(schema, json);
47+
build(json, context);
48+
addMetadata(schema, json, context);
49+
return json;
50+
}

packages/zod/src/v4/core/schemas.ts

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import * as checks from "./checks.js";
33
import * as core from "./core.js";
44
import { Doc } from "./doc.js";
55
import type * as errors from "./errors.js";
6+
import type * as JSONSchema from "./json-schema.js";
7+
import { createJSONSchema, stringFormatMap, type JSONSchemaContext } from "./json-schema-lite.js";
68
import { parse, parseAsync, safeParse, safeParseAsync } from "./parse.js";
79
import * as regexes from "./regexes.js";
810
import type { StandardSchemaV1 } from "./standard-schema.js";
@@ -149,6 +151,9 @@ export interface _$ZodTypeInternals {
149151
/** An optional method used to override `toJSONSchema` logic. */
150152
toJSONSchema?: () => unknown;
151153

154+
/** Minimal JSON Schema representation for this schema. */
155+
getJSONSchema: (ctx?: JSONSchemaContext) => JSONSchema.BaseSchema;
156+
152157
/** @internal The parent of this schema. Only set during certain clone operations. */
153158
parent?: $ZodType | undefined;
154159
}
@@ -181,6 +186,9 @@ export const $ZodType: core.$constructor<$ZodType> = /*@__PURE__*/ core.$constru
181186
inst._zod.def = def; // set _def property
182187
inst._zod.bag = inst._zod.bag || {}; // initialize _bag object
183188
inst._zod.version = version;
189+
inst._zod.getJSONSchema = (_ctx?: JSONSchemaContext) => {
190+
throw new Error(`Unsupported JSON Schema conversion for type "${def.type}"`);
191+
};
184192

185193
const checks = [...(inst._zod.def.checks ?? [])];
186194

@@ -353,6 +361,26 @@ export interface $ZodString<Input = unknown> extends _$ZodType<$ZodStringInterna
353361
export const $ZodString: core.$constructor<$ZodString> = /*@__PURE__*/ core.$constructor("$ZodString", (inst, def) => {
354362
$ZodType.init(inst, def);
355363
inst._zod.pattern = [...(inst?._zod.bag?.patterns ?? [])].pop() ?? regexes.string(inst._zod.bag);
364+
inst._zod.getJSONSchema = (ctx?: JSONSchemaContext) =>
365+
createJSONSchema<JSONSchema.StringSchema>(inst, ctx, (json) => {
366+
json.type = "string";
367+
const bag = inst._zod.bag as $ZodStringInternals<unknown>["bag"];
368+
if (typeof bag.minimum === "number") json.minLength = bag.minimum;
369+
if (typeof bag.maximum === "number") json.maxLength = bag.maximum;
370+
const format = bag.format as checks.$ZodStringFormats | string | undefined;
371+
if (typeof format === "string") {
372+
const mapped = stringFormatMap[format as checks.$ZodStringFormats];
373+
if (mapped !== undefined) json.format = mapped;
374+
else json.format = format;
375+
}
376+
if (bag.patterns?.size) {
377+
const [pattern] = bag.patterns;
378+
if (pattern) json.pattern = pattern.source;
379+
}
380+
if (typeof bag.contentEncoding === "string") {
381+
json.contentEncoding = bag.contentEncoding;
382+
}
383+
});
356384
inst._zod.parse = (payload, _) => {
357385
if (def.coerce)
358386
try {
@@ -1066,6 +1094,20 @@ export interface $ZodNumber<Input = unknown> extends $ZodType {
10661094
export const $ZodNumber: core.$constructor<$ZodNumber> = /*@__PURE__*/ core.$constructor("$ZodNumber", (inst, def) => {
10671095
$ZodType.init(inst, def);
10681096
inst._zod.pattern = inst._zod.bag.pattern ?? regexes.number;
1097+
inst._zod.getJSONSchema = (ctx?: JSONSchemaContext) =>
1098+
createJSONSchema<JSONSchema.NumberSchema | JSONSchema.IntegerSchema>(inst, ctx, (json) => {
1099+
const bag = inst._zod.bag as $ZodNumberInternals<unknown>["bag"] & {
1100+
multipleOf?: number;
1101+
};
1102+
const format = bag?.format;
1103+
json.type = typeof format === "string" && format.includes("int") ? "integer" : "number";
1104+
if (typeof bag?.minimum === "number") json.minimum = bag.minimum;
1105+
if (typeof bag?.maximum === "number") json.maximum = bag.maximum;
1106+
if (typeof bag?.exclusiveMinimum === "number") json.exclusiveMinimum = bag.exclusiveMinimum;
1107+
if (typeof bag?.exclusiveMaximum === "number") json.exclusiveMaximum = bag.exclusiveMaximum;
1108+
if (typeof bag?.multipleOf === "number") json.multipleOf = bag.multipleOf;
1109+
if (typeof format === "string") json.format = format;
1110+
});
10691111

10701112
inst._zod.parse = (payload, _ctx) => {
10711113
if (def.coerce)
@@ -1149,6 +1191,10 @@ export const $ZodBoolean: core.$constructor<$ZodBoolean> = /*@__PURE__*/ core.$c
11491191
(inst, def) => {
11501192
$ZodType.init(inst, def);
11511193
inst._zod.pattern = regexes.boolean;
1194+
inst._zod.getJSONSchema = (ctx?: JSONSchemaContext) =>
1195+
createJSONSchema<JSONSchema.BooleanSchema>(inst, ctx, (json) => {
1196+
json.type = "boolean";
1197+
});
11521198

11531199
inst._zod.parse = (payload, _ctx) => {
11541200
if (def.coerce)
@@ -1202,6 +1248,17 @@ export interface $ZodBigInt<T = unknown> extends $ZodType {
12021248
export const $ZodBigInt: core.$constructor<$ZodBigInt> = /*@__PURE__*/ core.$constructor("$ZodBigInt", (inst, def) => {
12031249
$ZodType.init(inst, def);
12041250
inst._zod.pattern = regexes.bigint;
1251+
inst._zod.getJSONSchema = (ctx?: JSONSchemaContext) =>
1252+
createJSONSchema<JSONSchema.IntegerSchema>(inst, ctx, (json) => {
1253+
json.type = "integer";
1254+
const bag = inst._zod.bag as $ZodBigIntInternals<unknown>["bag"] & {
1255+
minimum?: bigint;
1256+
maximum?: bigint;
1257+
};
1258+
if (typeof bag?.format === "string") json.format = bag.format;
1259+
if (typeof bag?.minimum === "bigint") json.minimum = Number(bag.minimum);
1260+
if (typeof bag?.maximum === "bigint") json.maximum = Number(bag.maximum);
1261+
});
12051262

12061263
inst._zod.parse = (payload, _ctx) => {
12071264
if (def.coerce)
@@ -1354,6 +1411,10 @@ export const $ZodNull: core.$constructor<$ZodNull> = /*@__PURE__*/ core.$constru
13541411
$ZodType.init(inst, def);
13551412
inst._zod.pattern = regexes.null;
13561413
inst._zod.values = new Set([null]);
1414+
inst._zod.getJSONSchema = (ctx?: JSONSchemaContext) =>
1415+
createJSONSchema<JSONSchema.NullSchema>(inst, ctx, (json) => {
1416+
json.type = "null";
1417+
});
13571418

13581419
inst._zod.parse = (payload, _ctx) => {
13591420
const input = payload.value;
@@ -1392,6 +1453,7 @@ export interface $ZodAny extends $ZodType {
13921453

13931454
export const $ZodAny: core.$constructor<$ZodAny> = /*@__PURE__*/ core.$constructor("$ZodAny", (inst, def) => {
13941455
$ZodType.init(inst, def);
1456+
inst._zod.getJSONSchema = (ctx?: JSONSchemaContext) => createJSONSchema<JSONSchema.BaseSchema>(inst, ctx, () => {});
13951457

13961458
inst._zod.parse = (payload) => payload;
13971459
});
@@ -1421,6 +1483,7 @@ export const $ZodUnknown: core.$constructor<$ZodUnknown> = /*@__PURE__*/ core.$c
14211483
"$ZodUnknown",
14221484
(inst, def) => {
14231485
$ZodType.init(inst, def);
1486+
inst._zod.getJSONSchema = (ctx?: JSONSchemaContext) => createJSONSchema<JSONSchema.BaseSchema>(inst, ctx, () => {});
14241487

14251488
inst._zod.parse = (payload) => payload;
14261489
}
@@ -1527,6 +1590,13 @@ export interface $ZodDate<T = unknown> extends $ZodType {
15271590

15281591
export const $ZodDate: core.$constructor<$ZodDate> = /*@__PURE__*/ core.$constructor("$ZodDate", (inst, def) => {
15291592
$ZodType.init(inst, def);
1593+
inst._zod.getJSONSchema = (ctx?: JSONSchemaContext) =>
1594+
createJSONSchema<JSONSchema.StringSchema>(inst, ctx, (json) => {
1595+
json.type = "string";
1596+
json.format = "date-time";
1597+
const bag = inst._zod.bag as $ZodDateInternals<unknown>["bag"];
1598+
if (bag?.format) json.format = bag.format;
1599+
});
15301600

15311601
inst._zod.parse = (payload, _ctx) => {
15321602
if (def.coerce) {
@@ -1584,6 +1654,14 @@ function handleArrayResult(result: ParsePayload<any>, final: ParsePayload<any[]>
15841654

15851655
export const $ZodArray: core.$constructor<$ZodArray> = /*@__PURE__*/ core.$constructor("$ZodArray", (inst, def) => {
15861656
$ZodType.init(inst, def);
1657+
inst._zod.getJSONSchema = (ctx?: JSONSchemaContext) =>
1658+
createJSONSchema<JSONSchema.ArraySchema>(inst, ctx, (json, context) => {
1659+
json.type = "array";
1660+
json.items = def.element._zod.getJSONSchema(context);
1661+
const bag = inst._zod.bag as { minimum?: number; maximum?: number };
1662+
if (typeof bag?.minimum === "number") json.minItems = bag.minimum;
1663+
if (typeof bag?.maximum === "number") json.maxItems = bag.maximum;
1664+
});
15871665

15881666
inst._zod.parse = (payload, ctx) => {
15891667
const input = payload.value;
@@ -1825,6 +1903,26 @@ function handleCatchall(
18251903
export const $ZodObject: core.$constructor<$ZodObject> = /*@__PURE__*/ core.$constructor("$ZodObject", (inst, def) => {
18261904
// requires cast because technically $ZodObject doesn't extend
18271905
$ZodType.init(inst, def);
1906+
inst._zod.getJSONSchema = (ctx?: JSONSchemaContext) =>
1907+
createJSONSchema<JSONSchema.ObjectSchema>(inst, ctx, (json, context) => {
1908+
json.type = "object";
1909+
const shape = def.shape as $ZodShape;
1910+
const properties: Record<string, JSONSchema.BaseSchema> = {};
1911+
const required: string[] = [];
1912+
for (const key of Object.keys(shape)) {
1913+
const child = shape[key]!;
1914+
properties[key] = child._zod.getJSONSchema(context);
1915+
if (child._zod.optin !== "optional") {
1916+
required.push(key);
1917+
}
1918+
}
1919+
if (Object.keys(properties).length) json.properties = properties;
1920+
if (required.length) json.required = required;
1921+
const catchall = def.catchall as $ZodType | undefined;
1922+
if (catchall) {
1923+
json.additionalProperties = catchall._zod.def.type === "never" ? false : catchall._zod.getJSONSchema(context);
1924+
}
1925+
});
18281926
// const sh = def.shape;
18291927
const desc = Object.getOwnPropertyDescriptor(def, "shape");
18301928
if (!desc?.get) {
@@ -2055,6 +2153,10 @@ function handleUnionResults(results: ParsePayload[], final: ParsePayload, inst:
20552153

20562154
export const $ZodUnion: core.$constructor<$ZodUnion> = /*@__PURE__*/ core.$constructor("$ZodUnion", (inst, def) => {
20572155
$ZodType.init(inst, def);
2156+
inst._zod.getJSONSchema = (ctx?: JSONSchemaContext) =>
2157+
createJSONSchema<JSONSchema.BaseSchema>(inst, ctx, (json, context) => {
2158+
json.anyOf = def.options.map((option) => option._zod.getJSONSchema(context));
2159+
});
20582160

20592161
util.defineLazy(inst._zod, "optin", () =>
20602162
def.options.some((o) => o._zod.optin === "optional") ? "optional" : undefined
@@ -2419,6 +2521,17 @@ export interface $ZodTuple<
24192521

24202522
export const $ZodTuple: core.$constructor<$ZodTuple> = /*@__PURE__*/ core.$constructor("$ZodTuple", (inst, def) => {
24212523
$ZodType.init(inst, def);
2524+
inst._zod.getJSONSchema = (ctx?: JSONSchemaContext) =>
2525+
createJSONSchema<JSONSchema.ArraySchema>(inst, ctx, (json, context) => {
2526+
json.type = "array";
2527+
const items = def.items.map((item) => item._zod.getJSONSchema(context));
2528+
if (items.length) json.prefixItems = items;
2529+
if (def.rest) json.items = def.rest._zod.getJSONSchema(context);
2530+
else json.items = false;
2531+
const required = def.items.filter((item) => item._zod.optin !== "optional").length;
2532+
if (required) json.minItems = required;
2533+
if (!def.rest) json.maxItems = def.items.length;
2534+
});
24222535
const items = def.items;
24232536
const optStart = items.length - [...items].reverse().findIndex((item) => item._zod.optin !== "optional");
24242537

@@ -2573,6 +2686,12 @@ export interface $ZodRecord<Key extends $ZodRecordKey = $ZodRecordKey, Value ext
25732686

25742687
export const $ZodRecord: core.$constructor<$ZodRecord> = /*@__PURE__*/ core.$constructor("$ZodRecord", (inst, def) => {
25752688
$ZodType.init(inst, def);
2689+
inst._zod.getJSONSchema = (ctx?: JSONSchemaContext) =>
2690+
createJSONSchema<JSONSchema.ObjectSchema>(inst, ctx, (json, context) => {
2691+
json.type = "object";
2692+
json.additionalProperties = def.valueType._zod.getJSONSchema(context);
2693+
json.propertyNames = def.keyType._zod.getJSONSchema(context);
2694+
});
25762695

25772696
inst._zod.parse = (payload, ctx) => {
25782697
const input = payload.value;
@@ -2893,6 +3012,13 @@ export const $ZodEnum: core.$constructor<$ZodEnum> = /*@__PURE__*/ core.$constru
28933012
.map((o) => (typeof o === "string" ? util.escapeRegex(o) : o.toString()))
28943013
.join("|")})$`
28953014
);
3015+
inst._zod.getJSONSchema = (ctx?: JSONSchemaContext) =>
3016+
createJSONSchema<JSONSchema.BaseSchema>(inst, ctx, (json) => {
3017+
const list = Array.from(inst._zod.values ?? []);
3018+
if (list.length) {
3019+
json.enum = list as Array<string | number | boolean | null>;
3020+
}
3021+
});
28963022

28973023
inst._zod.parse = (payload, _ctx) => {
28983024
const input = payload.value;
@@ -2949,6 +3075,11 @@ export const $ZodLiteral: core.$constructor<$ZodLiteral> = /*@__PURE__*/ core.$c
29493075
.map((o) => (typeof o === "string" ? util.escapeRegex(o) : o ? util.escapeRegex(o.toString()) : String(o)))
29503076
.join("|")})$`
29513077
);
3078+
inst._zod.getJSONSchema = (ctx?: JSONSchemaContext) =>
3079+
createJSONSchema<JSONSchema.BaseSchema>(inst, ctx, (json) => {
3080+
const list = Array.from(inst._zod.values) as Array<string | number | boolean | null>;
3081+
json.enum = list;
3082+
});
29523083

29533084
inst._zod.parse = (payload, _ctx) => {
29543085
const input = payload.value;
@@ -3146,6 +3277,11 @@ export const $ZodOptional: core.$constructor<$ZodOptional> = /*@__PURE__*/ core.
31463277
(inst, def) => {
31473278
$ZodType.init(inst, def);
31483279
inst._zod.optin = "optional";
3280+
inst._zod.getJSONSchema = (ctx?: JSONSchemaContext) =>
3281+
createJSONSchema<JSONSchema.BaseSchema>(inst, ctx, (json, context) => {
3282+
const inner = def.innerType._zod.getJSONSchema(context);
3283+
Object.assign(json, inner);
3284+
});
31493285
inst._zod.optout = "optional";
31503286

31513287
util.defineLazy(inst._zod, "values", () => {
@@ -3200,6 +3336,11 @@ export const $ZodNullable: core.$constructor<$ZodNullable> = /*@__PURE__*/ core.
32003336
"$ZodNullable",
32013337
(inst, def) => {
32023338
$ZodType.init(inst, def);
3339+
inst._zod.getJSONSchema = (ctx?: JSONSchemaContext) =>
3340+
createJSONSchema<JSONSchema.BaseSchema>(inst, ctx, (json, context) => {
3341+
const inner = def.innerType._zod.getJSONSchema(context);
3342+
json.anyOf = [inner, { type: "null" }];
3343+
});
32033344
util.defineLazy(inst._zod, "optin", () => def.innerType._zod.optin);
32043345
util.defineLazy(inst._zod, "optout", () => def.innerType._zod.optout);
32053346

@@ -3255,6 +3396,13 @@ export const $ZodDefault: core.$constructor<$ZodDefault> = /*@__PURE__*/ core.$c
32553396

32563397
// inst._zod.qin = "true";
32573398
inst._zod.optin = "optional";
3399+
inst._zod.getJSONSchema = (ctx?: JSONSchemaContext) =>
3400+
createJSONSchema<JSONSchema.BaseSchema>(inst, ctx, (json, context) => {
3401+
const inner = def.innerType._zod.getJSONSchema(context);
3402+
Object.assign(json, inner);
3403+
const value = def.defaultValue;
3404+
json.default = typeof value === "function" ? value() : value;
3405+
});
32583406
util.defineLazy(inst._zod, "values", () => def.innerType._zod.values);
32593407

32603408
inst._zod.parse = (payload, ctx) => {
@@ -4163,6 +4311,11 @@ export interface $ZodLazy<T extends SomeType = $ZodType> extends $ZodType {
41634311

41644312
export const $ZodLazy: core.$constructor<$ZodLazy> = /*@__PURE__*/ core.$constructor("$ZodLazy", (inst, def) => {
41654313
$ZodType.init(inst, def);
4314+
inst._zod.getJSONSchema = (ctx?: JSONSchemaContext) =>
4315+
createJSONSchema<JSONSchema.BaseSchema>(inst, ctx, (json, context) => {
4316+
const resolved = inst._zod.innerType._zod.getJSONSchema(context);
4317+
Object.assign(json, resolved);
4318+
});
41664319

41674320
// let _innerType!: any;
41684321
// util.defineLazy(def, "getter", () => {

0 commit comments

Comments
 (0)