@gabrielbryk/json-schema-to-zod 2.7.4 → 2.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/CHANGELOG.md +23 -1
  2. package/dist/cjs/core/analyzeSchema.js +62 -0
  3. package/dist/cjs/core/emitZod.js +141 -0
  4. package/dist/cjs/generators/generateBundle.js +355 -0
  5. package/dist/cjs/index.js +6 -0
  6. package/dist/cjs/jsonSchemaToZod.js +5 -73
  7. package/dist/cjs/parsers/parseArray.js +34 -15
  8. package/dist/cjs/parsers/parseIfThenElse.js +2 -1
  9. package/dist/cjs/parsers/parseNumber.js +81 -39
  10. package/dist/cjs/parsers/parseObject.js +24 -0
  11. package/dist/cjs/parsers/parseSchema.js +147 -25
  12. package/dist/cjs/parsers/parseString.js +294 -54
  13. package/dist/cjs/utils/buildRefRegistry.js +56 -0
  14. package/dist/cjs/utils/cycles.js +113 -0
  15. package/dist/cjs/utils/resolveUri.js +16 -0
  16. package/dist/cjs/utils/withMessage.js +4 -5
  17. package/dist/esm/core/analyzeSchema.js +58 -0
  18. package/dist/esm/core/emitZod.js +137 -0
  19. package/dist/esm/generators/generateBundle.js +351 -0
  20. package/dist/esm/index.js +6 -0
  21. package/dist/esm/jsonSchemaToZod.js +5 -73
  22. package/dist/esm/parsers/parseArray.js +34 -15
  23. package/dist/esm/parsers/parseIfThenElse.js +2 -1
  24. package/dist/esm/parsers/parseNumber.js +81 -39
  25. package/dist/esm/parsers/parseObject.js +24 -0
  26. package/dist/esm/parsers/parseSchema.js +147 -25
  27. package/dist/esm/parsers/parseString.js +294 -54
  28. package/dist/esm/utils/buildRefRegistry.js +52 -0
  29. package/dist/esm/utils/cycles.js +107 -0
  30. package/dist/esm/utils/resolveUri.js +12 -0
  31. package/dist/esm/utils/withMessage.js +4 -5
  32. package/dist/types/Types.d.ts +36 -0
  33. package/dist/types/core/analyzeSchema.d.ts +24 -0
  34. package/dist/types/core/emitZod.d.ts +2 -0
  35. package/dist/types/generators/generateBundle.d.ts +62 -0
  36. package/dist/types/index.d.ts +6 -0
  37. package/dist/types/jsonSchemaToZod.d.ts +1 -1
  38. package/dist/types/parsers/parseSchema.d.ts +2 -1
  39. package/dist/types/parsers/parseString.d.ts +2 -2
  40. package/dist/types/utils/buildRefRegistry.d.ts +12 -0
  41. package/dist/types/utils/cycles.d.ts +7 -0
  42. package/dist/types/utils/jsdocs.d.ts +1 -1
  43. package/dist/types/utils/resolveUri.d.ts +1 -0
  44. package/dist/types/utils/withMessage.d.ts +6 -1
  45. package/docs/proposals/bundle-refactor.md +43 -0
  46. package/docs/proposals/ref-anchor-support.md +65 -0
  47. package/eslint.config.js +26 -0
  48. package/package.json +10 -4
  49. /package/{jest.config.js → jest.config.cjs} +0 -0
  50. /package/{postcjs.js → postcjs.cjs} +0 -0
  51. /package/{postesm.js → postesm.cjs} +0 -0
@@ -1,70 +1,112 @@
1
1
  import { withMessage } from "../utils/withMessage.js";
