@gabrielbryk/json-schema-to-zod 2.12.0 → 2.13.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 (53) hide show
  1. package/.github/RELEASE_SETUP.md +120 -0
  2. package/.github/TOOLING_GUIDE.md +169 -0
  3. package/.github/dependabot.yml +52 -0
  4. package/.github/workflows/ci.yml +33 -0
  5. package/.github/workflows/release.yml +12 -4
  6. package/.github/workflows/security.yml +40 -0
  7. package/.husky/commit-msg +1 -0
  8. package/.husky/pre-commit +1 -0
  9. package/.lintstagedrc.json +3 -0
  10. package/.prettierrc +20 -0
  11. package/AGENTS.md +7 -0
  12. package/CHANGELOG.md +13 -4
  13. package/README.md +9 -9
  14. package/commitlint.config.js +24 -0
  15. package/createIndex.ts +4 -4
  16. package/dist/cli.js +3 -4
  17. package/dist/core/analyzeSchema.js +28 -5
  18. package/dist/core/emitZod.js +11 -4
  19. package/dist/generators/generateBundle.js +67 -92
  20. package/dist/parsers/parseAllOf.js +11 -12
  21. package/dist/parsers/parseAnyOf.js +2 -2
  22. package/dist/parsers/parseArray.js +38 -12
  23. package/dist/parsers/parseMultipleType.js +2 -2
  24. package/dist/parsers/parseNumber.js +44 -102
  25. package/dist/parsers/parseObject.js +138 -393
  26. package/dist/parsers/parseOneOf.js +57 -100
  27. package/dist/parsers/parseSchema.js +132 -55
  28. package/dist/parsers/parseSimpleDiscriminatedOneOf.js +2 -2
  29. package/dist/parsers/parseString.js +113 -253
  30. package/dist/types/Types.d.ts +22 -1
  31. package/dist/types/core/analyzeSchema.d.ts +1 -0
  32. package/dist/types/generators/generateBundle.d.ts +1 -1
  33. package/dist/utils/cliTools.js +1 -2
  34. package/dist/utils/esmEmitter.js +6 -2
  35. package/dist/utils/extractInlineObject.js +1 -3
  36. package/dist/utils/jsdocs.js +1 -4
  37. package/dist/utils/liftInlineObjects.js +76 -15
  38. package/dist/utils/resolveRef.js +35 -10
  39. package/dist/utils/schemaRepresentation.js +35 -66
  40. package/dist/zodToJsonSchema.js +1 -2
  41. package/docs/IMPROVEMENT-PLAN.md +30 -12
  42. package/docs/ZOD-V4-RECURSIVE-TYPE-LIMITATIONS.md +70 -25
  43. package/docs/proposals/allof-required-merging.md +10 -4
  44. package/docs/proposals/bundle-refactor.md +10 -4
  45. package/docs/proposals/discriminated-union-with-default.md +18 -14
  46. package/docs/proposals/inline-object-lifting.md +15 -5
  47. package/docs/proposals/ref-anchor-support.md +11 -0
  48. package/output.txt +67 -0
  49. package/package.json +18 -5
  50. package/scripts/generateWorkflowSchema.ts +5 -14
  51. package/scripts/regenerate_bundle.ts +25 -0
  52. package/tsc_output.txt +542 -0
  53. package/tsc_output_2.txt +489 -0
