@gabrielbryk/json-schema-to-zod 2.12.1 → 2.14.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 (57) 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 +24 -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 +56 -11
  18. package/dist/core/emitZod.js +43 -12
  19. package/dist/generators/generateBundle.js +67 -92
  20. package/dist/index.js +1 -0
  21. package/dist/parsers/parseAllOf.js +11 -12
  22. package/dist/parsers/parseAnyOf.js +2 -2
  23. package/dist/parsers/parseArray.js +38 -12
  24. package/dist/parsers/parseMultipleType.js +2 -2
  25. package/dist/parsers/parseNumber.js +44 -102
  26. package/dist/parsers/parseObject.js +136 -443
  27. package/dist/parsers/parseOneOf.js +57 -110
  28. package/dist/parsers/parseSchema.js +176 -71
  29. package/dist/parsers/parseSimpleDiscriminatedOneOf.js +2 -2
  30. package/dist/parsers/parseString.js +113 -253
  31. package/dist/types/Types.d.ts +37 -1
  32. package/dist/types/core/analyzeSchema.d.ts +4 -0
  33. package/dist/types/generators/generateBundle.d.ts +1 -1
  34. package/dist/types/index.d.ts +1 -0
  35. package/dist/types/utils/schemaNaming.d.ts +6 -0
  36. package/dist/utils/cliTools.js +1 -2
  37. package/dist/utils/esmEmitter.js +6 -2
  38. package/dist/utils/extractInlineObject.js +1 -3
  39. package/dist/utils/jsdocs.js +1 -4
  40. package/dist/utils/liftInlineObjects.js +76 -15
  41. package/dist/utils/resolveRef.js +35 -10
  42. package/dist/utils/schemaNaming.js +31 -0
  43. package/dist/utils/schemaRepresentation.js +35 -66
  44. package/dist/zodToJsonSchema.js +1 -2
  45. package/docs/IMPROVEMENT-PLAN.md +30 -12
  46. package/docs/ZOD-V4-RECURSIVE-TYPE-LIMITATIONS.md +70 -25
  47. package/docs/proposals/allof-required-merging.md +10 -4
  48. package/docs/proposals/bundle-refactor.md +10 -4
  49. package/docs/proposals/discriminated-union-with-default.md +18 -14
  50. package/docs/proposals/inline-object-lifting.md +15 -5
  51. package/docs/proposals/ref-anchor-support.md +11 -0
  52. package/output.txt +67 -0
  53. package/package.json +18 -5
  54. package/scripts/generateWorkflowSchema.ts +5 -14
  55. package/scripts/regenerate_bundle.ts +25 -0
  56. package/tsc_output.txt +542 -0
  57. package/tsc_output_2.txt +489 -0