2
2
  export const parseNumber = (schema) => {
3
- let r = "z.number()";
3
+ const formatError = schema.errorMessage?.format;
4
+ const numericFormatMap = {
5
+ int32: "z.int32",
6
+ uint32: "z.uint32",
7
+ float32: "z.float32",
8
+ float64: "z.float64",
9
+ safeint: "z.safeint",
10
+ int64: "z.int64",
11
+ uint64: "z.uint64",
12
+ };
13
+ const mappedFormat = schema.format && numericFormatMap[schema.format] ? numericFormatMap[schema.format] : undefined;
14
+ const formatParams = formatError !== undefined ? `{ error: ${JSON.stringify(formatError)} }` : "";
15
+ let r = mappedFormat ? `${mappedFormat}(${formatParams})` : "z.number()";
4
16
  if (schema.type === "integer") {
5
- r += withMessage(schema, "type", () => [".int(", ")"]);
17
+ if (!mappedFormat) {
18
+ r += withMessage(schema, "type", () => ({
19
+ opener: ".int(",
20
+ closer: ")",
21
+ messagePrefix: "{ error: ",
22
+ messageCloser: " })",
23
+ }));
24
+ }
6
25
  }
7
26
  else {
8
- r += withMessage(schema, "format", ({ value }) => {
9
- if (value === "int64") {
10
- return [".int(", ")"];
11
- }
12
- });
27
+ if (!mappedFormat) {
28
+ r += withMessage(schema, "format", ({ value }) => {
29
+ if (value === "int64") {
30
+ return {
31
+ opener: ".int(",
32
+ closer: ")",
33
+ messagePrefix: "{ error: ",
34
+ messageCloser: " })",
35
+ };
36
+ }
37
+ });
38
+ }
13
39
  }
14
40
  r += withMessage(schema, "multipleOf", ({ value, json }) => {
15
41
  if (value === 1) {
16
42
  if (r.startsWith("z.number().int(")) {
17
43
  return;
18
44
  }
19
- return [".int(", ")"];
45
+ return {
46
+ opener: ".int(",
47
+ closer: ")",
48
+ messagePrefix: "{ error: ",
49
+ messageCloser: " })",
50
+ };
20
51
  }
21
- return [`.multipleOf(${json}`, ", ", ")"];
52
+ return {
53
+ opener: `.multipleOf(${json}`,
54
+ closer: ")",
55
+ messagePrefix: ", { error: ",
56
+ messageCloser: " })",
57
+ };
22
58
  });
23
59
  if (typeof schema.minimum === "number") {
24
60
  if (schema.exclusiveMinimum === true) {
25
- r += withMessage(schema, "minimum", ({ json }) => [
26
- `.gt(${json}`,
27
- ", ",
28
- ")",
29
- ]);
61
+ r += withMessage(schema, "minimum", ({ json }) => ({
62
+ opener: `.gt(${json}`,
63
+ closer: ")",
64
+ messagePrefix: ", { error: ",
65
+ messageCloser: " })",
66
+ }));
30
67
  }
31
68
  else {
32
- r += withMessage(schema, "minimum", ({ json }) => [
33
- `.gte(${json}`,
34
- ", ",
35
- ")",
36
- ]);
69
+ r += withMessage(schema, "minimum", ({ json }) => ({
70
+ opener: `.gte(${json}`,
71
+ closer: ")",
72
+ messagePrefix: ", { error: ",
73
+ messageCloser: " })",
74
+ }));
37
75
  }
38
76
  }
39
77
  else if (typeof schema.exclusiveMinimum === "number") {
40
- r += withMessage(schema, "exclusiveMinimum", ({ json }) => [
41
- `.gt(${json}`,
42
- ", ",
43
- ")",
44
- ]);
78
+ r += withMessage(schema, "exclusiveMinimum", ({ json }) => ({
79
+ opener: `.gt(${json}`,
80
+ closer: ")",
81
+ messagePrefix: ", { error: ",
82
+ messageCloser: " })",
83
+ }));
45
84
  }
46
85
  if (typeof schema.maximum === "number") {
47
86
  if (schema.exclusiveMaximum === true) {
48
- r += withMessage(schema, "maximum", ({ json }) => [
49
- `.lt(${json}`,
50
- ", ",
51
- ")",
52
- ]);
87
+ r += withMessage(schema, "maximum", ({ json }) => ({
88
+ opener: `.lt(${json}`,
89
+ closer: ")",
90
+ messagePrefix: ", { error: ",
91
+ messageCloser: " })",
92
+ }));
53
93
  }
54
94
  else {
55
- r += withMessage(schema, "maximum", ({ json }) => [
56
- `.lte(${json}`,
57
- ", ",
58
- ")",
59
- ]);
95
+ r += withMessage(schema, "maximum", ({ json }) => ({
96
+ opener: `.lte(${json}`,
97
+ closer: ")",
98
+ messagePrefix: ", { error: ",
99
+ messageCloser: " })",
100
+ }));
60
101
  }
61
102
  }
62
103
  else if (typeof schema.exclusiveMaximum === "number") {
63
- r += withMessage(schema, "exclusiveMaximum", ({ json }) => [
64
- `.lt(${json}`,
65
- ", ",
66
- ")",
67
- ]);
104
+ r += withMessage(schema, "exclusiveMaximum", ({ json }) => ({
105
+ opener: `.lt(${json}`,
106
+ closer: ")",
107
+ messagePrefix: ", { error: ",
108
+ messageCloser: " })",
109
+ }));
68
110
  }
69
111
  return r;
70
112
  };