@@ -122,7 +122,12 @@ const deepTransform = (obj, ctx, forceInDefs) => {
122
122
  if (clone.$defs && typeof clone.$defs === "object" && ctx.allowInDefs) {
123
123
  const defsObj = clone.$defs;
124
124
  for (const [key, value] of Object.entries(defsObj)) {
125
- const visited = visit(value, { ...ctx, path: [...ctx.path, "$defs", key], inDefs: true, context: "root" });
125
+ const visited = visit(value, {
126
+ ...ctx,
127
+ path: [...ctx.path, "$defs", key],
128
+ inDefs: true,
129
+ context: "root",
130
+ });
126
131
  ctx.defs[key] = visited;
127
132
  }
128
133
  clone.$defs = ctx.defs;
@@ -131,7 +136,12 @@ const deepTransform = (obj, ctx, forceInDefs) => {
131
136
  if (clone.patternProperties && typeof clone.patternProperties === "object") {
132
137
  const newPatterns = {};
133
138
  for (const [key, value] of Object.entries(clone.patternProperties)) {
134
- newPatterns[key] = visit(value, { ...ctx, path: [...ctx.path, key], inDefs: nextInDefs, context: "patternProperties" });
139
+ newPatterns[key] = visit(value, {
140
+ ...ctx,
141
+ path: [...ctx.path, key],
142
+ inDefs: nextInDefs,
143
+ context: "patternProperties",
144
+ });
135
145
  }
136
146
  clone.patternProperties = newPatterns;
137
147
  }
@@ -146,7 +156,12 @@ const deepTransform = (obj, ctx, forceInDefs) => {
146
156
  }
147
157
  // items / additionalItems
148
158
  if (clone.items) {
149
- clone.items = visit(clone.items, { ...ctx, path: [...ctx.path, "items"], inDefs: nextInDefs, context: "items" });
159
+ clone.items = visit(clone.items, {
160
+ ...ctx,
161
+ path: [...ctx.path, "items"],
162
+ inDefs: nextInDefs,
163
+ context: "items",
164
+ });
150
165
  }
151
166
  if (clone.additionalItems) {
152
167
  clone.additionalItems = visit(clone.additionalItems, {
@@ -159,13 +174,30 @@ const deepTransform = (obj, ctx, forceInDefs) => {
159
174
  // compositions
160
175
  for (const keyword of ["allOf", "anyOf", "oneOf"]) {
161
176
  if (Array.isArray(clone[keyword])) {
162
- clone[keyword] = clone[keyword].map((entry, index) => visit(entry, { ...ctx, path: [...ctx.path, keyword, index], inDefs: nextInDefs, context: keyword }));
177
+ clone[keyword] = clone[keyword].map((entry, index) => visit(entry, {
178
+ ...ctx,
179
+ path: [...ctx.path, keyword, index],
180
+ inDefs: nextInDefs,
181
+ context: keyword,
182
+ }));
163
183
  }
164
184
  }
165
185
  // conditionals
166
- for (const keyword of ["if", "then", "else", "not", "contains", "unevaluatedProperties"]) {
186
+ for (const keyword of [
187
+ "if",
188
+ "then",
189
+ "else",
190
+ "not",
191
+ "contains",
192
+ "unevaluatedProperties",
193
+ ]) {
167
194
  if (clone[keyword]) {
168
- clone[keyword] = visit(clone[keyword], { ...ctx, path: [...ctx.path, keyword], inDefs: nextInDefs, context: keyword });
195
+ clone[keyword] = visit(clone[keyword], {
196
+ ...ctx,
197
+ path: [...ctx.path, keyword],
198
+ inDefs: nextInDefs,
199
+ context: keyword,
200
+ });
169
201
  }
170
202
  }
171
203
  // dependentSchemas
@@ -184,7 +216,9 @@ const deepTransform = (obj, ctx, forceInDefs) => {
184
216
  return clone;
185
217
  };
186
218
  const getDefs = (schema) => {
187
- if (typeof schema === "object" && schema !== null && typeof schema.$defs === "object") {
219
+ if (typeof schema === "object" &&
220
+ schema !== null &&
221
+ typeof schema.$defs === "object") {
188
222
  return { ...schema.$defs };
189
223
  }
190
224
  return {};
@@ -192,7 +226,11 @@ const getDefs = (schema) => {
192
226
  const isObjectSchema = (schema) => {
193
227
  if (schema.type === "object")
194
228
  return true;
195
- return Boolean(schema.properties || schema.patternProperties || schema.additionalProperties || schema.required || schema.unevaluatedProperties);
229
+ return Boolean(schema.properties ||
230
+ schema.patternProperties ||
231
+ schema.additionalProperties ||
232
+ schema.required ||
233
+ schema.unevaluatedProperties);
196
234
  };
197
235
  const isMetaOnly = (schema) => {
198
236
  const keys = Object.keys(schema);
@@ -200,7 +238,11 @@ const isMetaOnly = (schema) => {
200
238
  };
201
239
  const isRecursiveRef = (schema, ctx) => {
202
240
  // Only guard when refs are present on the schema itself
203
- const ref = typeof schema.$ref === "string" ? schema.$ref : typeof schema.$dynamicRef === "string" ? schema.$dynamicRef : null;
241
+ const ref = typeof schema.$ref === "string"
242
+ ? schema.$ref
243
+ : typeof schema.$dynamicRef === "string"
244
+ ? schema.$dynamicRef
245
+ : null;
204
246
  if (!ref)
205
247
  return false;
206
248
  const resolved = resolveRef(schema, ref, {
@@ -244,7 +286,9 @@ const extractCallConst = (ctx) => {
244
286
  return undefined;
245
287
  const parentPath = ctx.path.slice(0, -1);
246
288
  const parentNode = getAtPath(ctx.rootSchema, parentPath);
247
- if (parentNode && typeof parentNode === "object" && parentNode.properties) {
289
+ if (parentNode &&
290
+ typeof parentNode === "object" &&
291
+ parentNode.properties) {
248
292
  const props = parentNode.properties;
249
293
  const callProp = props["call"];
250
294
  if (callProp && typeof callProp === "object" && callProp.const) {
@@ -301,7 +345,11 @@ const computeCyclicPaths = (schema, refRegistry, rootBaseUri) => {
301
345
  nodes.add(pathStr);
302
346
  nodes.add(ownerStr);
303
347
  const nextBase = typeof obj.$id === "string" ? resolveUri(baseUri, obj.$id) : baseUri;
304
- const ref = typeof obj.$ref === "string" ? obj.$ref : typeof obj.$dynamicRef === "string" ? obj.$dynamicRef : obj.$recursiveRef;
348
+ const ref = typeof obj.$ref === "string"
349
+ ? obj.$ref
350
+ : typeof obj.$dynamicRef === "string"
351
+ ? obj.$dynamicRef
352
+ : obj.$recursiveRef;
305
353
  if (typeof ref === "string") {
306
354
  const resolved = resolveRef(obj, ref, {
307
355
  path,
@@ -432,9 +480,18 @@ const subtreeHasCycle = (node, ctx, pathPrefix) => {
432
480
  // When a schema is reached through these keywords and participates in a cycle,
433
481
  // it will generate z.lazy() which causes type annotation issues when lifted
434
482
  const structuralKeywords = new Set([
435
- "additionalProperties", "items", "additionalItems", "contains",
436
- "unevaluatedProperties", "not", "if", "then", "else",
437
- "allOf", "anyOf", "oneOf"
483
+ "additionalProperties",
484
+ "items",
485
+ "additionalItems",
486
+ "contains",
487
+ "unevaluatedProperties",
488
+ "not",
489
+ "if",
490
+ "then",
491
+ "else",
492
+ "allOf",
493
+ "anyOf",
494
+ "oneOf",
438
495
  ]);
439
496
  const rootPathStr = normalizePath(pathPrefix);
440
497
  const walk = (value, path, parentContext) => {
@@ -443,7 +500,11 @@ const subtreeHasCycle = (node, ctx, pathPrefix) => {
443
500
  const obj = value;
444
501
  // Check if this node has a $ref that points back to the root or an ancestor
445
502
  // This would create self-referential recursion which blocks lifting
446
- const ref = typeof obj.$ref === "string" ? obj.$ref : typeof obj.$dynamicRef === "string" ? obj.$dynamicRef : null;
503
+ const ref = typeof obj.$ref === "string"
504
+ ? obj.$ref
505
+ : typeof obj.$dynamicRef === "string"
506
+ ? obj.$dynamicRef
507
+ : null;
447
508
  if (ref) {
448
509
  // Resolve the ref to see if it points to root or an ancestor
449
510
  if (ref.startsWith("#/")) {
@@ -22,7 +22,12 @@ export const resolveRef = (schemaNode, ref, refs) => {
22
22
  const key = `${entry.uri}#${name}`;
23
23
  const target = refs.refRegistry?.get(key);
24
24
  if (target) {
25
- return { schema: target.schema, path: target.path, baseUri: target.baseUri, pointerKey: key };
25
+ return {
26
+ schema: target.schema,
27
+ path: target.path,
28
+ baseUri: target.baseUri,
29
+ pointerKey: key,
30
+ };
26
31
  }
27
32
  }
28
33
  }
@@ -33,7 +38,12 @@ export const resolveRef = (schemaNode, ref, refs) => {
33
38
  const key = fragment ? `${uriBase}#${fragment}` : uriBase;
34
39
  let regEntry = refs.refRegistry?.get(key);
35
40
  if (regEntry) {
36
- return { schema: regEntry.schema, path: regEntry.path, baseUri: regEntry.baseUri, pointerKey: key };
41
+ return {
42
+ schema: regEntry.schema,
43
+ path: regEntry.path,
44
+ baseUri: regEntry.baseUri,
45
+ pointerKey: key,
46
+ };
37
47
  }
38
48
  // Legacy recursive ref: treat as dynamic to __recursive__
39
49
  if (schemaNode.$recursiveRef) {
@@ -51,13 +61,12 @@ export const resolveRef = (schemaNode, ref, refs) => {
51
61
  // External resolver hook
52
62
  const extBase = uriBaseFromRef(resolvedUri);
53
63
  if (refs.resolveExternalRef && extBase && !isLocalBase(extBase, refs.rootBaseUri ?? "")) {
64
+ // console.log("ATTEMPTING EXTERNAL RESOLUTION FOR", ref, "AT BASE", extBase);
54
65
  const loaded = refs.resolveExternalRef(extBase);
55
66
  if (loaded) {
56
67
  // If async resolver is used synchronously here, it will be ignored; keep simple sync for now
57
68
  const maybePromise = loaded;
58
- const schema = typeof maybePromise.then === "function"
59
- ? undefined
60
- : loaded;
69
+ const schema = typeof maybePromise.then === "function" ? undefined : loaded;
61
70
  if (schema) {
62
71
  const { registry } = buildRefRegistry(schema, extBase);
63
72
  registry.forEach((entry, k) => refs.refRegistry?.set(k, entry));
@@ -80,13 +89,29 @@ export const resolveRef = (schemaNode, ref, refs) => {
80
89
  .split("/")
81
90
  .filter((segment) => segment.length > 0)
82
91
  .map(decodePointerSegment);
83
- let current = refs.root;
84
- for (const segment of rawSegments) {
85
- if (typeof current !== "object" || current === null)
92
+ const tryResolve = (rootNode) => {
93
+ if (!rootNode)
86
94
  return undefined;
87
- current = current[segment];
95
+ let current = rootNode;
96
+ for (const segment of rawSegments) {
97
+ const record = current;
98
+ if (!record || typeof record !== "object") {
99
+ return undefined;
100
+ }
101
+ current = record[segment];
102
+ if (current === undefined)
103
+ return undefined;
104
+ }
105
+ return current;
106
+ };
107
+ let resolved = tryResolve(refs.root);
108
+ if (resolved === undefined && refs.documentRoot && refs.documentRoot !== refs.root) {
109
+ resolved = tryResolve(refs.documentRoot);
110
+ }
111
+ if (resolved !== undefined) {
112
+ return { schema: resolved, path: rawSegments, baseUri: base, pointerKey: ref };
88
113
  }
89
- return { schema: current, path: rawSegments, baseUri: base, pointerKey: ref };
114
+ return undefined;
90
115
  }
91
116
  return undefined;
92
117
  };
@@ -182,7 +182,7 @@ export const zodObject = (shape) => {
182
182
  export const zodStrictObject = (shape) => {
183
183
  const base = zodObject(shape);
184
184
  return {
185
- expression: `${base.expression}.strict()`,
185
+ expression: base.expression.replace(/^z\.object\(/, "z.strictObject("),
186
186
  type: base.type, // strict() doesn't change the type signature
187
187
  };
188
188
  };
@@ -245,31 +245,31 @@ export const fromExpression = (expression) => ({
245
245
  * This is used for backward compatibility during migration.
246
246
  */
247
247
  export const inferTypeFromExpression = (expr) => {
248
- // Handle z.lazy with explicit type (possibly with method chains like .optional())
249
- const lazyTypedMatch = expr.match(/^z\.lazy<([^>]+)>\(\s*\(\)\s*=>\s*([A-Za-z0-9_.$]+)\s*\)(\.[a-z]+\(\))*$/);
250
- if (lazyTypedMatch) {
251
- let type = `z.ZodLazy<${lazyTypedMatch[1]}>`;
252
- const methods = lazyTypedMatch[3] || "";
253
- if (methods.includes(".optional()")) {
248
+ const applyOptionality = (type, methods) => {
249
+ if (methods.includes(".exactOptional()")) {
250
+ type = `z.ZodExactOptional<${type}>`;
251
+ }
252
+ else if (methods.includes(".optional()")) {
254
253
  type = `z.ZodOptional<${type}>`;
255
254
  }
256
255
  if (methods.includes(".nullable()")) {
257
256
  type = `z.ZodNullable<${type}>`;
258
257
  }
259
258
  return type;
259
+ };
260
+ // Handle z.lazy with explicit type (possibly with method chains like .optional())
261
+ const lazyTypedMatch = expr.match(/^z\.lazy<([^>]+)>\(\s*\(\)\s*=>\s*([A-Za-z0-9_.$]+)\s*\)(\.[a-z]+\(\))*$/);
262
+ if (lazyTypedMatch) {
263
+ let type = `z.ZodLazy<${lazyTypedMatch[1]}>`;
264
+ const methods = lazyTypedMatch[3] || "";
265
+ return applyOptionality(type, methods);
260
266
  }
261
267
  // Handle z.lazy without explicit type (possibly with method chains like .optional())
262
268
  const lazyMatch = expr.match(/^z\.lazy\(\s*\(\)\s*=>\s*([A-Za-z0-9_.$]+)\s*\)(\.[a-z]+\(\))*$/);
263
269
  if (lazyMatch) {
264
270
  let type = `z.ZodLazy<typeof ${lazyMatch[1]}>`;
265
271
  const methods = lazyMatch[2] || "";
266
- if (methods.includes(".optional()")) {
267
- type = `z.ZodOptional<${type}>`;
268
- }
269
- if (methods.includes(".nullable()")) {
270
- type = `z.ZodNullable<${type}>`;
271
- }
272
- return type;
272
+ return applyOptionality(type, methods);
273
273
  }
274
274
  // Handle .and() method chains - this creates an intersection type
275
275
  // Need to find the .and( that's not inside nested parentheses
@@ -286,13 +286,7 @@ export const inferTypeFromExpression = (expr) => {
286
286
  const andType = inferTypeFromExpression(andArg);
287
287
  let type = `z.ZodIntersection<${baseType}, ${andType}>`;
288
288
  // Handle trailing methods
289
- if (remainder.includes(".optional()")) {
290
- type = `z.ZodOptional<${type}>`;
291
- }
292
- if (remainder.includes(".nullable()")) {
293
- type = `z.ZodNullable<${type}>`;
294
- }
295
- return type;
289
+ return applyOptionality(type, remainder);
296
290
  }
297
291
  }
298
292
  // Handle z.intersection(X, Y)
@@ -312,26 +306,19 @@ export const inferTypeFromExpression = (expr) => {
312
306
  }
313
307
  }
314
308
  }
315
- // Handle z.object({...}) - for objects with getters or complex shapes
316
- if (expr.startsWith("z.object(")) {
309
+ // Handle z.object({...})/z.strictObject({...})/z.looseObject({...})
310
+ const objectPrefixes = ["z.object(", "z.strictObject(", "z.looseObject("];
311
+ const objectPrefix = objectPrefixes.find((prefix) => expr.startsWith(prefix));
312
+ if (objectPrefix) {
317
313
  // Find the end of z.object({...})
318
- const argsStart = 9; // length of "z.object("
314
+ const argsStart = objectPrefix.length; // length of prefix
319
315
  const argsEnd = findMatchingParen(expr, argsStart - 1);
320
316
  if (argsEnd !== -1) {
321
317
  const remainder = expr.substring(argsEnd + 1);
322
318
  // Base type for any z.object
323
319
  let type = "z.ZodObject<Record<string, z.ZodTypeAny>>";
324
320
  // Handle method chains after z.object({...})
325
- if (remainder.includes(".strict()")) {
326
- // .strict() doesn't change the type
327
- }
328
- if (remainder.includes(".optional()")) {
329
- type = `z.ZodOptional<${type}>`;
330
- }
331
- if (remainder.includes(".nullable()")) {
332
- type = `z.ZodNullable<${type}>`;
333
- }
334
- return type;
321
+ return applyOptionality(type, remainder);
335
322
  }
336
323
  }
337
324
  // Handle z.record(K, V)
@@ -377,13 +364,7 @@ export const inferTypeFromExpression = (expr) => {
377
364
  const baseName = refMatch[1];
378
365
  const methods = refMatch[2] || "";
379
366
  let type = `typeof ${baseName}`;
380
- if (methods.includes(".optional()")) {
381
- type = `z.ZodOptional<${type}>`;
382
- }
383
- if (methods.includes(".nullable()")) {
384
- type = `z.ZodNullable<${type}>`;
385
- }
386
- return type;
367
+ return applyOptionality(type, methods);
387
368
  }
388
369
  // Handle z.array(X)
389
370
  const arrayMatch = expr.match(/^z\.array\((.+)\)(\.[a-z]+\(\))*$/);
@@ -391,13 +372,7 @@ export const inferTypeFromExpression = (expr) => {
391
372
  const innerType = inferTypeFromExpression(arrayMatch[1]);
392
373
  let type = `z.ZodArray<${innerType}>`;
393
374
  const methods = arrayMatch[2] || "";
394
- if (methods.includes(".optional()")) {
395
- type = `z.ZodOptional<${type}>`;
396
- }
397
- if (methods.includes(".nullable()")) {
398
- type = `z.ZodNullable<${type}>`;
399
- }
400
- return type;
375
+ return applyOptionality(type, methods);
401
376
  }
402
377
  // Handle z.nullable(X)
403
378
  const nullableMatch = expr.match(/^z\.nullable\((.+)\)$/);
@@ -413,16 +388,10 @@ export const inferTypeFromExpression = (expr) => {
413
388
  if (bracketEnd !== -1) {
414
389
  const arrayContent = expr.substring(bracketStart + 1, bracketEnd); // inside the []
415
390
  const memberTypes = parseTopLevelArrayElements(arrayContent);
416
- const types = memberTypes.map(m => inferTypeFromExpression(m.trim()));
391
+ const types = memberTypes.map((m) => inferTypeFromExpression(m.trim()));
417
392
  let baseType = `z.ZodUnion<readonly [${types.join(", ")}]>`;
418
393
  const remainder = expr.substring(bracketEnd + 2); // skip ] and )
419
- if (remainder.includes(".optional()")) {
420
- baseType = `z.ZodOptional<${baseType}>`;
421
- }
422
- if (remainder.includes(".nullable()")) {
423
- baseType = `z.ZodNullable<${baseType}>`;
424
- }
425
- return baseType;
394
+ return applyOptionality(baseType, remainder);
426
395
  }
427
396
  }
428
397
  // Handle z.discriminatedUnion(...) - Zod v4 uses readonly arrays
@@ -445,10 +414,10 @@ export const inferTypeFromExpression = (expr) => {
445
414
  const findTopLevelMethod = (expr, method) => {
446
415
  let depth = 0;
447
416
  for (let i = 0; i < expr.length - method.length; i++) {
448
- if (expr[i] === '(' || expr[i] === '[' || expr[i] === '{') {
417
+ if (expr[i] === "(" || expr[i] === "[" || expr[i] === "{") {
449
418
  depth++;
450
419
  }
451
- else if (expr[i] === ')' || expr[i] === ']' || expr[i] === '}') {
420
+ else if (expr[i] === ")" || expr[i] === "]" || expr[i] === "}") {
452
421
  depth--;
453
422
  }
454
423
  else if (depth === 0 && expr.substring(i, i + method.length) === method) {
@@ -463,10 +432,10 @@ const findTopLevelMethod = (expr, method) => {
463
432
  const findMatchingParen = (expr, openIndex) => {
464
433
  let depth = 0;
465
434
  for (let i = openIndex; i < expr.length; i++) {
466
- if (expr[i] === '(' || expr[i] === '[' || expr[i] === '{') {
435
+ if (expr[i] === "(" || expr[i] === "[" || expr[i] === "{") {
467
436
  depth++;
468
437
  }
469
- else if (expr[i] === ')' || expr[i] === ']' || expr[i] === '}') {
438
+ else if (expr[i] === ")" || expr[i] === "]" || expr[i] === "}") {
470
439
  depth--;
471
440
  if (depth === 0) {
472
441
  return i;
@@ -481,13 +450,13 @@ const findMatchingParen = (expr, openIndex) => {
481
450
  const findTopLevelComma = (expr) => {
482
451
  let depth = 0;
483
452
  for (let i = 0; i < expr.length; i++) {
484
- if (expr[i] === '(' || expr[i] === '[' || expr[i] === '{') {
453
+ if (expr[i] === "(" || expr[i] === "[" || expr[i] === "{") {
485
454
  depth++;
486
455
  }
487
- else if (expr[i] === ')' || expr[i] === ']' || expr[i] === '}') {
456
+ else if (expr[i] === ")" || expr[i] === "]" || expr[i] === "}") {
488
457
  depth--;
489
458
  }
490
- else if (depth === 0 && expr[i] === ',') {
459
+ else if (depth === 0 && expr[i] === ",") {
491
460
  return i;
492
461
  }
493
462
  }
@@ -502,15 +471,15 @@ const parseTopLevelArrayElements = (content) => {
502
471
  let current = "";
503
472
  for (let i = 0; i < content.length; i++) {
504
473
  const char = content[i];
505
- if (char === '(' || char === '[' || char === '{') {
474
+ if (char === "(" || char === "[" || char === "{") {
506
475
  depth++;
507
476
  current += char;
508
477
  }
509
- else if (char === ')' || char === ']' || char === '}') {
478
+ else if (char === ")" || char === "]" || char === "}") {
510
479
  depth--;
511
480
  current += char;
512
481
  }
513
- else if (char === ',' && depth === 0) {
482
+ else if (char === "," && depth === 0) {
514
483
  if (current.trim()) {
515
484
  elements.push(current.trim());
516
485
  }
@@ -39,8 +39,7 @@ export function reconstructJsonSchema(schema) {
39
39
  secondElement.__jsonSchema.conditional) {
40
40
  // Extract the main schema and conditional
41
41
  const mainSchema = reconstructJsonSchema(result.allOf[0]);
42
- const conditionalMeta = secondElement.__jsonSchema
43
- .conditional;
42
+ const conditionalMeta = secondElement.__jsonSchema.conditional;
44
43
  // Merge: main schema + if/then/else at top level
45
44
  const merged = {
46
45
  ...mainSchema,
@@ -9,17 +9,20 @@ Based on analysis of our generated output and Zod v4 limitations research.
9
9
  ### Problem 1: `z.record()` with recursive values lacks `z.lazy()`
10
10
 
11
11
  **Current output:**
12
+
12
13
  ```typescript
13
14
  export const TaskList: z.ZodArray<z.ZodRecord<typeof z, typeof Task>> =
14
15
  z.array(z.record(z.string(), Task).meta({...}))
15
16
  ```
16
17
 
17
18
  **Issues:**
19
+
18
20
  1. `Task` referenced directly in `z.record()` - Colin confirmed in #4881 this REQUIRES `z.lazy()`
19
21
  2. Type annotation is completely wrong - `typeof z` as key type is nonsense
20
22
  3. This will cause runtime TDZ errors if Task isn't declared yet
21
23
 
22
24
  **Should be:**
25
+
23
26
  ```typescript
24
27
  export const TaskList = z.array(
25
28
  z.record(z.string(), z.lazy(() => Task)).meta({...})
@@ -31,16 +34,19 @@ export const TaskList = z.array(
31
34
  ### Problem 2: Union type annotations use `z.ZodTypeAny`
32
35
 
33
36
  **Current output:**
37
+
34
38
  ```typescript
35
39
  export const CallTask: z.ZodUnion<readonly z.ZodTypeAny[]> = z.union([...])
36
40
  ```
37
41
 
38
42
  **Issues:**
43
+
39
44
  1. `z.ZodTypeAny[]` defeats the entire purpose of type safety
40
45
  2. Loses all type information about what's actually in the union
41
46
 
42
47
  **Should be:**
43
48
  Either remove the type annotation entirely:
49
+
44
50
  ```typescript
45
51
  export const CallTask = z.union([...])
46
52
  ```
@@ -52,6 +58,7 @@ Or if we must have one (for circular reference reasons), at least don't use `Zod
52
58
  ### Problem 3: Object type annotations use `Record<string, z.ZodTypeAny>`
53
59
 
54
60
  **Current output:**
61
+
55
62
  ```typescript
56
63
  export const DoTask: z.ZodIntersection<
57
64
  z.ZodObject<Record<string, z.ZodTypeAny>>,
@@ -60,11 +67,13 @@ export const DoTask: z.ZodIntersection<
60
67
  ```
61
68
 
62
69
  **Issues:**
70
+
63
71
  1. `Record<string, z.ZodTypeAny>` loses all property type information
64
72
  2. The intersection type is overly complex and still loses info
65
73
 
66
74
  **Should be:**
67
75
  Remove the type annotation and let TypeScript infer:
76
+
68
77
  ```typescript
69
78
  export const DoTask = z.object({}).and(z.intersection(TaskBase, z.object({...})))
70
79
  ```
@@ -74,6 +83,7 @@ export const DoTask = z.object({}).and(z.intersection(TaskBase, z.object({...}))
74
83
  ### Problem 4: Getters ARE being used correctly ✅
75
84
 
76
85
  **Current output (GOOD):**
86
+
77
87
  ```typescript
78
88
  get "do"(): z.ZodOptional<typeof TaskList>{ return TaskList.optional() }
79
89
  ```
@@ -85,6 +95,7 @@ This follows the Zod v4 pattern correctly! The getter with explicit return type
85
95
  ### Problem 5: Empty object base with `.and()` is wasteful
86
96
 
87
97
  **Current output:**
98
+
88
99
  ```typescript
89
100
  z.object({}).and(z.intersection(TaskBase, z.object({...})))
90
101
  ```
@@ -93,6 +104,7 @@ z.object({}).and(z.intersection(TaskBase, z.object({...})))
93
104
  Starting with `z.object({})` then using `.and()` is unnecessary when there are no direct properties.
94
105
 
95
106
  **Should be:**
107
+
96
108
  ```typescript
97
109
  z.intersection(TaskBase, z.object({...}))
98
110
  // OR
@@ -111,9 +123,8 @@ When reference is inside a `z.record()` context AND the target is recursive, use
111
123
 
112
124
  ```typescript
113
125
  // Check if we're inside a record value context
114
- const inRecordValue = refs.path.some((p, i) =>
115
- p === "additionalProperties" ||
116
- (refs.path[i-1] === "record" && p === "1") // second arg to z.record
126
+ const inRecordValue = refs.path.some(
127
+ (p, i) => p === "additionalProperties" || (refs.path[i - 1] === "record" && p === "1") // second arg to z.record
117
128
  );
118
129
 
119
130
  if (inRecordValue && (isSameCycle || isForwardRef)) {
@@ -128,6 +139,7 @@ if (inRecordValue && (isSameCycle || isForwardRef)) {
128
139
  **File:** `src/core/emitZod.ts`
129
140
 
130
141
  Current logic adds type annotations when there's a cycle/lazy/getter. Change to:
142
+
131
143
  1. Only add type annotation if we can infer a GOOD type
132
144
  2. Never use `z.ZodTypeAny` - either infer correctly or don't annotate
133
145
 
@@ -135,9 +147,11 @@ Current logic adds type annotations when there's a cycle/lazy/getter. Change to:
135
147
  if (isCycle || hasLazy || hasGetter) {
136
148
  const inferredType = inferTypeFromExpression(value);
137
149
  // Skip annotation if it's useless or wrong
138
- if (inferredType !== "z.ZodTypeAny" &&
139
- !inferredType.includes("Record<string, z.ZodTypeAny>") &&
140
- !inferredType.includes("typeof z,")) {
150
+ if (
151
+ inferredType !== "z.ZodTypeAny" &&
152
+ !inferredType.includes("Record<string, z.ZodTypeAny>") &&
153
+ !inferredType.includes("typeof z,")
154
+ ) {
141
155
  return `${shouldExport ? "export " : ""}const ${refName}: ${inferredType} = ${value}`;
142
156
  }
143
157
  }
@@ -196,6 +210,7 @@ When there are no direct properties but there IS composition (allOf), don't crea
196
210
  ```
197
211
 
198
212
  Then when building output with `.and()`:
213
+
199
214
  ```typescript
200
215
  if (output === null && its.an.allOf(objectSchema)) {
201
216
  // No base object, just use the composition directly
@@ -209,18 +224,19 @@ if (output === null && its.an.allOf(objectSchema)) {
209
224
 
210
225
  ## Summary of Changes
211
226
 
212
- | File | Change | Priority |
213
- |------|--------|----------|
214
- | `parseSchema.ts` | Add `z.lazy()` for refs inside `z.record()` | HIGH |
215
- | `emitZod.ts` | Don't add `z.ZodTypeAny` annotations | HIGH |
216
- | `schemaRepresentation.ts` | Fix `z.record()` type inference | HIGH |
217
- | `parseObject.ts` | Remove unnecessary `z.object({}).and()` | MEDIUM |
227
+ | File | Change | Priority |
228
+ | ------------------------- | ------------------------------------------- | -------- |
229
+ | `parseSchema.ts` | Add `z.lazy()` for refs inside `z.record()` | HIGH |
230
+ | `emitZod.ts` | Don't add `z.ZodTypeAny` annotations | HIGH |
231
+ | `schemaRepresentation.ts` | Fix `z.record()` type inference | HIGH |
232
+ | `parseObject.ts` | Remove unnecessary `z.object({}).and()` | MEDIUM |
218
233
 
219
234
  ---
220
235
 
221
236
  ## Expected Outcome
222
237
 
223
238
  **Before:**
239
+
224
240
  ```typescript
225
241
  export const TaskList: z.ZodArray<z.ZodRecord<typeof z, typeof Task>> =
226
242
  z.array(z.record(z.string(), Task).meta({...}))
@@ -229,6 +245,7 @@ export const CallTask: z.ZodUnion<readonly z.ZodTypeAny[]> = z.union([...])
229
245
  ```
230
246
 
231
247
  **After:**
248
+
232
249
  ```typescript
233
250
  export const TaskList = z.array(
234
251
  z.record(z.string(), z.lazy(() => Task)).meta({...})
@@ -238,6 +255,7 @@ export const CallTask = z.union([...]) // Let TS infer
238
255
  ```
239
256
 
240
257
  This aligns with:
258
+
241
259
  1. Colin's guidance that `z.record()` REQUIRES `z.lazy()` for recursive values
242
260
  2. Best practice of not using `z.ZodTypeAny` which defeats type safety
243
261
  3. Zod v4's getter pattern for recursive object properties (which we already do)