@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
@@ -21,7 +21,14 @@ export const analyzeSchema = (schema, options = {}) => {
21
21
  }
22
22
  const declarations = new Map();
23
23
  const dependencies = new Map();
24
- const { registry: refRegistry, rootBaseUri } = buildRefRegistry(schema);
24
+ // Use provided registry or build a new one for this schema
25
+ let refRegistry = rest.refRegistry;
26
+ let rootBaseUri = rest.rootBaseUri ?? "root:///";
27
+ if (!refRegistry) {
28
+ const built = buildRefRegistry(schema, rootBaseUri);
29
+ refRegistry = built.registry;
30
+ rootBaseUri = built.rootBaseUri;
31
+ }
25
32
  const pass1 = {
26
33
  name,
27
34
  path: [],
@@ -42,16 +49,32 @@ export const analyzeSchema = (schema, options = {}) => {
42
49
  const names = Array.from(declarations.keys());
43
50
  const cycleRefNames = detectCycles(names, dependencies);
44
51
  const { componentByName } = computeScc(names, dependencies);
52
+ // Pass 2: Re-parse with cycle information if cycles were detected.
53
+ // This allows parseRef to correctly identify cyclic references and wrap them in z.lazy().
54
+ if (cycleRefNames.size > 0) {
55
+ declarations.clear();
56
+ pass1.seen.clear();
57
+ pass1.inProgress.clear();
58
+ // We reuse refNameByPointer to ensure stable naming across passes
59
+ const pass2 = {
60
+ ...pass1,
61
+ declarations,
62
+ cycleRefNames,
63
+ cycleComponentByName: componentByName,
64
+ };
65
+ parseSchema(schema, pass2);
66
+ }
45
67
  return {
46
68
  schema,
47
69
  options: normalized,
48
- refNameByPointer,
49
- usedNames,
50
70
  declarations,
71
+ definitions: {}, // Legacy support
51
72
  dependencies,
73
+ refNameByPointer,
74
+ usedNames,
52
75
  cycleRefNames,
53
76
  cycleComponentByName: componentByName,
54
- refRegistry,
55
- rootBaseUri,
77
+ rootBaseUri: pass1.rootBaseUri,
78
+ refRegistry: pass1.refRegistry,
56
79
  };
57
80
  };
@@ -148,7 +148,7 @@ const orderDeclarations = (entries, dependencies) => {
148
148
  return ordered.map((name) => [name, repByName.get(name)]);
149
149
  };
150
150
  export const emitZod = (analysis) => {
151
- const { schema, options, refNameByPointer, cycleRefNames, cycleComponentByName, } = analysis;
151
+ const { schema, options, refNameByPointer, cycleRefNames, cycleComponentByName } = analysis;
152
152
  const { name, type, noImport, exportRefs, typeExports, withMeta, ...rest } = options;
153
153
  const declarations = new Map();
154
154
  const dependencies = new Map();
@@ -191,21 +191,27 @@ export const emitZod = (analysis) => {
191
191
  if (typeof expression !== "string") {
192
192
  throw new Error(`Expected declaration expression for ${refName}`);
193
193
  }
194
- const hintedType = typeof rep === "object" && rep && "type" in rep && typeof rep.type === "string"
194
+ const hintedType = typeof rep === "object" &&
195
+ rep &&
196
+ "type" in rep &&
197
+ typeof rep.type === "string"
195
198
  ? rep.type
196
199
  : undefined;
200
+ const effectiveHint = hintedType === "z.ZodTypeAny" ? undefined : hintedType;
197
201
  const hasLazy = expression.includes("z.lazy(");
198
202
  const hasGetter = expression.includes("get ");
199
203
  // Check if this schema references any cycle members (recursive schemas)
200
204
  // This can cause TS7056 when TypeScript tries to serialize the expanded type
201
- const referencesRecursiveSchema = Array.from(cycleRefNames).some(cycleName => new RegExp(`\\b${cycleName}\\b`).test(expression));
205
+ const referencesRecursiveSchema = Array.from(cycleRefNames).some((cycleName) => new RegExp(`\\b${cycleName}\\b`).test(expression));
202
206
  // Per Zod v4 docs: type annotations should be on GETTERS for recursive types, not on const declarations.
203
207
  // TypeScript can infer the type of const declarations.
204
208
  // Exceptions that need explicit type annotation:
205
209
  // 1. z.lazy() without getters
206
210
  // 2. Any schema that references recursive schemas (to prevent TS7056)
207
211
  const needsTypeAnnotation = (hasLazy && !hasGetter) || referencesRecursiveSchema;
208
- const storedType = needsTypeAnnotation ? (hintedType ?? inferTypeFromExpression(expression)) : undefined;
212
+ const storedType = needsTypeAnnotation
213
+ ? (effectiveHint ?? inferTypeFromExpression(expression))
214
+ : undefined;
209
215
  // Rule 2 from Zod v4: Don't chain methods on recursive types
210
216
  // If the schema has getters (recursive), we need to split it:
211
217
  // 1. Emit base schema as _RefName
@@ -226,6 +232,7 @@ export const emitZod = (analysis) => {
226
232
  name: refName,
227
233
  expression: `${baseName}${methodChain}`,
228
234
  exported: exportRefs,
235
+ typeAnnotation: storedType !== "z.ZodTypeAny" ? storedType : undefined,
229
236
  });
230
237
  // Export type for this declaration if typeExports is enabled
231
238
  if (typeExports && exportRefs) {
@@ -11,29 +11,31 @@ export const generateSchemaBundle = (schema, options = {}) => {
11
11
  ? liftInlineObjects(schema, {
12
12
  enable: true,
13
13
  nameForPath: liftOpts.nameForPath,
14
- parentName: options.splitDefs?.rootTypeName ?? options.splitDefs?.rootName ?? schema.title,
14
+ parentName: options.splitDefs?.rootTypeName ??
15
+ options.splitDefs?.rootName ??
16
+ schema.title,
15
17
  dedup: liftOpts.dedup === true,
16
18
  allowInDefs: liftOpts.allowInDefs,
17
19
  }).schema
18
20
  : schema;
19
- const defs = liftedSchema.$defs || {};
20
- const definitions = liftedSchema.definitions || {};
21
- const defNames = Object.keys(defs);
22
- const { rootName, rootTypeName, defInfoMap } = buildBundleContext(defNames, defs, options);
21
+ const allDefs = {
22
+ ...liftedSchema.definitions,
23
+ ...liftedSchema.$defs,
24
+ };
25
+ const defNames = Object.keys(allDefs);
26
+ const { rootName, rootTypeName, defInfoMap } = buildBundleContext(defNames, allDefs, options);
23
27
  const files = [];
24
- const targets = planBundleTargets(liftedSchema, defs, definitions, defNames, options, rootName, defInfoMap, rootTypeName);
28
+ const targets = planBundleTargets(liftedSchema, allDefs, {}, defNames, options, rootName, defInfoMap, rootTypeName);
25
29
  for (const target of targets) {
26
- const usedRefs = target.usedRefs;
30
+ const usedRefs = new Set();
27
31
  const zodParts = [];
28
32
  for (const member of target.members) {
29
33
  const analysis = analyzeSchema(member.schemaWithDefs, {
30
34
  ...options,
31
35
  name: member.schemaName,
32
36
  type: member.typeName,
33
- parserOverride: createRefHandler(member.defName, defInfoMap, usedRefs, {
34
- ...(member.schemaWithDefs.$defs || {}),
35
- ...(member.schemaWithDefs.definitions || {}),
36
- }, options, target.groupId),
37
+ documentRoot: liftedSchema,
38
+ parserOverride: createRefHandler(member.defName, defInfoMap, usedRefs, allDefs, options, target.groupId),
37
39
  });
38
40
  const zodSchema = emitZod(analysis);
39
41
  zodParts.push(zodSchema);
@@ -44,7 +46,7 @@ export const generateSchemaBundle = (schema, options = {}) => {
44
46
  // Nested types extraction (optional)
45
47
  const nestedTypesEnabled = options.nestedTypes?.enable;
46
48
  if (nestedTypesEnabled) {
47
- const nestedTypes = collectNestedTypes(liftedSchema, defs, defNames, rootTypeName ?? rootName);
49
+ const nestedTypes = collectNestedTypes(liftedSchema, allDefs, defNames, rootTypeName ?? rootName);
48
50
  if (nestedTypes.length > 0) {
49
51
  const nestedFileName = options.nestedTypes?.fileName ?? "nested-types.ts";
50
52
  const nestedContent = generateNestedTypesFile(nestedTypes);
@@ -89,19 +91,28 @@ const buildBundleContext = (defNames, defs, options) => {
89
91
  if (info)
90
92
  info.hasCycle = true;
91
93
  }
92
- const groups = buildSccGroups(defInfoMap);
93
- for (const [groupId, members] of groups) {
94
- for (const defName of members) {
94
+ /*
95
+ const useLazyCrossRefs = options.refResolution?.lazyCrossRefs ?? true;
96
+ // NOTE: SCC grouping is currently disabled to ensure 1-to-1 mapping of $defs to files,
97
+ // which is expected by the test suite and preferred for clarity.
98
+ if (!useLazyCrossRefs) {
99
+ const groups = buildSccGroups(defInfoMap);
100
+ for (const [groupId, members] of groups) {
101
+ if (members.length > 1) {
102
+ for (const defName of members) {
95
103
  const info = defInfoMap.get(defName);
96
- if (info)
97
- info.groupId = groupId;
104
+ if (info) info.groupId = groupId;
105
+ }
98
106
  }
107
+ }
99
108
  }
109
+ */
100
110
  const rootName = options.splitDefs?.rootName ?? options.name ?? "RootSchema";
101
111
  const rootTypeName = typeof options.type === "string"
102
112
  ? options.type
103
- : options.splitDefs?.rootTypeName ?? (typeof options.type === "boolean" && options.type ? rootName : undefined);
104
- return { defInfoMap, rootName, rootTypeName, groups };
113
+ : (options.splitDefs?.rootTypeName ??
114
+ (typeof options.type === "boolean" && options.type ? rootName : undefined));
115
+ return { defInfoMap, rootName, rootTypeName };
105
116
  };
106
117
  const createRefHandler = (currentDefName, defInfoMap, usedRefs, allDefs, options, currentGroupId) => {
107
118
  const useLazyCrossRefs = options.refResolution?.lazyCrossRefs ?? true;
@@ -111,10 +122,7 @@ const createRefHandler = (currentDefName, defInfoMap, usedRefs, allDefs, options
111
122
  const match = refPath.match(/^#\/(?:\$defs|definitions)\/(.+)$/);
112
123
  if (match) {
113
124
  const refName = match[1];
114
- // Only intercept top-level def refs (no nested path like a/$defs/x)
115
- if (refName.includes("/")) {
116
- return undefined;
117
- }
125
+ // First check if it's exactly a top-level definition
118
126
  const refInfo = defInfoMap.get(refName);
119
127
  if (refInfo) {
120
128
  // Track imports when referencing other defs
@@ -131,25 +139,26 @@ const createRefHandler = (currentDefName, defInfoMap, usedRefs, allDefs, options
131
139
  });
132
140
  if (resolved)
133
141
  return resolved;
142
+ // Self-recursion ALWAYS needs z.lazy if not using getters
143
+ if (refName === currentDefName) {
144
+ return `z.lazy(() => ${refInfo.schemaName})`;
145
+ }
134
146
  if (isCycle && useLazyCrossRefs) {
135
- const inObjectProperty = refs.path.includes("properties") ||
136
- refs.path.includes("patternProperties") ||
137
- refs.path.includes("additionalProperties");
138
- if (inObjectProperty && refName === currentDefName) {
139
- // Self-recursion inside object getters can safely reference the schema name
140
- return refInfo.schemaName;
141
- }
142
- return `z.lazy<typeof ${refInfo.schemaName}>(() => ${refInfo.schemaName})`;
147
+ return `z.lazy(() => ${refInfo.schemaName})`;
143
148
  }
144
149
  return refInfo.schemaName;
145
150
  }
146
- // If the ref points to a local/inline $def (not part of top-level defs),
147
- // let the default parser resolve it normally.
148
- if (allDefs && Object.prototype.hasOwnProperty.call(allDefs, refName)) {
149
- return undefined;
150
- }
151
+ // If it's NOT exactly a top-level definition, it could be:
152
+ // 1. A path into a top-level definition (e.g. #/$defs/alpha/properties/foo)
153
+ // 2. A local/inline definition NOT in allDefs
154
+ // 3. A reference to allDefs that we missed? (shouldn't happen)
155
+ // We return undefined to let the standard parser resolve it.
156
+ return undefined;
151
157
  }
152
- const unknown = options.refResolution?.onUnknownRef?.({ ref: refPath, currentDef: currentDefName });
158
+ const unknown = options.refResolution?.onUnknownRef?.({
159
+ ref: refPath,
160
+ currentDef: currentDefName,
161
+ });
153
162
  if (unknown)
154
163
  return unknown;
155
164
  return options.useUnknown ? "z.unknown()" : "z.any()";
@@ -160,7 +169,7 @@ const createRefHandler = (currentDefName, defInfoMap, usedRefs, allDefs, options
160
169
  const buildSchemaFile = (zodCodeParts, usedRefs, defInfoMap) => {
161
170
  const groupFileById = new Map();
162
171
  for (const info of defInfoMap.values()) {
163
- if (!groupFileById.has(info.groupId)) {
172
+ if (info.groupId && !groupFileById.has(info.groupId)) {
164
173
  groupFileById.set(info.groupId, info.fileName.replace(/\.ts$/, ".js"));
165
174
  }
166
175
  }
@@ -168,7 +177,8 @@ const buildSchemaFile = (zodCodeParts, usedRefs, defInfoMap) => {
168
177
  for (const refName of [...usedRefs].sort()) {
169
178
  const refInfo = defInfoMap.get(refName);
170
179
  if (refInfo) {
171
- const groupFile = groupFileById.get(refInfo.groupId) ?? refInfo.fileName.replace(/\.ts$/, ".js");
180
+ const groupFile = (refInfo.groupId ? groupFileById.get(refInfo.groupId) : null) ??
181
+ refInfo.fileName.replace(/\.ts$/, ".js");
172
182
  const path = `./${groupFile}`;
173
183
  const set = importsByFile.get(path) ?? new Set();
174
184
  set.add(refInfo.schemaName);
@@ -187,7 +197,7 @@ const buildSchemaFile = (zodCodeParts, usedRefs, defInfoMap) => {
187
197
  })
188
198
  .join("\n");
189
199
  const withImports = imports.length
190
- ? body.replace('import { z } from "zod"', `import { z } from "zod"\n${imports.join("\n")}`)
200
+ ? body.replace(/import \{ z \} from "zod";?/, `import { z } from "zod";\n${imports.join("\n")}`)
191
201
  : body;
192
202
  return withImports;
193
203
  };
@@ -207,7 +217,10 @@ const planBundleTargets = (rootSchema, defs, definitions, defNames, options, roo
207
217
  const defSchema = defs[defName];
208
218
  const defSchemaWithDefs = {
209
219
  ...defSchema,
210
- $defs: { ...defs, ...defSchema?.$defs },
220
+ $defs: {
221
+ ...defs,
222
+ ...defSchema?.$defs,
223
+ },
211
224
  definitions: {
212
225
  ...defSchema.definitions,
213
226
  ...definitions,
@@ -342,52 +355,6 @@ const detectCycles = (defInfoMap) => {
342
355
  }
343
356
  return cycleNodes;
344
357
  };
345
- const buildSccGroups = (defInfoMap) => {
346
- const indexMap = new Map();
347
- const lowLink = new Map();
348
- const onStack = new Set();
349
- const stack = [];
350
- let index = 0;
351
- const groups = new Map();
352
- const strongConnect = (node) => {
353
- indexMap.set(node, index);
354
- lowLink.set(node, index);
355
- index += 1;
356
- stack.push(node);
357
- onStack.add(node);
358
- const info = defInfoMap.get(node);
359
- if (info) {
360
- for (const dep of info.dependencies) {
361
- if (!indexMap.has(dep)) {
362
- strongConnect(dep);
363
- lowLink.set(node, Math.min(lowLink.get(node), lowLink.get(dep)));
364
- }
365
- else if (onStack.has(dep)) {
366
- lowLink.set(node, Math.min(lowLink.get(node), indexMap.get(dep)));
367
- }
368
- }
369
- }
370
- if (lowLink.get(node) === indexMap.get(node)) {
371
- const members = [];
372
- let w;
373
- do {
374
- w = stack.pop();
375
- if (w) {
376
- onStack.delete(w);
377
- members.push(w);
378
- }
379
- } while (w && w !== node);
380
- const groupId = members.sort().join("__");
381
- groups.set(groupId, members);
382
- }
383
- };
384
- for (const name of defInfoMap.keys()) {
385
- if (!indexMap.has(name)) {
386
- strongConnect(name);
387
- }
388
- }
389
- return groups;
390
- };
391
358
  const collectNestedTypes = (rootSchema, defs, defNames, rootTypeName) => {
392
359
  const allNestedTypes = [];
393
360
  for (const defName of defNames) {
@@ -400,7 +367,10 @@ const collectNestedTypes = (rootSchema, defs, defNames, rootTypeName) => {
400
367
  allNestedTypes.push(nested);
401
368
  }
402
369
  }
403
- const workflowNestedTypes = findNestedTypesInSchema({ properties: rootSchema.properties, required: rootSchema.required }, rootTypeName, defNames);
370
+ const workflowNestedTypes = findNestedTypesInSchema({
371
+ properties: rootSchema.properties,
372
+ required: rootSchema.required,
373
+ }, rootTypeName, defNames);
404
374
  for (const nested of workflowNestedTypes) {
405
375
  nested.file = "workflow";
406
376
  nested.parentType = rootTypeName;
@@ -450,7 +420,10 @@ const findNestedTypesInSchema = (schema, parentTypeName, defNames, currentPath =
450
420
  nestedTypes.push(...findNestedTypesInSchema(record.items, parentTypeName, defNames, [...currentPath, "items"]));
451
421
  }
452
422
  if (record.additionalProperties && typeof record.additionalProperties === "object") {
453
- nestedTypes.push(...findNestedTypesInSchema(record.additionalProperties, parentTypeName, defNames, [...currentPath, "additionalProperties"]));
423
+ nestedTypes.push(...findNestedTypesInSchema(record.additionalProperties, parentTypeName, defNames, [
424
+ ...currentPath,
425
+ "additionalProperties",
426
+ ]));
454
427
  }
455
428
  return nestedTypes;
456
429
  };
@@ -467,9 +440,9 @@ const generateNestedTypesFile = (nestedTypes) => {
467
440
  " P extends []",
468
441
  " ? NonNullable<T>",
469
442
  " : P extends readonly [infer H, ...infer R]",
470
- " ? H extends \"items\"",
443
+ ' ? H extends "items"',
471
444
  " ? Access<NonNullable<T> extends Array<infer U> ? U : unknown, Extract<R, (string | number)[]>>",
472
- " : H extends \"additionalProperties\"",
445
+ ' : H extends "additionalProperties"',
473
446
  " ? Access<NonNullable<T> extends Record<string, infer V> ? V : unknown, Extract<R, (string | number)[]>>",
474
447
  " : H extends number",
475
448
  " ? Access<NonNullable<T> extends Array<infer U> ? U : unknown, Extract<R, (string | number)[]>>",
@@ -502,7 +475,9 @@ const generateNestedTypesFile = (nestedTypes) => {
502
475
  }
503
476
  lines.push("");
504
477
  const buildAccessExpr = (parentType, propertyPath) => {
505
- const path = propertyPath.map((prop) => (typeof prop === "number" ? prop : JSON.stringify(prop))).join(", ");
478
+ const path = propertyPath
479
+ .map((prop) => (typeof prop === "number" ? prop : JSON.stringify(prop)))
480
+ .join(", ");
506
481
  return `Access<${parentType}, [${path}]>`;
507
482
  };
508
483
  for (const [parentType, types] of [...byParent.entries()].sort()) {
@@ -34,12 +34,8 @@ const parseObjectShape = (schema, refs, pathPrefix) => {
34
34
  ? schema.required.includes(key)
35
35
  : typeof propSchema === "object" && propSchema.required === true;
36
36
  const optional = !hasDefault && !required;
37
- const valueExpr = optional
38
- ? `${parsedProp.expression}.optional()`
39
- : parsedProp.expression;
40
- const valueType = optional
41
- ? `z.ZodOptional<${parsedProp.type}>`
42
- : parsedProp.type;
37
+ const valueExpr = optional ? `${parsedProp.expression}.exactOptional()` : parsedProp.expression;
38
+ const valueType = optional ? `z.ZodExactOptional<${parsedProp.type}>` : parsedProp.type;
43
39
  shapeEntries.push(`${JSON.stringify(key)}: ${valueExpr}`);
44
40
  shapeTypes.push(`${JSON.stringify(key)}: ${valueType}`);
45
41
  }
@@ -61,14 +57,18 @@ const trySpreadPattern = (allOfMembers, refs) => {
61
57
  return undefined;
62
58
  }
63
59
  // Extract shape entries from inline object
64
- const { shapeEntries: entries, shapeTypes: types } = parseObjectShape(member, refs, [...refs.path, "allOf", idx]);
60
+ const { shapeEntries: entries, shapeTypes: types } = parseObjectShape(member, refs, [
61
+ ...refs.path,
62
+ "allOf",
63
+ idx,
64
+ ]);
65
65
  shapeEntries.push(...entries);
66
66
  shapeTypes.push(...types);
67
67
  }
68
68
  if (shapeEntries.length === 0)
69
69
  return undefined;
70
70
  return {
71
- expression: `z.object({ ${shapeEntries.join(", ")} })`,
71
+ expression: `z.looseObject({ ${shapeEntries.join(", ")} })`,
72
72
  type: `z.ZodObject<{ ${shapeTypes.join(", ")} }>`,
73
73
  };
74
74
  };
@@ -79,9 +79,7 @@ const ensureOriginalIndex = (arr) => {
79
79
  if (typeof item === "boolean") {
80
80
  newArr.push(item ? { [originalIndexKey]: i } : { [originalIndexKey]: i, not: {} });
81
81
  }
82
- else if (typeof item === "object" &&
83
- item !== null &&
84
- originalIndexKey in item) {
82
+ else if (typeof item === "object" && item !== null && originalIndexKey in item) {
85
83
  return arr;
86
84
  }
87
85
  else {
@@ -96,7 +94,7 @@ export function parseAllOf(schema, refs) {
96
94
  }
97
95
  else if (schema.allOf.length === 1) {
98
96
  const item = schema.allOf[0];
99
- return parseSchema(item, {
97
+ const parsed = parseSchema(item, {
100
98
  ...refs,
101
99
  path: [
102
100
  ...refs.path,
@@ -104,6 +102,7 @@ export function parseAllOf(schema, refs) {
104
102
  item[originalIndexKey] ?? 0,
105
103
  ],
106
104
  });
105
+ return parsed;
107
106
  }
108
107
  else {
109
108
  // Try spread pattern first (more efficient than intersection)
@@ -19,8 +19,8 @@ export const parseAnyOf = (schema, refs) => {
19
19
  }
20
20
  return parseSchema(memberSchema, { ...refs, path: [...refs.path, "anyOf", i] });
21
21
  });
22
- const expressions = members.map(m => m.expression).join(", ");
23
- const types = members.map(m => m.type).join(", ");
22
+ const expressions = members.map((m) => m.expression).join(", ");
23
+ const types = members.map((m) => m.type).join(", ");
24
24
  const expression = `z.union([${expressions}])`;
25
25
  // Use readonly tuple for union type annotations (required for recursive type inference)
26
26
  const type = `z.ZodUnion<readonly [${types}]>`;
@@ -2,12 +2,39 @@ import { withMessage } from "../utils/withMessage.js";
2
2
  import { parseSchema } from "./parseSchema.js";
3
3
  import { anyOrUnknown } from "../utils/anyOrUnknown.js";
4
4
  export const parseArray = (schema, refs) => {
5
- if (Array.isArray(schema.items)) {
5
+ // JSON Schema 2020-12 uses `prefixItems` for tuples.
6
+ // Older drafts used `items` as an array.
7
+ const prefixItems = schema.prefixItems || (Array.isArray(schema.items) ? schema.items : undefined);
8
+ if (prefixItems) {
6
9
  // Tuple case
7
- const itemResults = schema.items.map((v, i) => parseSchema(v, { ...refs, path: [...refs.path, "items", i] }));
8
- let tuple = `z.tuple([${itemResults.map(r => r.expression).join(", ")}])`;
9
- const tupleTypes = itemResults.map(r => r.type).join(", ");
10
- let tupleType = `z.ZodTuple<[${tupleTypes}]>`;
10
+ const itemResults = prefixItems.map((v, i) => parseSchema(v, { ...refs, path: [...refs.path, "prefixItems", i] }));
11
+ let tuple = `z.tuple([${itemResults.map((r) => r.expression).join(", ")}])`;
12
+ // We construct the type manually for the tuple part
13
+ let tupleTypes = itemResults.map((r) => r.type).join(", ");
14
+ let tupleType = `z.ZodTuple<[${tupleTypes}], null>`; // Default null rest
15
+ // Handle "additionalItems" (older drafts) or "items" (2020-12 when prefixItems is used)
16
+ // If prefixItems is present, `items` acts as the schema for additional items.
17
+ // If prefixItems came from `items` (array form), then `additionalItems` controls the rest.
18
+ const additionalSchema = schema.prefixItems ? schema.items : schema.additionalItems;
19
+ if (additionalSchema === false) {
20
+ // Closed tuple
21
+ }
22
+ else if (additionalSchema) {
23
+ const restSchema = additionalSchema === true
24
+ ? anyOrUnknown(refs)
25
+ : parseSchema(additionalSchema, {
26
+ ...refs,
27
+ path: [...refs.path, "items"],
28
+ });
29
+ tuple += `.rest(${restSchema.expression})`;
30
+ tupleType = `z.ZodTuple<[${tupleTypes}], ${restSchema.type}>`;
31
+ }
32
+ else {
33
+ // Open by default
34
+ const anyRes = anyOrUnknown(refs);
35
+ tuple += `.rest(${anyRes.expression})`;
36
+ tupleType = `z.ZodTuple<[${tupleTypes}], ${anyRes.type}>`;
37
+ }
11
38
  if (schema.contains) {
12
39
  const containsResult = parseSchema(schema.contains, {
13
40
  ...refs,
@@ -24,18 +51,18 @@ export const parseArray = (schema, refs) => {
24
51
  ctx.addIssue({ code: "custom", message: "Array contains too many matching items" });
25
52
  }
26
53
  })`;
27
- // In Zod v4, .superRefine() doesn't change the type
28
54
  }
29
55
  return {
30
56
  expression: tuple,
31
57
  type: tupleType,
32
58
  };
33
59
  }
34
- // Array case
60
+ // Regular Array case
61
+ const itemsSchema = schema.items;
35
62
  const anyOrUnknownResult = anyOrUnknown(refs);
36
- const itemResult = !schema.items
63
+ const itemResult = !itemsSchema || itemsSchema === true
37
64
  ? anyOrUnknownResult
38
- : parseSchema(schema.items, {
65
+ : parseSchema(itemsSchema, {
39
66
  ...refs,
40
67
  path: [...refs.path, "items"],
41
68
  });
@@ -44,13 +71,13 @@ export const parseArray = (schema, refs) => {
44
71
  r += withMessage(schema, "minItems", ({ json }) => ({
45
72
  opener: `.min(${json}`,
46
73
  closer: ")",
47
- messagePrefix: ", { error: ",
74
+ messagePrefix: ", { message: ",
48
75
  messageCloser: " })",
49
76
  }));
50
77
  r += withMessage(schema, "maxItems", ({ json }) => ({
51
78
  opener: `.max(${json}`,
52
79
  closer: ")",
53
- messagePrefix: ", { error: ",
80
+ messagePrefix: ", { message: ",
54
81
  messageCloser: " })",
55
82
  }));
56
83
  if (schema.uniqueItems === true) {
@@ -94,7 +121,6 @@ export const parseArray = (schema, refs) => {
94
121
  }
95
122
  })`;
96
123
  }
97
- // In Zod v4, .superRefine() doesn't change the type, so no wrapping needed
98
124
  return {
99
125
  expression: r,
100
126
  type: arrayType,
@@ -1,8 +1,8 @@
1
1
  import { parseSchema } from "./parseSchema.js";
2
2
  export const parseMultipleType = (schema, refs) => {
3
3
  const schemas = schema.type.map((type) => parseSchema({ ...schema, type }, { ...refs, withoutDefaults: true }));
4
- const expressions = schemas.map(s => s.expression).join(", ");
5
- const types = schemas.map(s => s.type).join(", ");
4
+ const expressions = schemas.map((s) => s.expression).join(", ");
5
+ const types = schemas.map((s) => s.type).join(", ");
6
6
  return {
7
7
  expression: `z.union([${expressions}])`,
8
8
  type: `z.ZodUnion<[${types}]>`,