@@ -268,6 +268,30 @@ export function parseObject(objectSchema, refs) {
268
268
  }`;
269
269
  })
270
270
  .join("\n ")}
271
+ })`;
272
+ }
273
+ }
274
+ // dependentRequired
275
+ if (objectSchema.dependentRequired && typeof objectSchema.dependentRequired === "object") {
276
+ const entries = Object.entries(objectSchema.dependentRequired);
277
+ if (entries.length) {
278
+ const depRequiredMessage = objectSchema.errorMessage?.dependentRequired ?? "Dependent required properties missing";
279
+ output += `.superRefine((obj, ctx) => {
280
+ ${entries
281
+ .map(([prop, deps]) => {
282
+ const arr = Array.isArray(deps) ? deps : [];
283
+ if (!arr.length)
284
+ return "";
285
+ const jsonDeps = JSON.stringify(arr);
286
+ return `if (Object.prototype.hasOwnProperty.call(obj, ${JSON.stringify(prop)})) {
287
+ const missing = ${jsonDeps}.filter((d) => !Object.prototype.hasOwnProperty.call(obj, d));
288
+ if (missing.length) {
289
+ ctx.addIssue({ code: "custom", message: ${JSON.stringify(depRequiredMessage)}, path: [], params: { missing } });
290
+ }
291
+ }`;
292
+ })
293
+ .filter(Boolean)
294
+ .join("\n ")}
271
295
  })`;
272
296
  }
273
297
  }
@@ -16,17 +16,33 @@ import { parseOneOf } from "./parseOneOf.js";
16
16
  import { parseSimpleDiscriminatedOneOf } from "./parseSimpleDiscriminatedOneOf.js";
17
17
  import { parseNullable } from "./parseNullable.js";
18
18
  import { anyOrUnknown } from "../utils/anyOrUnknown.js";