package/dist/cli.js CHANGED
@@ -7,8 +7,7 @@ const params = {
7
7
  input: {
8
8
  shorthand: "i",
9
9
  value: "string",
10
- required: process.stdin.isTTY &&
11
- "input is required when no JSON or file path is piped",
10
+ required: process.stdin.isTTY && "input is required when no JSON or file path is piped",
12
11
  description: "JSON or a source file path. Required if no data is piped.",
13
12
  },
14
13
  output: {
@@ -29,11 +28,11 @@ const params = {
29
28
  type: {
30
29
  shorthand: "t",
31
30
  value: "string",
32
- description: "The name of the (optional) inferred type export."
31
+ description: "The name of the (optional) inferred type export.",
33
32
  },
34
33
  noImport: {
35
34
  shorthand: "ni",
36
- description: "Removes the `import { z } from 'zod';` or equivalent from the output."
35
+ description: "Removes the `import { z } from 'zod';` or equivalent from the output.",
37
36
  },
38
37
  withJsdocs: {
39
38
  shorthand: "wj",
@@ -1,27 +1,49 @@
1
1
  import { parseSchema } from "../parsers/parseSchema.js";
2
2
  import { detectCycles, computeScc } from "../utils/cycles.js";
3
3
  import { buildRefRegistry } from "../utils/buildRefRegistry.js";
4
+ import { resolveSchemaName } from "../utils/schemaNaming.js";
4
5
  export const analyzeSchema = (schema, options = {}) => {
5
- const { name, type, ...rest } = options;
6
+ const { name, type, naming, ...rest } = options;
6
7
  if (type && !name) {
7
8
  throw new Error("Option `type` requires `name` to be set");
8
9
  }
10
+ const nameContext = { isRoot: true, isLifted: false };
11
+ const rootBaseName = name;
12
+ let resolvedName = name;
13
+ const usedNames = new Set();
14
+ const usedBaseNames = new Set();
15
+ if (name && naming) {
16
+ resolvedName = resolveSchemaName(name, naming, nameContext, usedNames);
17
+ usedBaseNames.add(name);
18
+ }
19
+ if (resolvedName) {
20
+ usedNames.add(resolvedName);
21
+ }
9
22
  const normalized = {
10
- name,
23
+ name: resolvedName,
11
24
  type,
25
+ naming,
12
26
  module: "esm",
13
27
  ...rest,
14
28
  exportRefs: rest.exportRefs ?? true,
15
29
  withMeta: rest.withMeta ?? true,
16
30
  };
17
31
  const refNameByPointer = new Map();
18
- const usedNames = new Set();
19
- if (name) {
20
- usedNames.add(name);
32
+ const refBaseNameByPointer = new Map();
33
+ const baseNameBySchema = new Map();
34
+ if (naming && resolvedName && rootBaseName) {
35
+ baseNameBySchema.set(resolvedName, rootBaseName);
21
36
  }
22
37
  const declarations = new Map();
23
38
  const dependencies = new Map();
24
- const { registry: refRegistry, rootBaseUri } = buildRefRegistry(schema);
39
+ // Use provided registry or build a new one for this schema
40
+ let refRegistry = rest.refRegistry;
41
+ let rootBaseUri = rest.rootBaseUri ?? "root:///";
42
+ if (!refRegistry) {
43
+ const built = buildRefRegistry(schema, rootBaseUri);
44
+ refRegistry = built.registry;
45
+ rootBaseUri = built.rootBaseUri;
46
+ }
25
47
  const pass1 = {
26
48
  name,
27
49
  path: [],
@@ -30,28 +52,51 @@ export const analyzeSchema = (schema, options = {}) => {
30
52
  dependencies,
31
53
  inProgress: new Set(),
32
54
  refNameByPointer,
55
+ refBaseNameByPointer,
56
+ baseNameBySchema,
33
57
  usedNames,
58
+ usedBaseNames,
34
59
  root: schema,
35
- currentSchemaName: name,
60
+ currentSchemaName: resolvedName,
36
61
  refRegistry,
37
62
  rootBaseUri,
38
63
  ...rest,
39
64
  withMeta: normalized.withMeta,
65
+ naming,
40
66
  };
41
67
  parseSchema(schema, pass1);
42
68
  const names = Array.from(declarations.keys());
43
69
  const cycleRefNames = detectCycles(names, dependencies);
44
70
  const { componentByName } = computeScc(names, dependencies);
71
+ // Pass 2: Re-parse with cycle information if cycles were detected.
72
+ // This allows parseRef to correctly identify cyclic references and wrap them in z.lazy().
73
+ if (cycleRefNames.size > 0) {
74
+ declarations.clear();
75
+ pass1.seen.clear();
76
+ pass1.inProgress.clear();
77
+ // We reuse refNameByPointer to ensure stable naming across passes
78
+ const pass2 = {
79
+ ...pass1,
80
+ declarations,
81
+ cycleRefNames,
82
+ cycleComponentByName: componentByName,
83
+ };
84
+ parseSchema(schema, pass2);
85
+ }
45
86
  return {
46
87
  schema,
47
88
  options: normalized,
48
- refNameByPointer,
49
- usedNames,
50
89
  declarations,
90
+ definitions: {}, // Legacy support
51
91
  dependencies,
92
+ refNameByPointer,
93
+ refBaseNameByPointer,
94
+ baseNameBySchema,
95
+ rootBaseName,
96
+ usedNames,
52
97
  cycleRefNames,
53
98
  cycleComponentByName: componentByName,
54
- refRegistry,
55
- rootBaseUri,
99
+ rootBaseUri: pass1.rootBaseUri,
100
+ refRegistry: pass1.refRegistry,
56
101
  };
57
102
  };
@@ -2,6 +2,7 @@ import { parseSchema } from "../parsers/parseSchema.js";
2
2
  import { expandJsdocs } from "../utils/jsdocs.js";
3
3
  import { inferTypeFromExpression } from "../utils/schemaRepresentation.js";
4
4
  import { EsmEmitter } from "../utils/esmEmitter.js";
5
+ import { resolveTypeName } from "../utils/schemaNaming.js";
5
6
  /**
6
7
  * Split a z.object({...}).method1().method2() expression into base and method chain.
7
8
  * This is needed for Rule 2: Don't chain methods on recursive types.
@@ -148,8 +149,8 @@ const orderDeclarations = (entries, dependencies) => {
148
149
  return ordered.map((name) => [name, repByName.get(name)]);
149
150
  };
150
151
  export const emitZod = (analysis) => {
151
- const { schema, options, refNameByPointer, cycleRefNames, cycleComponentByName, } = analysis;
152
- const { name, type, noImport, exportRefs, typeExports, withMeta, ...rest } = options;
152
+ const { schema, options, refNameByPointer, cycleRefNames, cycleComponentByName, baseNameBySchema, rootBaseName, } = analysis;
153
+ const { name, type, naming, noImport, exportRefs, typeExports, withMeta, ...rest } = options;
153
154
  const declarations = new Map();
154
155
  const dependencies = new Map();
155
156
  // Fresh name registry for the emission pass.
@@ -175,6 +176,7 @@ export const emitZod = (analysis) => {
175
176
  rootBaseUri: analysis.rootBaseUri,
176
177
  ...rest,
177
178
  withMeta,
179
+ naming,
178
180
  });
179
181
  const jsdocs = rest.withJsdocs && typeof schema === "object" && schema !== null && "description" in schema
180
182
  ? expandJsdocs(typeof schema.description === "string"
@@ -182,6 +184,13 @@ export const emitZod = (analysis) => {
182
184
  : "")
183
185
  : "";
184
186
  const emitter = new EsmEmitter();
187
+ const usedTypeNames = new Set();
188
+ const resolveDeclarationTypeName = (schemaName) => {
189
+ if (!naming)
190
+ return schemaName;
191
+ const baseName = baseNameBySchema.get(schemaName) ?? schemaName;
192
+ return resolveTypeName(baseName, naming, { isRoot: false, isLifted: true }, usedTypeNames);
193
+ };
185
194
  if (!noImport) {
186
195
  emitter.addNamedImport("z", "zod");
187
196
  }
@@ -191,21 +200,27 @@ export const emitZod = (analysis) => {
191
200
  if (typeof expression !== "string") {
192
201
  throw new Error(`Expected declaration expression for ${refName}`);
193
202
  }
194
- const hintedType = typeof rep === "object" && rep && "type" in rep && typeof rep.type === "string"
203
+ const hintedType = typeof rep === "object" &&
204
+ rep &&
205
+ "type" in rep &&
206
+ typeof rep.type === "string"
195
207
  ? rep.type
196
208
  : undefined;
209
+ const effectiveHint = hintedType === "z.ZodTypeAny" ? undefined : hintedType;
197
210
  const hasLazy = expression.includes("z.lazy(");
198
211
  const hasGetter = expression.includes("get ");
199
212
  // Check if this schema references any cycle members (recursive schemas)
200
213
  // 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));
214
+ const referencesRecursiveSchema = Array.from(cycleRefNames).some((cycleName) => new RegExp(`\\b${cycleName}\\b`).test(expression));
202
215
  // Per Zod v4 docs: type annotations should be on GETTERS for recursive types, not on const declarations.
203
216
  // TypeScript can infer the type of const declarations.
204
217
  // Exceptions that need explicit type annotation:
205
218
  // 1. z.lazy() without getters
206
219
  // 2. Any schema that references recursive schemas (to prevent TS7056)
207
220
  const needsTypeAnnotation = (hasLazy && !hasGetter) || referencesRecursiveSchema;
208
- const storedType = needsTypeAnnotation ? (hintedType ?? inferTypeFromExpression(expression)) : undefined;
221
+ const storedType = needsTypeAnnotation
222
+ ? (effectiveHint ?? inferTypeFromExpression(expression))
223
+ : undefined;
209
224
  // Rule 2 from Zod v4: Don't chain methods on recursive types
210
225
  // If the schema has getters (recursive), we need to split it:
211
226
  // 1. Emit base schema as _RefName
@@ -226,13 +241,18 @@ export const emitZod = (analysis) => {
226
241
  name: refName,
227
242
  expression: `${baseName}${methodChain}`,
228
243
  exported: exportRefs,
244
+ typeAnnotation: storedType !== "z.ZodTypeAny" ? storedType : undefined,
229
245
  });
230
246
  // Export type for this declaration if typeExports is enabled
231
247
  if (typeExports && exportRefs) {
248
+ const typeName = resolveDeclarationTypeName(refName);
232
249
  emitter.addTypeExport({
233
- name: refName,
250
+ name: typeName ?? refName,
234
251
  type: `z.infer<typeof ${refName}>`,
235
252
  });
253
+ if (typeName) {
254
+ usedTypeNames.add(typeName);
255
+ }
236
256
  }
237
257
  continue;
238
258
  }
@@ -245,10 +265,14 @@ export const emitZod = (analysis) => {
245
265
  });
246
266
  // Export type for this declaration if typeExports is enabled
247
267
  if (typeExports && exportRefs) {
268
+ const typeName = resolveDeclarationTypeName(refName);
248
269
  emitter.addTypeExport({
249
- name: refName,
270
+ name: typeName ?? refName,
250
271
  type: `z.infer<typeof ${refName}>`,
251
272
  });
273
+ if (typeName) {
274
+ usedTypeNames.add(typeName);
275
+ }
252
276
  }
253
277
  }
254
278
  }
@@ -268,11 +292,18 @@ export const emitZod = (analysis) => {
268
292
  }
269
293
  // Export type for root schema if type option is set, or if typeExports is enabled
270
294
  if (name && (type || typeExports)) {
271
- const typeName = typeof type === "string" ? type : `${name[0].toUpperCase()}${name.substring(1)}`;
272
- emitter.addTypeExport({
273
- name: typeName,
274
- type: `z.infer<typeof ${name}>`,
275
- });
295
+ const rootTypeName = typeof type === "string"
296
+ ? type
297
+ : naming && rootBaseName
298
+ ? resolveTypeName(rootBaseName, naming, { isRoot: true, isLifted: false }, usedTypeNames)
299
+ : `${name[0].toUpperCase()}${name.substring(1)}`;
300
+ if (rootTypeName) {
301
+ emitter.addTypeExport({
302
+ name: rootTypeName,
303
+ type: `z.infer<typeof ${name}>`,
304
+ });
305
+ usedTypeNames.add(rootTypeName);
306
+ }
276
307
  }
277
308
  return emitter.render();
278
309
  };
@@ -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()) {
package/dist/index.js CHANGED
@@ -33,6 +33,7 @@ export * from "./utils/namingService.js";
33
33
  export * from "./utils/omit.js";
34
34
  export * from "./utils/resolveRef.js";
35
35
  export * from "./utils/resolveUri.js";
36
+ export * from "./utils/schemaNaming.js";
36
37
  export * from "./utils/schemaRepresentation.js";
37
38
  export * from "./utils/withMessage.js";
38
39
  export * from "./zodToJsonSchema.js";
@@ -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}]>`;