@gabrielbryk/json-schema-to-zod 2.10.0 → 2.10.1

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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # @gabrielbryk/json-schema-to-zod
2
2
 
3
+ ## 2.10.1
4
+
5
+ ### Patch Changes
6
+
7
+ - 1c318a0: Ensure bundled outputs remain type-safe by grouping strongly connected defs, emitting lazy getters for recursive object refs, and reordering bundle members to avoid duplicate exports.
8
+ - 87526f0: Preserve precise types for recursive schemas by emitting typed lazy wrappers instead of erasing types, and add a workflow regression type-check to guard against inference blowups.
9
+
3
10
  ## 2.9.0
4
11
 
5
12
  ### Minor Changes
@@ -73,6 +73,16 @@ const emitZod = (analysis) => {
73
73
  const { module, name, type, noImport, exportRefs, withMeta, ...rest } = options;
74
74
  const declarations = new Map();
75
75
  const dependencies = new Map();
76
+ const reserveName = (base) => {
77
+ let candidate = base;
78
+ let i = 1;
79
+ while (usedNames.has(candidate) || declarations.has(candidate)) {
80
+ candidate = `${base}${i}`;
81
+ i += 1;
82
+ }
83
+ usedNames.add(candidate);
84
+ return candidate;
85
+ };
76
86
  const parsedSchema = (0, parseSchema_js_1.parseSchema)(schema, {
77
87
  module,
78
88
  name,
@@ -94,10 +104,16 @@ const emitZod = (analysis) => {
94
104
  });
95
105
  const declarationBlock = declarations.size
96
106
  ? orderDeclarations(Array.from(declarations.entries()), dependencies)
97
- .map(([refName, value]) => {
107
+ .flatMap(([refName, value]) => {
98
108
  const shouldExport = exportRefs && module === "esm";
99
- const decl = `${shouldExport ? "export " : ""}const ${refName} = ${value}`;
100
- return decl;
109
+ const isCycle = cycleRefNames.has(refName);
110
+ if (!isCycle) {
111
+ return [`${shouldExport ? "export " : ""}const ${refName} = ${value}`];
112
+ }
113
+ const baseName = `${refName}Def`;
114
+ const lines = [`const ${baseName} = ${value}`];
115
+ lines.push(`${shouldExport ? "export " : ""}const ${refName} = ${baseName}`);
116
+ return lines;
101
117
  })
102
118
  .join("\n")
103
119
  : "";
@@ -11,23 +11,27 @@ const generateSchemaBundle = (schema, options = {}) => {
11
11
  const defs = schema.$defs || {};
12
12
  const definitions = schema.definitions || {};
13
13
  const defNames = Object.keys(defs);
14
- const { rootName, rootTypeName, defInfoMap } = buildBundleContext(defNames, defs, options);
14
+ const { rootName, rootTypeName, defInfoMap, groups } = buildBundleContext(defNames, defs, options);
15
15
  const files = [];
16
- const targets = planBundleTargets(schema, defs, definitions, defNames, options, rootName, rootTypeName);
16
+ const targets = planBundleTargets(schema, defs, definitions, defNames, options, rootName, defInfoMap, rootTypeName);
17
17
  for (const target of targets) {
18
18
  const usedRefs = target.usedRefs;
19
- const analysis = (0, analyzeSchema_js_1.analyzeSchema)(target.schemaWithDefs, {
20
- ...options,
21
- module,
22
- name: target.schemaName,
23
- type: target.typeName,
24
- parserOverride: createRefHandler(target.defName, defInfoMap, usedRefs, {
25
- ...(target.schemaWithDefs.$defs || {}),
26
- ...(target.schemaWithDefs.definitions || {}),
27
- }, options),
28
- });
29
- const zodSchema = (0, emitZod_js_1.emitZod)(analysis);
30
- const finalSchema = buildSchemaFile(zodSchema, usedRefs, defInfoMap, module);
19
+ const zodParts = [];
20
+ for (const member of target.members) {
21
+ const analysis = (0, analyzeSchema_js_1.analyzeSchema)(member.schemaWithDefs, {
22
+ ...options,
23
+ module,
24
+ name: member.schemaName,
25
+ type: member.typeName,
26
+ parserOverride: createRefHandler(member.defName, defInfoMap, usedRefs, {
27
+ ...(member.schemaWithDefs.$defs || {}),
28
+ ...(member.schemaWithDefs.definitions || {}),
29
+ }, options, target.groupId),
30
+ });
31
+ const zodSchema = (0, emitZod_js_1.emitZod)(analysis);
32
+ zodParts.push(zodSchema);
33
+ }
34
+ const finalSchema = buildSchemaFile(zodParts, usedRefs, defInfoMap, module, target);
31
35
  files.push({ fileName: target.fileName, contents: finalSchema });
32
36
  }
33
37
  // Nested types extraction (optional)
@@ -57,13 +61,16 @@ const buildDefInfoMap = (defNames, defs, options) => {
57
61
  const pascalName = toPascalCase(defName);
58
62
  const schemaName = options.splitDefs?.schemaName?.(defName, { isRoot: false }) ?? `${pascalName}Schema`;
59
63
  const typeName = options.splitDefs?.typeName?.(defName, { isRoot: false }) ?? pascalName;
64
+ const fileName = options.splitDefs?.fileName?.(defName, { isRoot: false }) ?? `${defName}.schema.ts`;
60
65
  map.set(defName, {
61
66
  name: defName,
62
67
  pascalName,
63
68
  schemaName,
64
69
  typeName,
70
+ fileName,
65
71
  dependencies,
66
72
  hasCycle: false,
73
+ groupId: "",
67
74
  });
68
75
  }
69
76
  return map;
@@ -76,13 +83,21 @@ const buildBundleContext = (defNames, defs, options) => {
76
83
  if (info)
77
84
  info.hasCycle = true;
78
85
  }
86
+ const groups = buildSccGroups(defInfoMap);
87
+ for (const [groupId, members] of groups) {
88
+ for (const defName of members) {
89
+ const info = defInfoMap.get(defName);
90
+ if (info)
91
+ info.groupId = groupId;
92
+ }
93
+ }
79
94
  const rootName = options.splitDefs?.rootName ?? options.name ?? "RootSchema";
80
95
  const rootTypeName = typeof options.type === "string"
81
96
  ? options.type
82
97
  : options.splitDefs?.rootTypeName ?? (typeof options.type === "boolean" && options.type ? rootName : undefined);
83
- return { defInfoMap, rootName, rootTypeName };
98
+ return { defInfoMap, rootName, rootTypeName, groups };
84
99
  };
85
- const createRefHandler = (currentDefName, defInfoMap, usedRefs, allDefs, options) => {
100
+ const createRefHandler = (currentDefName, defInfoMap, usedRefs, allDefs, options, currentGroupId) => {
86
101
  const useLazyCrossRefs = options.refResolution?.lazyCrossRefs ?? true;
87
102
  return (schema, refs) => {
88
103
  if (typeof schema["$ref"] === "string") {
@@ -97,7 +112,7 @@ const createRefHandler = (currentDefName, defInfoMap, usedRefs, allDefs, options
97
112
  const refInfo = defInfoMap.get(refName);
98
113
  if (refInfo) {
99
114
  // Track imports when referencing other defs
100
- if (refName !== currentDefName) {
115
+ if (refName !== currentDefName && refInfo.groupId !== currentGroupId) {
101
116
  usedRefs.add(refName);
102
117
  }
103
118
  const isCycle = refName === currentDefName || (refInfo.hasCycle && !!currentDefName);
@@ -111,6 +126,13 @@ const createRefHandler = (currentDefName, defInfoMap, usedRefs, allDefs, options
111
126
  if (resolved)
112
127
  return resolved;
113
128
  if (isCycle && useLazyCrossRefs) {
129
+ const inObjectProperty = refs.path.includes("properties") ||
130
+ refs.path.includes("patternProperties") ||
131
+ refs.path.includes("additionalProperties");
132
+ if (inObjectProperty && refName === currentDefName) {
133
+ // Self-recursion inside object getters can safely reference the schema name
134
+ return refInfo.schemaName;
135
+ }
114
136
  return `z.lazy(() => ${refInfo.schemaName})`;
115
137
  }
116
138
  return refInfo.schemaName;
@@ -129,42 +151,73 @@ const createRefHandler = (currentDefName, defInfoMap, usedRefs, allDefs, options
129
151
  return undefined;
130
152
  };
131
153
  };
132
- const buildSchemaFile = (zodCode, usedRefs, defInfoMap, module) => {
154
+ const buildSchemaFile = (zodCodeParts, usedRefs, defInfoMap, module, target) => {
133
155
  if (module !== "esm")
134
- return zodCode;
135
- const imports = [];
156
+ return zodCodeParts.join("\n");
157
+ const groupFileById = new Map();
158
+ for (const info of defInfoMap.values()) {
159
+ if (!groupFileById.has(info.groupId)) {
160
+ groupFileById.set(info.groupId, info.fileName.replace(/\.ts$/, ".js"));
161
+ }
162
+ }
163
+ const importsByFile = new Map();
136
164
  for (const refName of [...usedRefs].sort()) {
137
165
  const refInfo = defInfoMap.get(refName);
138
166
  if (refInfo) {
139
- imports.push(`import { ${refInfo.schemaName} } from './${refName}.schema.js';`);
167
+ const groupFile = groupFileById.get(refInfo.groupId) ?? refInfo.fileName.replace(/\.ts$/, ".js");
168
+ const path = `./${groupFile}`;
169
+ const set = importsByFile.get(path) ?? new Set();
170
+ set.add(refInfo.schemaName);
171
+ importsByFile.set(path, set);
140
172
  }
141
173
  }
142
- if (!imports.length)
143
- return zodCode;
144
- return zodCode.replace('import { z } from "zod"', `import { z } from "zod"\n${imports.join("\n")}`);
174
+ const imports = [];
175
+ for (const [path, names] of [...importsByFile.entries()].sort(([a], [b]) => a.localeCompare(b))) {
176
+ imports.push(`import { ${[...names].sort().join(", ")} } from '${path}';`);
177
+ }
178
+ const body = zodCodeParts
179
+ .map((code, idx) => {
180
+ if (idx === 0)
181
+ return code;
182
+ return code.replace(/^import \{ z \} from "zod"\n?/, "");
183
+ })
184
+ .join("\n");
185
+ return imports.length
186
+ ? body.replace('import { z } from "zod"', `import { z } from "zod"\n${imports.join("\n")}`)
187
+ : body;
145
188
  };
146
- const planBundleTargets = (rootSchema, defs, definitions, defNames, options, rootName, rootTypeName) => {
189
+ const planBundleTargets = (rootSchema, defs, definitions, defNames, options, rootName, defInfoMap, rootTypeName) => {
147
190
  const targets = [];
191
+ const groupById = new Map();
148
192
  for (const defName of defNames) {
149
- const defSchema = defs[defName];
150
- const defSchemaWithDefs = {
151
- ...defSchema,
152
- $defs: { ...defs, ...defSchema?.$defs },
153
- definitions: {
154
- ...defSchema.definitions,
155
- ...definitions,
156
- },
157
- };
158
- const pascalName = toPascalCase(defName);
159
- const schemaName = options.splitDefs?.schemaName?.(defName, { isRoot: false }) ?? `${pascalName}Schema`;
160
- const typeName = options.splitDefs?.typeName?.(defName, { isRoot: false }) ?? pascalName;
161
- const fileName = options.splitDefs?.fileName?.(defName, { isRoot: false }) ?? `${defName}.schema.ts`;
193
+ const info = defInfoMap.get(defName);
194
+ const gid = info?.groupId || defName;
195
+ if (!groupById.has(gid))
196
+ groupById.set(gid, []);
197
+ groupById.get(gid).push(defName);
198
+ }
199
+ for (const [groupId, memberDefs] of groupById.entries()) {
200
+ const orderedDefs = orderGroupMembers(memberDefs, defInfoMap);
201
+ const members = orderedDefs.map((defName) => {
202
+ const defSchema = defs[defName];
203
+ const defSchemaWithDefs = {
204
+ ...defSchema,
205
+ $defs: { ...defs, ...defSchema?.$defs },
206
+ definitions: {
207
+ ...defSchema.definitions,
208
+ ...definitions,
209
+ },
210
+ };
211
+ const pascalName = toPascalCase(defName);
212
+ const schemaName = options.splitDefs?.schemaName?.(defName, { isRoot: false }) ?? `${pascalName}Schema`;
213
+ const typeName = options.splitDefs?.typeName?.(defName, { isRoot: false }) ?? pascalName;
214
+ return { defName, schemaWithDefs: defSchemaWithDefs, schemaName, typeName };
215
+ });
216
+ const fileName = defInfoMap.get(memberDefs[0])?.fileName ?? `${memberDefs[0]}.schema.ts`;
162
217
  targets.push({
163
- defName,
164
- schemaWithDefs: defSchemaWithDefs,
165
- schemaName,
166
- typeName,
218
+ groupId,
167
219
  fileName,
220
+ members,
168
221
  usedRefs: new Set(),
169
222
  isRoot: false,
170
223
  });
@@ -172,17 +225,22 @@ const planBundleTargets = (rootSchema, defs, definitions, defNames, options, roo
172
225
  if (options.splitDefs?.includeRoot ?? true) {
173
226
  const rootFile = options.splitDefs?.fileName?.("root", { isRoot: true }) ?? "workflow.schema.ts";
174
227
  targets.push({
175
- defName: null,
176
- schemaWithDefs: {
177
- ...rootSchema,
178
- definitions: {
179
- ...rootSchema.definitions,
180
- ...definitions,
181
- },
182
- },
183
- schemaName: rootName,
184
- typeName: rootTypeName,
228
+ groupId: "root",
185
229
  fileName: rootFile,
230
+ members: [
231
+ {
232
+ defName: null,
233
+ schemaWithDefs: {
234
+ ...rootSchema,
235
+ definitions: {
236
+ ...rootSchema.definitions,
237
+ ...definitions,
238
+ },
239
+ },
240
+ schemaName: rootName,
241
+ typeName: rootTypeName,
242
+ },
243
+ ],
186
244
  usedRefs: new Set(),
187
245
  isRoot: true,
188
246
  });
@@ -213,6 +271,35 @@ const findRefDependencies = (schema, validDefNames) => {
213
271
  traverse(schema);
214
272
  return deps;
215
273
  };
274
+ const orderGroupMembers = (defs, defInfoMap) => {
275
+ const inGroup = new Set(defs);
276
+ const visited = new Set();
277
+ const temp = new Set();
278
+ const result = [];
279
+ const visit = (name) => {
280
+ if (visited.has(name))
281
+ return;
282
+ if (temp.has(name)) {
283
+ return;
284
+ }
285
+ temp.add(name);
286
+ const info = defInfoMap.get(name);
287
+ if (info) {
288
+ for (const dep of info.dependencies) {
289
+ if (inGroup.has(dep)) {
290
+ visit(dep);
291
+ }
292
+ }
293
+ }
294
+ temp.delete(name);
295
+ visited.add(name);
296
+ result.push(name);
297
+ };
298
+ for (const name of defs) {
299
+ visit(name);
300
+ }
301
+ return result;
302
+ };
216
303
  const detectCycles = (defInfoMap) => {
217
304
  const cycleNodes = new Set();
218
305
  const visited = new Set();
@@ -246,6 +333,52 @@ const detectCycles = (defInfoMap) => {
246
333
  }
247
334
  return cycleNodes;
248
335
  };
336
+ const buildSccGroups = (defInfoMap) => {
337
+ const indexMap = new Map();
338
+ const lowLink = new Map();
339
+ const onStack = new Set();
340
+ const stack = [];
341
+ let index = 0;
342
+ const groups = new Map();
343
+ const strongConnect = (node) => {
344
+ indexMap.set(node, index);
345
+ lowLink.set(node, index);
346
+ index += 1;
347
+ stack.push(node);
348
+ onStack.add(node);
349
+ const info = defInfoMap.get(node);
350
+ if (info) {
351
+ for (const dep of info.dependencies) {
352
+ if (!indexMap.has(dep)) {
353
+ strongConnect(dep);
354
+ lowLink.set(node, Math.min(lowLink.get(node), lowLink.get(dep)));
355
+ }
356
+ else if (onStack.has(dep)) {
357
+ lowLink.set(node, Math.min(lowLink.get(node), indexMap.get(dep)));
358
+ }
359
+ }
360
+ }
361
+ if (lowLink.get(node) === indexMap.get(node)) {
362
+ const members = [];
363
+ let w;
364
+ do {
365
+ w = stack.pop();
366
+ if (w) {
367
+ onStack.delete(w);
368
+ members.push(w);
369
+ }
370
+ } while (w && w !== node);
371
+ const groupId = members.sort().join("__");
372
+ groups.set(groupId, members);
373
+ }
374
+ };
375
+ for (const name of defInfoMap.keys()) {
376
+ if (!indexMap.has(name)) {
377
+ strongConnect(name);
378
+ }
379
+ }
380
+ return groups;
381
+ };
249
382
  const collectNestedTypes = (rootSchema, defs, defNames, rootTypeName) => {
250
383
  const allNestedTypes = [];
251
384
  for (const defName of defNames) {
@@ -321,6 +454,26 @@ const generateNestedTypesFile = (nestedTypes) => {
321
454
  " * They are extracted using TypeScript indexed access types.",
322
455
  " */",
323
456
  "",
457
+ "type Access<T, P extends readonly (string | number)[]> =",
458
+ " P extends []",
459
+ " ? NonNullable<T>",
460
+ " : P extends readonly [infer H, ...infer R]",
461
+ " ? H extends \"items\"",
462
+ " ? Access<NonNullable<T> extends Array<infer U> ? U : unknown, Extract<R, (string | number)[]>>",
463
+ " : H extends \"additionalProperties\"",
464
+ " ? Access<NonNullable<T> extends Record<string, infer V> ? V : unknown, Extract<R, (string | number)[]>>",
465
+ " : H extends number",
466
+ " ? Access<NonNullable<T> extends Array<infer U> ? U : unknown, Extract<R, (string | number)[]>>",
467
+ " : H extends string",
468
+ " ? Access<",
469
+ " H extends keyof NonNullable<T>",
470
+ " ? NonNullable<NonNullable<T>[H]>",
471
+ " : unknown,",
472
+ " Extract<R, (string | number)[]>",
473
+ " >",
474
+ " : unknown",
475
+ " : unknown;",
476
+ "",
324
477
  ];
325
478
  const byParent = new Map();
326
479
  for (const info of nestedTypes) {
@@ -340,16 +493,8 @@ const generateNestedTypesFile = (nestedTypes) => {
340
493
  }
341
494
  lines.push("");
342
495
  const buildAccessExpr = (parentType, propertyPath) => {
343
- let accessExpr = parentType;
344
- for (const prop of propertyPath) {
345
- const accessor = prop === "items"
346
- ? "[number]"
347
- : typeof prop === "number"
348
- ? `[${prop}]`
349
- : `[${JSON.stringify(prop)}]`;
350
- accessExpr = `NonNullable<${accessExpr}${accessor}>`;
351
- }
352
- return accessExpr;
496
+ const path = propertyPath.map((prop) => (typeof prop === "number" ? prop : JSON.stringify(prop))).join(", ");
497
+ return `Access<${parentType}, [${path}]>`;
353
498
  };
354
499
  for (const [parentType, types] of [...byParent.entries()].sort()) {
355
500
  lines.push(`// From ${parentType}`);
@@ -18,7 +18,7 @@ const parseIfThenElse = (schema, refs) => {
18
18
  : ${$else}.safeParse(value);
19
19
  if (!result.success) {
20
20
  const issues = result.error.issues;
21
- issues.forEach((issue) => ctx.addIssue(issue))
21
+ issues.forEach((issue) => ctx.addIssue({ ...issue }))
22
22
  }
23
23
  })`;
24
24
  // Store original if/then/else for JSON Schema round-trip
@@ -311,5 +311,8 @@ const shouldUseGetter = (parsed, refs) => {
311
311
  if (refs.currentSchemaName && parsed.includes(refs.currentSchemaName)) {
312
312
  return true;
313
313
  }
314
+ if (refs.cycleRefNames?.has(parsed)) {
315
+ return true;
316
+ }
314
317
  return Boolean(refs.inProgress && refs.inProgress.has(parsed));
315
318
  };
@@ -124,6 +124,14 @@ const parseRef = (schema, refs) => {
124
124
  // (or is currently being resolved). This avoids TDZ on true cycles while
125
125
  // letting ordered, acyclic refs stay direct.
126
126
  if (isSameCycle || refs.inProgress.has(refName)) {
127
+ const inObjectProperty = refs.path.includes("properties") ||
128
+ refs.path.includes("patternProperties") ||
129
+ refs.path.includes("additionalProperties");
130
+ if (inObjectProperty && refName === refs.currentSchemaName) {
131
+ // Getter properties defer evaluation, so a direct reference avoids extra lazies
132
+ // for self-recursion.
133
+ return refName;
134
+ }
127
135
  return `z.lazy(() => ${refName})`;
128
136
  }
129
137
  return refName;
@@ -70,6 +70,16 @@ export const emitZod = (analysis) => {
70
70
  const { module, name, type, noImport, exportRefs, withMeta, ...rest } = options;
71
71
  const declarations = new Map();
72
72
  const dependencies = new Map();
73
+ const reserveName = (base) => {
74
+ let candidate = base;
75
+ let i = 1;
76
+ while (usedNames.has(candidate) || declarations.has(candidate)) {
77
+ candidate = `${base}${i}`;
78
+ i += 1;
79
+ }
80
+ usedNames.add(candidate);
81
+ return candidate;
82
+ };
73
83
  const parsedSchema = parseSchema(schema, {
74
84
  module,
75
85
  name,
@@ -91,10 +101,16 @@ export const emitZod = (analysis) => {
91
101
  });
92
102
  const declarationBlock = declarations.size
93
103
  ? orderDeclarations(Array.from(declarations.entries()), dependencies)
94
- .map(([refName, value]) => {
104
+ .flatMap(([refName, value]) => {
95
105
  const shouldExport = exportRefs && module === "esm";
96
- const decl = `${shouldExport ? "export " : ""}const ${refName} = ${value}`;
97
- return decl;
106
+ const isCycle = cycleRefNames.has(refName);
107
+ if (!isCycle) {
108
+ return [`${shouldExport ? "export " : ""}const ${refName} = ${value}`];
109
+ }
110
+ const baseName = `${refName}Def`;
111
+ const lines = [`const ${baseName} = ${value}`];
112
+ lines.push(`${shouldExport ? "export " : ""}const ${refName} = ${baseName}`);
113
+ return lines;
98
114
  })
99
115
  .join("\n")
100
116
  : "";
@@ -8,23 +8,27 @@ export const generateSchemaBundle = (schema, options = {}) => {
8
8
  const defs = schema.$defs || {};
9
9
  const definitions = schema.definitions || {};
10
10
  const defNames = Object.keys(defs);
11
- const { rootName, rootTypeName, defInfoMap } = buildBundleContext(defNames, defs, options);
11
+ const { rootName, rootTypeName, defInfoMap, groups } = buildBundleContext(defNames, defs, options);
12
12
  const files = [];
13
- const targets = planBundleTargets(schema, defs, definitions, defNames, options, rootName, rootTypeName);
13
+ const targets = planBundleTargets(schema, defs, definitions, defNames, options, rootName, defInfoMap, rootTypeName);
14
14
  for (const target of targets) {
15
15
  const usedRefs = target.usedRefs;
16
- const analysis = analyzeSchema(target.schemaWithDefs, {
17
- ...options,
18
- module,
19
- name: target.schemaName,
20
- type: target.typeName,
21
- parserOverride: createRefHandler(target.defName, defInfoMap, usedRefs, {
22
- ...(target.schemaWithDefs.$defs || {}),
23
- ...(target.schemaWithDefs.definitions || {}),
24
- }, options),
25
- });
26
- const zodSchema = emitZod(analysis);
27
- const finalSchema = buildSchemaFile(zodSchema, usedRefs, defInfoMap, module);
16
+ const zodParts = [];
17
+ for (const member of target.members) {
18
+ const analysis = analyzeSchema(member.schemaWithDefs, {
19
+ ...options,
20
+ module,
21
+ name: member.schemaName,
22
+ type: member.typeName,
23
+ parserOverride: createRefHandler(member.defName, defInfoMap, usedRefs, {
24
+ ...(member.schemaWithDefs.$defs || {}),
25
+ ...(member.schemaWithDefs.definitions || {}),
26
+ }, options, target.groupId),
27
+ });
28
+ const zodSchema = emitZod(analysis);
29
+ zodParts.push(zodSchema);
30
+ }
31
+ const finalSchema = buildSchemaFile(zodParts, usedRefs, defInfoMap, module, target);
28
32
  files.push({ fileName: target.fileName, contents: finalSchema });
29
33
  }
30
34
  // Nested types extraction (optional)
@@ -53,13 +57,16 @@ const buildDefInfoMap = (defNames, defs, options) => {
53
57
  const pascalName = toPascalCase(defName);
54
58
  const schemaName = options.splitDefs?.schemaName?.(defName, { isRoot: false }) ?? `${pascalName}Schema`;
55
59
  const typeName = options.splitDefs?.typeName?.(defName, { isRoot: false }) ?? pascalName;
60
+ const fileName = options.splitDefs?.fileName?.(defName, { isRoot: false }) ?? `${defName}.schema.ts`;
56
61
  map.set(defName, {
57
62
  name: defName,
58
63
  pascalName,
59
64
  schemaName,
60
65
  typeName,
66
+ fileName,
61
67
  dependencies,
62
68
  hasCycle: false,
69
+ groupId: "",
63
70
  });
64
71
  }
65
72
  return map;
@@ -72,13 +79,21 @@ const buildBundleContext = (defNames, defs, options) => {
72
79
  if (info)
73
80
  info.hasCycle = true;
74
81
  }
82
+ const groups = buildSccGroups(defInfoMap);
83
+ for (const [groupId, members] of groups) {
84
+ for (const defName of members) {
85
+ const info = defInfoMap.get(defName);
86
+ if (info)
87
+ info.groupId = groupId;
88
+ }
89
+ }
75
90
  const rootName = options.splitDefs?.rootName ?? options.name ?? "RootSchema";
76
91
  const rootTypeName = typeof options.type === "string"
77
92
  ? options.type
78
93
  : options.splitDefs?.rootTypeName ?? (typeof options.type === "boolean" && options.type ? rootName : undefined);
79
- return { defInfoMap, rootName, rootTypeName };
94
+ return { defInfoMap, rootName, rootTypeName, groups };
80
95
  };
81
- const createRefHandler = (currentDefName, defInfoMap, usedRefs, allDefs, options) => {
96
+ const createRefHandler = (currentDefName, defInfoMap, usedRefs, allDefs, options, currentGroupId) => {
82
97
  const useLazyCrossRefs = options.refResolution?.lazyCrossRefs ?? true;
83
98
  return (schema, refs) => {
84
99
  if (typeof schema["$ref"] === "string") {
@@ -93,7 +108,7 @@ const createRefHandler = (currentDefName, defInfoMap, usedRefs, allDefs, options
93
108
  const refInfo = defInfoMap.get(refName);
94
109
  if (refInfo) {
95
110
  // Track imports when referencing other defs
96
- if (refName !== currentDefName) {
111
+ if (refName !== currentDefName && refInfo.groupId !== currentGroupId) {
97
112
  usedRefs.add(refName);
98
113
  }
99
114
  const isCycle = refName === currentDefName || (refInfo.hasCycle && !!currentDefName);
@@ -107,6 +122,13 @@ const createRefHandler = (currentDefName, defInfoMap, usedRefs, allDefs, options
107
122
  if (resolved)
108
123
  return resolved;
109
124
  if (isCycle && useLazyCrossRefs) {
125
+ const inObjectProperty = refs.path.includes("properties") ||
126
+ refs.path.includes("patternProperties") ||
127
+ refs.path.includes("additionalProperties");
128
+ if (inObjectProperty && refName === currentDefName) {
129
+ // Self-recursion inside object getters can safely reference the schema name
130
+ return refInfo.schemaName;
131
+ }
110
132
  return `z.lazy(() => ${refInfo.schemaName})`;
111
133
  }
112
134
  return refInfo.schemaName;
@@ -125,42 +147,73 @@ const createRefHandler = (currentDefName, defInfoMap, usedRefs, allDefs, options
125
147
  return undefined;
126
148
  };
127
149
  };
128
- const buildSchemaFile = (zodCode, usedRefs, defInfoMap, module) => {
150
+ const buildSchemaFile = (zodCodeParts, usedRefs, defInfoMap, module, target) => {
129
151
  if (module !== "esm")
130
- return zodCode;
131
- const imports = [];
152
+ return zodCodeParts.join("\n");
153
+ const groupFileById = new Map();
154
+ for (const info of defInfoMap.values()) {
155
+ if (!groupFileById.has(info.groupId)) {
156
+ groupFileById.set(info.groupId, info.fileName.replace(/\.ts$/, ".js"));
157
+ }
158
+ }
159
+ const importsByFile = new Map();
132
160
  for (const refName of [...usedRefs].sort()) {
133
161
  const refInfo = defInfoMap.get(refName);
134
162
  if (refInfo) {
135
- imports.push(`import { ${refInfo.schemaName} } from './${refName}.schema.js';`);
163
+ const groupFile = groupFileById.get(refInfo.groupId) ?? refInfo.fileName.replace(/\.ts$/, ".js");
164
+ const path = `./${groupFile}`;
165
+ const set = importsByFile.get(path) ?? new Set();
166
+ set.add(refInfo.schemaName);
167
+ importsByFile.set(path, set);
136
168
  }
137
169
  }
138
- if (!imports.length)
139
- return zodCode;
140
- return zodCode.replace('import { z } from "zod"', `import { z } from "zod"\n${imports.join("\n")}`);
170
+ const imports = [];
171
+ for (const [path, names] of [...importsByFile.entries()].sort(([a], [b]) => a.localeCompare(b))) {
172
+ imports.push(`import { ${[...names].sort().join(", ")} } from '${path}';`);
173
+ }
174
+ const body = zodCodeParts
175
+ .map((code, idx) => {
176
+ if (idx === 0)
177
+ return code;
178
+ return code.replace(/^import \{ z \} from "zod"\n?/, "");
179
+ })
180
+ .join("\n");
181
+ return imports.length
182
+ ? body.replace('import { z } from "zod"', `import { z } from "zod"\n${imports.join("\n")}`)
183
+ : body;
141
184
  };
142
- const planBundleTargets = (rootSchema, defs, definitions, defNames, options, rootName, rootTypeName) => {
185
+ const planBundleTargets = (rootSchema, defs, definitions, defNames, options, rootName, defInfoMap, rootTypeName) => {
143
186
  const targets = [];
187
+ const groupById = new Map();
144
188
  for (const defName of defNames) {
145
- const defSchema = defs[defName];
146
- const defSchemaWithDefs = {
147
- ...defSchema,
148
- $defs: { ...defs, ...defSchema?.$defs },
149
- definitions: {
150
- ...defSchema.definitions,
151
- ...definitions,
152
- },
153
- };
154
- const pascalName = toPascalCase(defName);
155
- const schemaName = options.splitDefs?.schemaName?.(defName, { isRoot: false }) ?? `${pascalName}Schema`;
156
- const typeName = options.splitDefs?.typeName?.(defName, { isRoot: false }) ?? pascalName;
157
- const fileName = options.splitDefs?.fileName?.(defName, { isRoot: false }) ?? `${defName}.schema.ts`;
189
+ const info = defInfoMap.get(defName);
190
+ const gid = info?.groupId || defName;
191
+ if (!groupById.has(gid))
192
+ groupById.set(gid, []);
193
+ groupById.get(gid).push(defName);
194
+ }
195
+ for (const [groupId, memberDefs] of groupById.entries()) {
196
+ const orderedDefs = orderGroupMembers(memberDefs, defInfoMap);
197
+ const members = orderedDefs.map((defName) => {
198
+ const defSchema = defs[defName];
199
+ const defSchemaWithDefs = {
200
+ ...defSchema,
201
+ $defs: { ...defs, ...defSchema?.$defs },
202
+ definitions: {
203
+ ...defSchema.definitions,
204
+ ...definitions,
205
+ },
206
+ };
207
+ const pascalName = toPascalCase(defName);
208
+ const schemaName = options.splitDefs?.schemaName?.(defName, { isRoot: false }) ?? `${pascalName}Schema`;
209
+ const typeName = options.splitDefs?.typeName?.(defName, { isRoot: false }) ?? pascalName;
210
+ return { defName, schemaWithDefs: defSchemaWithDefs, schemaName, typeName };
211
+ });
212
+ const fileName = defInfoMap.get(memberDefs[0])?.fileName ?? `${memberDefs[0]}.schema.ts`;
158
213
  targets.push({
159
- defName,
160
- schemaWithDefs: defSchemaWithDefs,
161
- schemaName,
162
- typeName,
214
+ groupId,
163
215
  fileName,
216
+ members,
164
217
  usedRefs: new Set(),
165
218
  isRoot: false,
166
219
  });
@@ -168,17 +221,22 @@ const planBundleTargets = (rootSchema, defs, definitions, defNames, options, roo
168
221
  if (options.splitDefs?.includeRoot ?? true) {
169
222
  const rootFile = options.splitDefs?.fileName?.("root", { isRoot: true }) ?? "workflow.schema.ts";
170
223
  targets.push({
171
- defName: null,
172
- schemaWithDefs: {
173
- ...rootSchema,
174
- definitions: {
175
- ...rootSchema.definitions,
176
- ...definitions,
177
- },
178
- },
179
- schemaName: rootName,
180
- typeName: rootTypeName,
224
+ groupId: "root",
181
225
  fileName: rootFile,
226
+ members: [
227
+ {
228
+ defName: null,
229
+ schemaWithDefs: {
230
+ ...rootSchema,
231
+ definitions: {
232
+ ...rootSchema.definitions,
233
+ ...definitions,
234
+ },
235
+ },
236
+ schemaName: rootName,
237
+ typeName: rootTypeName,
238
+ },
239
+ ],
182
240
  usedRefs: new Set(),
183
241
  isRoot: true,
184
242
  });
@@ -209,6 +267,35 @@ const findRefDependencies = (schema, validDefNames) => {
209
267
  traverse(schema);
210
268
  return deps;
211
269
  };
270
+ const orderGroupMembers = (defs, defInfoMap) => {
271
+ const inGroup = new Set(defs);
272
+ const visited = new Set();
273
+ const temp = new Set();
274
+ const result = [];
275
+ const visit = (name) => {
276
+ if (visited.has(name))
277
+ return;
278
+ if (temp.has(name)) {
279
+ return;
280
+ }
281
+ temp.add(name);
282
+ const info = defInfoMap.get(name);
283
+ if (info) {
284
+ for (const dep of info.dependencies) {
285
+ if (inGroup.has(dep)) {
286
+ visit(dep);
287
+ }
288
+ }
289
+ }
290
+ temp.delete(name);
291
+ visited.add(name);
292
+ result.push(name);
293
+ };
294
+ for (const name of defs) {
295
+ visit(name);
296
+ }
297
+ return result;
298
+ };
212
299
  const detectCycles = (defInfoMap) => {
213
300
  const cycleNodes = new Set();
214
301
  const visited = new Set();
@@ -242,6 +329,52 @@ const detectCycles = (defInfoMap) => {
242
329
  }
243
330
  return cycleNodes;
244
331
  };
332
+ const buildSccGroups = (defInfoMap) => {
333
+ const indexMap = new Map();
334
+ const lowLink = new Map();
335
+ const onStack = new Set();
336
+ const stack = [];
337
+ let index = 0;
338
+ const groups = new Map();
339
+ const strongConnect = (node) => {
340
+ indexMap.set(node, index);
341
+ lowLink.set(node, index);
342
+ index += 1;
343
+ stack.push(node);
344
+ onStack.add(node);
345
+ const info = defInfoMap.get(node);
346
+ if (info) {
347
+ for (const dep of info.dependencies) {
348
+ if (!indexMap.has(dep)) {
349
+ strongConnect(dep);
350
+ lowLink.set(node, Math.min(lowLink.get(node), lowLink.get(dep)));
351
+ }
352
+ else if (onStack.has(dep)) {
353
+ lowLink.set(node, Math.min(lowLink.get(node), indexMap.get(dep)));
354
+ }
355
+ }
356
+ }
357
+ if (lowLink.get(node) === indexMap.get(node)) {
358
+ const members = [];
359
+ let w;
360
+ do {
361
+ w = stack.pop();
362
+ if (w) {
363
+ onStack.delete(w);
364
+ members.push(w);
365
+ }
366
+ } while (w && w !== node);
367
+ const groupId = members.sort().join("__");
368
+ groups.set(groupId, members);
369
+ }
370
+ };
371
+ for (const name of defInfoMap.keys()) {
372
+ if (!indexMap.has(name)) {
373
+ strongConnect(name);
374
+ }
375
+ }
376
+ return groups;
377
+ };
245
378
  const collectNestedTypes = (rootSchema, defs, defNames, rootTypeName) => {
246
379
  const allNestedTypes = [];
247
380
  for (const defName of defNames) {
@@ -317,6 +450,26 @@ const generateNestedTypesFile = (nestedTypes) => {
317
450
  " * They are extracted using TypeScript indexed access types.",
318
451
  " */",
319
452
  "",
453
+ "type Access<T, P extends readonly (string | number)[]> =",
454
+ " P extends []",
455
+ " ? NonNullable<T>",
456
+ " : P extends readonly [infer H, ...infer R]",
457
+ " ? H extends \"items\"",
458
+ " ? Access<NonNullable<T> extends Array<infer U> ? U : unknown, Extract<R, (string | number)[]>>",
459
+ " : H extends \"additionalProperties\"",
460
+ " ? Access<NonNullable<T> extends Record<string, infer V> ? V : unknown, Extract<R, (string | number)[]>>",
461
+ " : H extends number",
462
+ " ? Access<NonNullable<T> extends Array<infer U> ? U : unknown, Extract<R, (string | number)[]>>",
463
+ " : H extends string",
464
+ " ? Access<",
465
+ " H extends keyof NonNullable<T>",
466
+ " ? NonNullable<NonNullable<T>[H]>",
467
+ " : unknown,",
468
+ " Extract<R, (string | number)[]>",
469
+ " >",
470
+ " : unknown",
471
+ " : unknown;",
472
+ "",
320
473
  ];
321
474
  const byParent = new Map();
322
475
  for (const info of nestedTypes) {
@@ -336,16 +489,8 @@ const generateNestedTypesFile = (nestedTypes) => {
336
489
  }
337
490
  lines.push("");
338
491
  const buildAccessExpr = (parentType, propertyPath) => {
339
- let accessExpr = parentType;
340
- for (const prop of propertyPath) {
341
- const accessor = prop === "items"
342
- ? "[number]"
343
- : typeof prop === "number"
344
- ? `[${prop}]`
345
- : `[${JSON.stringify(prop)}]`;
346
- accessExpr = `NonNullable<${accessExpr}${accessor}>`;
347
- }
348
- return accessExpr;
492
+ const path = propertyPath.map((prop) => (typeof prop === "number" ? prop : JSON.stringify(prop))).join(", ");
493
+ return `Access<${parentType}, [${path}]>`;
349
494
  };
350
495
  for (const [parentType, types] of [...byParent.entries()].sort()) {
351
496
  lines.push(`// From ${parentType}`);
@@ -15,7 +15,7 @@ export const parseIfThenElse = (schema, refs) => {
15
15
  : ${$else}.safeParse(value);
16
16
  if (!result.success) {
17
17
  const issues = result.error.issues;
18
- issues.forEach((issue) => ctx.addIssue(issue))
18
+ issues.forEach((issue) => ctx.addIssue({ ...issue }))
19
19
  }
20
20
  })`;
21
21
  // Store original if/then/else for JSON Schema round-trip
@@ -308,5 +308,8 @@ const shouldUseGetter = (parsed, refs) => {
308
308
  if (refs.currentSchemaName && parsed.includes(refs.currentSchemaName)) {
309
309
  return true;
310
310
  }
311
+ if (refs.cycleRefNames?.has(parsed)) {
312
+ return true;
313
+ }
311
314
  return Boolean(refs.inProgress && refs.inProgress.has(parsed));
312
315
  };
@@ -120,6 +120,14 @@ const parseRef = (schema, refs) => {
120
120
  // (or is currently being resolved). This avoids TDZ on true cycles while
121
121
  // letting ordered, acyclic refs stay direct.
122
122
  if (isSameCycle || refs.inProgress.has(refName)) {
123
+ const inObjectProperty = refs.path.includes("properties") ||
124
+ refs.path.includes("patternProperties") ||
125
+ refs.path.includes("additionalProperties");
126
+ if (inObjectProperty && refName === refs.currentSchemaName) {
127
+ // Getter properties defer evaluation, so a direct reference avoids extra lazies
128
+ // for self-recursion.
129
+ return refName;
130
+ }
123
131
  return `z.lazy(() => ${refName})`;
124
132
  }
125
133
  return refName;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gabrielbryk/json-schema-to-zod",
3
- "version": "2.10.0",
3
+ "version": "2.10.1",
4
4
  "description": "Converts JSON schema objects or files into Zod schemas",
5
5
  "type": "module",
6
6
  "types": "./dist/types/index.d.ts",