19
+ import { resolveUri } from "../utils/resolveUri.js";
20
+ import { buildRefRegistry } from "../utils/buildRefRegistry.js";
19
21
  export const parseSchema = (schema, refs = { seen: new Map(), path: [] }, blockMeta) => {
20
22
  // Ensure ref bookkeeping exists so $ref declarations and getter-based recursion work
21
23
  refs.root = refs.root ?? schema;
24
+ refs.rootBaseUri = refs.rootBaseUri ?? "root:///";
22
25
  refs.declarations = refs.declarations ?? new Map();
26
+ refs.dependencies = refs.dependencies ?? new Map();
23
27
  refs.inProgress = refs.inProgress ?? new Set();
24
28
  refs.refNameByPointer = refs.refNameByPointer ?? new Map();
25
29
  refs.usedNames = refs.usedNames ?? new Set();
26
30
  if (typeof schema !== "object")
27
31
  return schema ? anyOrUnknown(refs) : "z.never()";
32
+ const parentBase = refs.currentBaseUri ?? refs.rootBaseUri ?? "root:///";
33
+ const baseUri = typeof schema.$id === "string"
34
+ ? resolveUri(parentBase, schema.$id)
35
+ : parentBase;
36
+ const dynamicAnchors = Array.isArray(refs.dynamicAnchors) ? [...refs.dynamicAnchors] : [];
37
+ if (typeof schema.$dynamicAnchor === "string") {
38
+ dynamicAnchors.push({
39
+ name: schema.$dynamicAnchor,
40
+ uri: baseUri,
41
+ path: refs.path,
42
+ });
43
+ }
28
44
  if (refs.parserOverride) {
29
- const custom = refs.parserOverride(schema, refs);
45
+ const custom = refs.parserOverride(schema, { ...refs, currentBaseUri: baseUri, dynamicAnchors });
30
46
  if (typeof custom === "string") {
31
47
  return custom;
32
48
  }
@@ -46,14 +62,14 @@ export const parseSchema = (schema, refs = { seen: new Map(), path: [] }, blockM
46
62
  refs.seen.set(schema, seen);
47
63
  }
48
64
  if (its.a.ref(schema)) {
49
- const parsedRef = parseRef(schema, refs);
65
+ const parsedRef = parseRef(schema, { ...refs, currentBaseUri: baseUri, dynamicAnchors });
50
66
  seen.r = parsedRef;
51
67
  return parsedRef;
52
68
  }
53
- let parsed = selectParser(schema, refs);
69
+ let parsed = selectParser(schema, { ...refs, currentBaseUri: baseUri, dynamicAnchors });
54
70
  if (!blockMeta) {
55
71
  if (!refs.withoutDescribes) {
56
- parsed = addDescribes(schema, parsed, refs);
72
+ parsed = addDescribes(schema, parsed, { ...refs, currentBaseUri: baseUri, dynamicAnchors });
57
73
  }
58
74
  if (!refs.withoutDefaults) {
59
75
  parsed = addDefaults(schema, parsed);
@@ -64,23 +80,47 @@ export const parseSchema = (schema, refs = { seen: new Map(), path: [] }, blockM
64
80
  return parsed;
65
81
  };
66
82
  const parseRef = (schema, refs) => {
67
- const resolved = resolveRef(refs.root, schema.$ref);
83
+ const refValue = schema.$dynamicRef ?? schema.$ref;
84
+ const resolved = resolveRef(schema, refValue, refs);
68
85
  if (!resolved) {
86
+ refs.onUnresolvedRef?.(refValue, refs.path);
69
87
  return anyOrUnknown(refs);
70
88
  }
71
- const { schema: target, path } = resolved;
72
- const refName = getOrCreateRefName(schema.$ref, path, refs);
89
+ const { schema: target, path, pointerKey } = resolved;
90
+ const refName = getOrCreateRefName(pointerKey, path, refs);
73
91
  if (!refs.declarations.has(refName) && !refs.inProgress.has(refName)) {
74
92
  refs.inProgress.add(refName);
75
93
  const declaration = parseSchema(target, {
76
94
  ...refs,
77
95
  path,
96
+ currentBaseUri: resolved.baseUri,
78
97
  currentSchemaName: refName,
79
98
  root: refs.root,
80
99
  });
81
100
  refs.inProgress.delete(refName);
82
101
  refs.declarations.set(refName, declaration);
83
102
  }
103
+ const current = refs.currentSchemaName;
104
+ if (current) {
105
+ const deps = refs.dependencies;
106
+ const set = deps.get(current) ?? new Set();
107
+ set.add(refName);
108
+ deps.set(current, set);
109
+ }
110
+ const currentComponent = refs.currentSchemaName
111
+ ? refs.cycleComponentByName?.get(refs.currentSchemaName)
112
+ : undefined;
113
+ const targetComponent = refs.cycleComponentByName?.get(refName);
114
+ const isSameCycle = currentComponent !== undefined &&
115
+ targetComponent !== undefined &&
116
+ currentComponent === targetComponent &&
117
+ refs.cycleRefNames?.has(refName);
118
+ // Only lazy if the ref stays inside the current strongly-connected component
119
+ // (or is currently being resolved). This avoids TDZ on true cycles while
120
+ // letting ordered, acyclic refs stay direct.
121
+ if (isSameCycle || refs.inProgress.has(refName)) {
122
+ return `z.lazy(() => ${refName})`;
123
+ }
84
124
  return refName;
85
125
  };
86
126
  const addDescribes = (schema, parsed, refs) => {
@@ -106,23 +146,93 @@ const addDescribes = (schema, parsed, refs) => {
106
146
  }
107
147
  return parsed;
108
148
  };
109
- const resolveRef = (root, ref) => {
110
- if (!root || !ref.startsWith("#/"))
111
- return undefined;
112
- const rawSegments = ref
113
- .slice(2)
114
- .split("/")
115
- .filter((segment) => segment.length > 0)
116
- .map(decodePointerSegment);
117
- let current = root;
118
- for (const segment of rawSegments) {
119
- if (typeof current !== "object" || current === null)
120
- return undefined;
121
- current = current[segment];
122
- }
123
- return { schema: current, path: rawSegments };
149
+ const resolveRef = (schemaNode, ref, refs) => {
150
+ const base = refs.currentBaseUri ?? refs.rootBaseUri ?? "root:///";
151
+ // Handle dynamicRef lookup via dynamicAnchors stack
152
+ const isDynamic = typeof schemaNode.$dynamicRef === "string";
153
+ if (isDynamic && refs.dynamicAnchors && ref.startsWith("#")) {
154
+ const name = ref.slice(1);
155
+ for (let i = refs.dynamicAnchors.length - 1; i >= 0; i -= 1) {
156
+ const entry = refs.dynamicAnchors[i];
157
+ if (entry.name === name) {
158
+ const key = `${entry.uri}#${name}`;
159
+ const target = refs.refRegistry?.get(key);
160
+ if (target) {
161
+ return { schema: target.schema, path: target.path, baseUri: target.baseUri, pointerKey: key };
162
+ }
163
+ }
164
+ }
165
+ }
166
+ // Resolve URI against base
167
+ const resolvedUri = resolveUri(base, ref);
168
+ const [uriBase, fragment] = resolvedUri.split("#");
169
+ const key = fragment ? `${uriBase}#${fragment}` : uriBase;
170
+ let regEntry = refs.refRegistry?.get(key);
171
+ if (regEntry) {
172
+ return { schema: regEntry.schema, path: regEntry.path, baseUri: regEntry.baseUri, pointerKey: key };
173
+ }
174
+ // Legacy recursive ref: treat as dynamic to __recursive__
175
+ if (schemaNode.$recursiveRef) {
176
+ const recursiveKey = `${base}#__recursive__`;
177
+ regEntry = refs.refRegistry?.get(recursiveKey);
178
+ if (regEntry) {
179
+ return {
180
+ schema: regEntry.schema,
181
+ path: regEntry.path,
182
+ baseUri: regEntry.baseUri,
183
+ pointerKey: recursiveKey,
184
+ };
185
+ }
186
+ }
187
+ // External resolver hook
188
+ const extBase = uriBaseFromRef(resolvedUri);
189
+ if (refs.resolveExternalRef && extBase && !isLocalBase(extBase, refs.rootBaseUri ?? "")) {
190
+ const loaded = refs.resolveExternalRef(extBase);
191
+ if (loaded) {
192
+ // If async resolver is used synchronously here, it will be ignored; keep simple sync for now
193
+ const schema = loaded.then ? undefined : loaded;
194
+ if (schema) {
195
+ const { registry } = buildRefRegistry(schema, extBase);
196
+ registry.forEach((entry, k) => refs.refRegistry?.set(k, entry));
197
+ regEntry = refs.refRegistry?.get(key);
198
+ if (regEntry) {
199
+ return {
200
+ schema: regEntry.schema,
201
+ path: regEntry.path,
202
+ baseUri: regEntry.baseUri,
203
+ pointerKey: key,
204
+ };
205
+ }
206
+ }
207
+ }
208
+ }
209
+ // Backward compatibility: JSON Pointer into root
210
+ if (refs.root && ref.startsWith("#/")) {
211
+ const rawSegments = ref
212
+ .slice(2)
213
+ .split("/")
214
+ .filter((segment) => segment.length > 0)
215
+ .map(decodePointerSegment);
216
+ let current = refs.root;
217
+ for (const segment of rawSegments) {
218
+ if (typeof current !== "object" || current === null)
219
+ return undefined;
220
+ current = current[segment];
221
+ }
222
+ return { schema: current, path: rawSegments, baseUri: base, pointerKey: ref };
223
+ }
224
+ return undefined;
124
225
  };
125
226
  const decodePointerSegment = (segment) => segment.replace(/~1/g, "/").replace(/~0/g, "~");
227
+ const uriBaseFromRef = (resolvedUri) => {
228
+ const hashIdx = resolvedUri.indexOf("#");
229
+ return hashIdx === -1 ? resolvedUri : resolvedUri.slice(0, hashIdx);
230
+ };
231
+ const isLocalBase = (base, rootBase) => {
232
+ if (!rootBase)
233
+ return false;
234
+ return base === rootBase;
235
+ };
126
236
  const getOrCreateRefName = (pointer, path, refs) => {
127
237
  if (refs.refNameByPointer?.has(pointer)) {
128
238
  return refs.refNameByPointer.get(pointer);
@@ -133,12 +243,24 @@ const getOrCreateRefName = (pointer, path, refs) => {
133
243
  return preferred;
134
244
  };
135
245
  const buildNameFromPath = (path, used) => {
136
- const filtered = path.filter((segment) => segment !== "$defs" && segment !== "definitions" && segment !== "properties");
246
+ const filtered = path
247
+ .map((segment, idx) => {
248
+ if (idx === 0 && (segment === "$defs" || segment === "definitions")) {
249
+ return undefined; // root-level defs prefix is redundant for naming
250
+ }
251
+ if (segment === "properties")
252
+ return undefined; // skip noisy properties segment
253
+ if (segment === "$defs" || segment === "definitions")
254
+ return "Defs";
255
+ return segment;
256
+ })
257
+ .filter((segment) => segment !== undefined);
137
258
  const base = filtered.length
138
259
  ? filtered
139
260
  .map((segment) => typeof segment === "number"
140
261
  ? `Ref${segment}`
141
262
  : segment
263
+ .toString()
142
264
  .replace(/[^a-zA-Z0-9_$]/g, " ")
143
265
  .split(" ")
144
266
  .filter(Boolean)
@@ -209,7 +331,7 @@ const selectParser = (schema, refs) => {
209
331
  return parseMultipleType(schema, refs);
210
332
  }
211
333
  else if (its.a.primitive(schema, "string")) {
212
- return parseString(schema);
334
+ return parseString(schema, refs);
213
335
  }
214
336
  else if (its.a.primitive(schema, "number") ||
215
337
  its.a.primitive(schema, "integer")) {
@@ -240,7 +362,7 @@ export const its = {
240
362
  nullable: (x) => x.nullable === true,
241
363
  multipleType: (x) => Array.isArray(x.type),
242
364
  not: (x) => x.not !== undefined,
243
- ref: (x) => typeof x.$ref === "string",
365
+ ref: (x) => typeof x.$ref === "string" || typeof x.$dynamicRef === "string",
244
366
  const: (x) => x.const !== undefined,
245
367
  primitive: (x, p) => x.type === p,
246
368
  conditional: (x) => Boolean("if" in x && x.if && "then" in x && "else" in x && x.then && x.else),