@gabrielbryk/json-schema-to-zod 2.10.1 → 2.11.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.
Files changed (138) hide show
  1. package/AGENTS.md +44 -0
  2. package/CHANGELOG.md +38 -0
  3. package/README.md +6 -33
  4. package/check-types-lift.sh +23 -0
  5. package/check-types.sh +20 -0
  6. package/dist/{esm/cli.js → cli.js} +0 -6
  7. package/dist/{esm/core → core}/analyzeSchema.js +4 -5
  8. package/dist/core/emitZod.js +263 -0
  9. package/dist/{esm/generators → generators}/generateBundle.js +26 -13
  10. package/dist/{esm/index.js → index.js} +6 -0
  11. package/dist/jsonSchemaToZod.js +17 -0
  12. package/dist/parsers/parseAllOf.js +125 -0
  13. package/dist/parsers/parseAnyOf.js +28 -0
  14. package/dist/{esm/parsers → parsers}/parseArray.js +27 -11
  15. package/dist/parsers/parseBoolean.js +4 -0
  16. package/dist/parsers/parseConst.js +22 -0
  17. package/dist/parsers/parseEnum.js +35 -0
  18. package/dist/{esm/parsers → parsers}/parseIfThenElse.js +10 -6
  19. package/dist/parsers/parseMultipleType.js +10 -0
  20. package/dist/parsers/parseNot.js +14 -0
  21. package/dist/parsers/parseNull.js +4 -0
  22. package/dist/parsers/parseNullable.js +12 -0
  23. package/dist/{esm/parsers → parsers}/parseNumber.js +4 -1
  24. package/dist/{esm/parsers → parsers}/parseObject.js +200 -37
  25. package/dist/parsers/parseOneOf.js +365 -0
  26. package/dist/{esm/parsers → parsers}/parseSchema.js +55 -117
  27. package/dist/parsers/parseSimpleDiscriminatedOneOf.js +24 -0
  28. package/dist/{esm/parsers → parsers}/parseString.js +29 -18
  29. package/dist/types/Types.d.ts +32 -4
  30. package/dist/types/core/analyzeSchema.d.ts +3 -2
  31. package/dist/types/generators/generateBundle.d.ts +0 -2
  32. package/dist/types/index.d.ts +6 -0
  33. package/dist/types/parsers/parseAllOf.d.ts +2 -2
  34. package/dist/types/parsers/parseAnyOf.d.ts +2 -2
  35. package/dist/types/parsers/parseArray.d.ts +2 -2
  36. package/dist/types/parsers/parseBoolean.d.ts +2 -1
  37. package/dist/types/parsers/parseConst.d.ts +2 -2
  38. package/dist/types/parsers/parseDefault.d.ts +2 -2
  39. package/dist/types/parsers/parseEnum.d.ts +2 -2
  40. package/dist/types/parsers/parseIfThenElse.d.ts +2 -2
  41. package/dist/types/parsers/parseMultipleType.d.ts +2 -2
  42. package/dist/types/parsers/parseNot.d.ts +2 -2
  43. package/dist/types/parsers/parseNull.d.ts +2 -1
  44. package/dist/types/parsers/parseNullable.d.ts +2 -2
  45. package/dist/types/parsers/parseNumber.d.ts +2 -2
  46. package/dist/types/parsers/parseObject.d.ts +2 -2
  47. package/dist/types/parsers/parseOneOf.d.ts +2 -2
  48. package/dist/types/parsers/parseSchema.d.ts +2 -2
  49. package/dist/types/parsers/parseSimpleDiscriminatedOneOf.d.ts +2 -2
  50. package/dist/types/parsers/parseString.d.ts +2 -2
  51. package/dist/types/utils/anyOrUnknown.d.ts +5 -4
  52. package/dist/types/utils/esmEmitter.d.ts +29 -0
  53. package/dist/types/utils/extractInlineObject.d.ts +15 -0
  54. package/dist/types/utils/liftInlineObjects.d.ts +21 -0
  55. package/dist/types/utils/namingService.d.ts +21 -0
  56. package/dist/types/utils/resolveRef.d.ts +7 -0
  57. package/dist/types/utils/schemaRepresentation.d.ts +71 -0
  58. package/dist/utils/anyOrUnknown.js +13 -0
  59. package/dist/{esm/utils → utils}/buildRefRegistry.js +4 -0
  60. package/dist/utils/esmEmitter.js +87 -0
  61. package/dist/utils/extractInlineObject.js +119 -0
  62. package/dist/utils/liftInlineObjects.js +476 -0
  63. package/dist/utils/namingService.js +58 -0
  64. package/dist/utils/resolveRef.js +92 -0
  65. package/dist/utils/schemaRepresentation.js +569 -0
  66. package/docs/IMPROVEMENT-PLAN.md +243 -0
  67. package/docs/ZOD-V4-RECURSIVE-TYPE-LIMITATIONS.md +292 -0
  68. package/docs/proposals/bundle-refactor.md +1 -1
  69. package/docs/proposals/discriminated-union-with-default.md +248 -0
  70. package/docs/proposals/inline-object-lifting.md +77 -0
  71. package/eslint.config.js +4 -2
  72. package/jest.config.mjs +19 -0
  73. package/package.json +17 -20
  74. package/scripts/generateWorkflowSchema.ts +0 -1
  75. package/dist/cjs/Types.js +0 -2
  76. package/dist/cjs/cli.js +0 -70
  77. package/dist/cjs/core/analyzeSchema.js +0 -62
  78. package/dist/cjs/core/emitZod.js +0 -157
  79. package/dist/cjs/generators/generateBundle.js +0 -510
  80. package/dist/cjs/index.js +0 -50
  81. package/dist/cjs/jsonSchemaToZod.js +0 -10
  82. package/dist/cjs/package.json +0 -1
  83. package/dist/cjs/parsers/parseAllOf.js +0 -46
  84. package/dist/cjs/parsers/parseAnyOf.js +0 -18
  85. package/dist/cjs/parsers/parseArray.js +0 -90
  86. package/dist/cjs/parsers/parseBoolean.js +0 -5
  87. package/dist/cjs/parsers/parseConst.js +0 -7
  88. package/dist/cjs/parsers/parseDefault.js +0 -8
  89. package/dist/cjs/parsers/parseEnum.js +0 -21
  90. package/dist/cjs/parsers/parseIfThenElse.js +0 -35
  91. package/dist/cjs/parsers/parseMultipleType.js +0 -10
  92. package/dist/cjs/parsers/parseNot.js +0 -12
  93. package/dist/cjs/parsers/parseNull.js +0 -5
  94. package/dist/cjs/parsers/parseNullable.js +0 -12
  95. package/dist/cjs/parsers/parseNumber.js +0 -116
  96. package/dist/cjs/parsers/parseObject.js +0 -318
  97. package/dist/cjs/parsers/parseOneOf.js +0 -53
  98. package/dist/cjs/parsers/parseSchema.js +0 -419
  99. package/dist/cjs/parsers/parseSimpleDiscriminatedOneOf.js +0 -21
  100. package/dist/cjs/parsers/parseString.js +0 -317
  101. package/dist/cjs/utils/anyOrUnknown.js +0 -14
  102. package/dist/cjs/utils/buildRefRegistry.js +0 -56
  103. package/dist/cjs/utils/cliTools.js +0 -108
  104. package/dist/cjs/utils/cycles.js +0 -113
  105. package/dist/cjs/utils/half.js +0 -7
  106. package/dist/cjs/utils/jsdocs.js +0 -20
  107. package/dist/cjs/utils/omit.js +0 -11
  108. package/dist/cjs/utils/resolveUri.js +0 -16
  109. package/dist/cjs/utils/withMessage.js +0 -21
  110. package/dist/cjs/zodToJsonSchema.js +0 -89
  111. package/dist/esm/core/emitZod.js +0 -153
  112. package/dist/esm/jsonSchemaToZod.js +0 -6
  113. package/dist/esm/package.json +0 -1
  114. package/dist/esm/parsers/parseAllOf.js +0 -43
  115. package/dist/esm/parsers/parseAnyOf.js +0 -14
  116. package/dist/esm/parsers/parseBoolean.js +0 -1
  117. package/dist/esm/parsers/parseConst.js +0 -3
  118. package/dist/esm/parsers/parseEnum.js +0 -17
  119. package/dist/esm/parsers/parseMultipleType.js +0 -6
  120. package/dist/esm/parsers/parseNot.js +0 -8
  121. package/dist/esm/parsers/parseNull.js +0 -1
  122. package/dist/esm/parsers/parseNullable.js +0 -8
  123. package/dist/esm/parsers/parseOneOf.js +0 -49
  124. package/dist/esm/parsers/parseSimpleDiscriminatedOneOf.js +0 -17
  125. package/dist/esm/utils/anyOrUnknown.js +0 -10
  126. package/jest.config.cjs +0 -4
  127. package/postcjs.cjs +0 -1
  128. package/postesm.cjs +0 -1
  129. /package/dist/{esm/Types.js → Types.js} +0 -0
  130. /package/dist/{esm/parsers → parsers}/parseDefault.js +0 -0
  131. /package/dist/{esm/utils → utils}/cliTools.js +0 -0
  132. /package/dist/{esm/utils → utils}/cycles.js +0 -0
  133. /package/dist/{esm/utils → utils}/half.js +0 -0
  134. /package/dist/{esm/utils → utils}/jsdocs.js +0 -0
  135. /package/dist/{esm/utils → utils}/omit.js +0 -0
  136. /package/dist/{esm/utils → utils}/resolveUri.js +0 -0
  137. /package/dist/{esm/utils → utils}/withMessage.js +0 -0
  138. /package/dist/{esm/zodToJsonSchema.js → zodToJsonSchema.js} +0 -0
@@ -0,0 +1,365 @@
1
+ import { parseSchema } from "./parseSchema.js";
2
+ import { anyOrUnknown } from "../utils/anyOrUnknown.js";
3
+ import { extractInlineObject } from "../utils/extractInlineObject.js";
4
+ import { resolveRef } from "../utils/resolveRef.js";
5
+ /**
6
+ * Check if a schema is a "required-only" validation constraint.
7
+ * These are schemas that only specify `required` without defining types.
8
+ */
9
+ const isRequiredOnlySchema = (schema) => {
10
+ if (typeof schema !== "object" || schema === null) {
11
+ return false;
12
+ }
13
+ const obj = schema;
14
+ // Must have required array
15
+ if (!Array.isArray(obj.required) || obj.required.length === 0) {
16
+ return false;
17
+ }
18
+ // Must NOT have type-defining keywords
19
+ if (obj.type || obj.properties || obj.additionalProperties || obj.patternProperties) {
20
+ return false;
21
+ }
22
+ // Must NOT have composition keywords
23
+ if (obj.allOf || obj.anyOf || obj.oneOf) {
24
+ return false;
25
+ }
26
+ // Must NOT be a reference
27
+ if (obj.$ref || obj.$dynamicRef) {
28
+ return false;
29
+ }
30
+ return true;
31
+ };
32
+ /**
33
+ * Generate a superRefine expression that validates required field combinations.
34
+ * This handles the JSON Schema pattern where oneOf is used purely for validation.
35
+ *
36
+ * When isRefinementOnly is true, the expression is just the refinement function body
37
+ * that should be appended with .superRefine() directly to the parent schema.
38
+ */
39
+ const generateRequiredFieldsRefinement = (requiredCombinations) => {
40
+ const conditions = requiredCombinations.map((fields) => {
41
+ const checks = fields.map((f) => `obj[${JSON.stringify(f)}] !== undefined`).join(" && ");
42
+ return `(${checks})`;
43
+ });
44
+ const message = `Must have one of the following required field combinations: ${requiredCombinations.map((r) => r.join(", ")).join(" | ")}`;
45
+ // The refinement function body (without the surrounding .superRefine())
46
+ const refinementBody = `(obj, ctx) => { if (!(${conditions.join(" || ")})) { ctx.addIssue({ code: "custom", message: ${JSON.stringify(message)} }); } }`;
47
+ // For standalone use, return z.any() with the refinement
48
+ const expression = `z.any().superRefine(${refinementBody})`;
49
+ return {
50
+ expression,
51
+ type: "z.ZodAny",
52
+ isRefinementOnly: true,
53
+ refinementBody,
54
+ };
55
+ };
56
+ /**
57
+ * Collects all properties from a schema, including properties defined in allOf members.
58
+ * Returns merged properties object and combined required array.
59
+ */
60
+ const collectSchemaProperties = (schema, refs) => {
61
+ let properties = {};
62
+ let required = [];
63
+ // Collect direct properties
64
+ if (schema.properties) {
65
+ properties = { ...properties, ...schema.properties };
66
+ }
67
+ // Collect direct required
68
+ if (Array.isArray(schema.required)) {
69
+ required = [...required, ...schema.required];
70
+ }
71
+ // Collect from allOf members
72
+ if (Array.isArray(schema.allOf)) {
73
+ for (const member of schema.allOf) {
74
+ if (typeof member !== 'object' || member === null)
75
+ continue;
76
+ let resolvedMember = member;
77
+ // Resolve $ref if needed
78
+ if (resolvedMember.$ref || resolvedMember.$dynamicRef) {
79
+ const resolved = resolveRef(resolvedMember, (resolvedMember.$ref || resolvedMember.$dynamicRef), refs);
80
+ if (resolved && typeof resolved.schema === 'object' && resolved.schema !== null) {
81
+ resolvedMember = resolved.schema;
82
+ }
83
+ else {
84
+ continue;
85
+ }
86
+ }
87
+ // Merge properties from this allOf member
88
+ if (resolvedMember.properties) {
89
+ properties = { ...properties, ...resolvedMember.properties };
90
+ }
91
+ // Merge required from this allOf member
92
+ if (Array.isArray(resolvedMember.required)) {
93
+ required = [...required, ...resolvedMember.required];
94
+ }
95
+ }
96
+ }
97
+ // Return undefined if no properties found
98
+ if (Object.keys(properties).length === 0) {
99
+ return undefined;
100
+ }
101
+ return { properties, required: [...new Set(required)] };
102
+ };
103
+ /**
104
+ * Check if two sets contain the same elements.
105
+ */
106
+ const setsEqual = (a, b) => {
107
+ if (a.size !== b.size)
108
+ return false;
109
+ for (const item of a) {
110
+ if (!b.has(item))
111
+ return false;
112
+ }
113
+ return true;
114
+ };
115
+ /**
116
+ * Extract the constant value from a property schema.
117
+ * Returns the string value if it's a const or single-element enum, undefined otherwise.
118
+ */
119
+ const getConstValue = (prop) => {
120
+ if (prop.const !== undefined && typeof prop.const === 'string') {
121
+ return prop.const;
122
+ }
123
+ if (prop.enum &&
124
+ Array.isArray(prop.enum) &&
125
+ prop.enum.length === 1 &&
126
+ typeof prop.enum[0] === 'string') {
127
+ return prop.enum[0];
128
+ }
129
+ return undefined;
130
+ };
131
+ /**
132
+ * Extract the negated enum values from a property schema.
133
+ * Returns the enum values if the property has { not: { enum: [...] } }, undefined otherwise.
134
+ */
135
+ const getNegatedEnumValues = (prop) => {
136
+ if (prop.not &&
137
+ typeof prop.not === 'object' &&
138
+ prop.not !== null &&
139
+ Array.isArray(prop.not.enum) &&
140
+ prop.not.enum.every((v) => typeof v === 'string')) {
141
+ return prop.not.enum;
142
+ }
143
+ return undefined;
144
+ };
145
+ /**
146
+ * Attempts to find a discriminator property common to all options.
147
+ * A discriminator must:
148
+ * 1. Be present in 'properties' of all options (resolving $refs and allOf if needed)
149
+ * 2. Be required in all options (checking both direct required and allOf required)
150
+ * 3. Have a constant string value (const or enum: [val]) in all options, OR
151
+ * have constant values in all but one option which has not:{enum:[those values]}
152
+ * 4. Have unique values across all options (for const values)
153
+ */
154
+ const findImplicitDiscriminator = (options, refs) => {
155
+ if (options.length < 2)
156
+ return undefined;
157
+ // Fully resolve schemas and collect their properties (including from allOf)
158
+ const resolvedOptions = [];
159
+ for (const opt of options) {
160
+ if (typeof opt !== 'object' || opt === null)
161
+ return undefined;
162
+ let schemaObj = opt;
163
+ // Resolve ref if needed
164
+ if (schemaObj.$ref || schemaObj.$dynamicRef) {
165
+ const resolved = resolveRef(schemaObj, (schemaObj.$ref || schemaObj.$dynamicRef), refs);
166
+ if (resolved && typeof resolved.schema === 'object' && resolved.schema !== null) {
167
+ schemaObj = resolved.schema;
168
+ }
169
+ else {
170
+ return undefined;
171
+ }
172
+ }
173
+ // Must be an object type
174
+ if (schemaObj.type !== 'object') {
175
+ return undefined;
176
+ }
177
+ // Collect all properties including from allOf
178
+ const collected = collectSchemaProperties(schemaObj, refs);
179
+ if (!collected) {
180
+ return undefined;
181
+ }
182
+ resolvedOptions.push(collected);
183
+ }
184
+ // Get all possible keys from the first option
185
+ const firstProps = resolvedOptions[0].properties;
186
+ const candidateKeys = Object.keys(firstProps);
187
+ for (const key of candidateKeys) {
188
+ const constValues = [];
189
+ const constValuesSet = new Set();
190
+ let defaultIndex;
191
+ let defaultEnumValues;
192
+ let isValidDiscriminator = true;
193
+ for (let i = 0; i < resolvedOptions.length; i++) {
194
+ const opt = resolvedOptions[i];
195
+ // Must be required
196
+ if (!opt.required.includes(key)) {
197
+ isValidDiscriminator = false;
198
+ break;
199
+ }
200
+ const propBeforeResolve = opt.properties[key];
201
+ if (!propBeforeResolve) {
202
+ isValidDiscriminator = false;
203
+ break;
204
+ }
205
+ // Resolve property schema ref if needed (e.g. definitions/kind -> const)
206
+ let prop = propBeforeResolve;
207
+ if (typeof prop === 'object' && prop !== null && (prop.$ref || prop.$dynamicRef)) {
208
+ const resolvedProp = resolveRef(prop, (prop.$ref || prop.$dynamicRef), refs);
209
+ if (resolvedProp) {
210
+ prop = resolvedProp.schema;
211
+ }
212
+ }
213
+ if (typeof prop !== 'object' || prop === null) {
214
+ isValidDiscriminator = false;
215
+ break;
216
+ }
217
+ // Check for constant value
218
+ const constValue = getConstValue(prop);
219
+ if (constValue !== undefined) {
220
+ if (constValuesSet.has(constValue)) {
221
+ isValidDiscriminator = false; // Duplicate value found
222
+ break;
223
+ }
224
+ constValuesSet.add(constValue);
225
+ constValues.push(constValue);
226
+ continue;
227
+ }
228
+ // Check for negated enum (default case pattern)
229
+ const negatedEnum = getNegatedEnumValues(prop);
230
+ if (negatedEnum !== undefined) {
231
+ if (defaultIndex !== undefined) {
232
+ // Multiple defaults - can't optimize
233
+ isValidDiscriminator = false;
234
+ break;
235
+ }
236
+ defaultIndex = i;
237
+ defaultEnumValues = negatedEnum;
238
+ continue;
239
+ }
240
+ // Neither const nor not.enum - can't use discriminated union
241
+ isValidDiscriminator = false;
242
+ break;
243
+ }
244
+ if (!isValidDiscriminator) {
245
+ continue;
246
+ }
247
+ // Check if all options have const values (full discriminated union)
248
+ if (constValues.length === resolvedOptions.length) {
249
+ return { type: 'full', key };
250
+ }
251
+ // Check if we have a valid default case pattern
252
+ if (defaultIndex !== undefined &&
253
+ defaultEnumValues !== undefined &&
254
+ constValues.length === resolvedOptions.length - 1) {
255
+ // Verify the negated enum exactly matches the const values
256
+ const enumSet = new Set(defaultEnumValues);
257
+ if (setsEqual(constValuesSet, enumSet)) {
258
+ return { type: 'withDefault', key, defaultIndex, constValues };
259
+ }
260
+ }
261
+ }
262
+ return undefined;
263
+ };
264
+ export const parseOneOf = (schema, refs) => {
265
+ if (!schema.oneOf.length) {
266
+ return anyOrUnknown(refs);
267
+ }
268
+ if (schema.oneOf.length === 1) {
269
+ return parseSchema(schema.oneOf[0], {
270
+ ...refs,
271
+ path: [...refs.path, "oneOf", 0],
272
+ });
273
+ }
274
+ // Check if ALL oneOf members are "required-only" schemas
275
+ const requiredOnlyMembers = schema.oneOf.filter(isRequiredOnlySchema);
276
+ if (requiredOnlyMembers.length === schema.oneOf.length) {
277
+ const requiredCombinations = requiredOnlyMembers.map((m) => m.required);
278
+ return generateRequiredFieldsRefinement(requiredCombinations);
279
+ }
280
+ // Optimize: Check for implicit discriminated union
281
+ const discriminator = findImplicitDiscriminator(schema.oneOf, refs);
282
+ if (discriminator?.type === 'full') {
283
+ // All options have constant discriminator values
284
+ const options = schema.oneOf.map((s, i) => parseSchema(s, {
285
+ ...refs,
286
+ path: [...refs.path, "oneOf", i],
287
+ }));
288
+ const expressions = options.map(o => o.expression).join(", ");
289
+ const types = options.map(o => o.type).join(", ");
290
+ return {
291
+ expression: `z.discriminatedUnion("${discriminator.key}", [${expressions}])`,
292
+ // Use readonly tuple for union type annotations (required for recursive type inference)
293
+ type: `z.ZodDiscriminatedUnion<"${discriminator.key}", readonly [${types}]>`,
294
+ };
295
+ }
296
+ // Note: 'withDefault' case (discriminated union with catch-all) cannot be optimized
297
+ // in Zod v4 because ZodDiscriminatedUnion cannot be nested inside ZodUnion at the type level.
298
+ // The runtime would work, but the types wouldn't match, causing compile errors.
299
+ // So we fall through to the regular union handling below.
300
+ // Fallback: Standard z.union
301
+ const parsedSchemas = schema.oneOf.map((s, i) => {
302
+ const extracted = extractInlineObject(s, refs, [...refs.path, "oneOf", i]);
303
+ if (extracted) {
304
+ // extractInlineObject returns a refName string
305
+ return { expression: extracted, type: `typeof ${extracted}` };
306
+ }
307
+ let parsed = parseSchema(s, {
308
+ ...refs,
309
+ path: [...refs.path, "oneOf", i],
310
+ });
311
+ // Make regular unions stricter: if it's an object, it shouldn't match emptiness.
312
+ // Ensure we only apply .strict() to actual z.object() calls.
313
+ if (typeof s === "object" &&
314
+ s !== null &&
315
+ (s.type === "object" || s.properties) &&
316
+ !s.$ref &&
317
+ parsed.expression.startsWith("z.object(") && // Critical check: Must be a Zod object
318
+ !parsed.expression.includes(".and(") &&
319
+ !parsed.expression.includes(".intersection(") &&
320
+ !parsed.expression.includes(".strict()") &&
321
+ !parsed.expression.includes(".catchall") &&
322
+ !parsed.expression.includes(".passthrough")) {
323
+ parsed = {
324
+ expression: parsed.expression + ".strict()",
325
+ type: parsed.type, // .strict() doesn't change the Zod type
326
+ };
327
+ }
328
+ return parsed;
329
+ });
330
+ // Build the union types for the SchemaRepresentation
331
+ const unionTypes = parsedSchemas.map(r => r.type).join(", ");
332
+ const unionExpression = `z.union([${parsedSchemas.map(r => r.expression).join(", ")}])`;
333
+ if (refs.strictOneOf) {
334
+ const schemasExpressions = parsedSchemas.map(r => r.expression).join(", ");
335
+ const expression = `${unionExpression}.superRefine((x, ctx) => {
336
+ const schemas = [${schemasExpressions}];
337
+ const errors = schemas.reduce<z.ZodError[]>(
338
+ (errors, schema) =>
339
+ ((result) =>
340
+ result.error ? [...errors, result.error] : errors)(
341
+ schema.safeParse(x),
342
+ ),
343
+ [],
344
+ );
345
+ if (schemas.length - errors.length !== 1) {
346
+ ctx.addIssue({
347
+ path: [],
348
+ code: "invalid_union",
349
+ errors: errors.map(e => e.issues),
350
+ message: "Invalid input: Should pass single schema",
351
+ });
352
+ }
353
+ })`;
354
+ return {
355
+ expression,
356
+ // In Zod v4, .superRefine() doesn't change the type
357
+ type: `z.ZodUnion<readonly [${unionTypes}]>`,
358
+ };
359
+ }
360
+ return {
361
+ expression: unionExpression,
362
+ // Use readonly tuple for union type annotations (required for recursive type inference)
363
+ type: `z.ZodUnion<readonly [${unionTypes}]>`,
364
+ };
365
+ };
@@ -17,7 +17,7 @@ import { parseSimpleDiscriminatedOneOf } from "./parseSimpleDiscriminatedOneOf.j
17
17
  import { parseNullable } from "./parseNullable.js";
18
18
  import { anyOrUnknown } from "../utils/anyOrUnknown.js";
19
19
  import { resolveUri } from "../utils/resolveUri.js";
20
- import { buildRefRegistry } from "../utils/buildRefRegistry.js";
20
+ import { resolveRef } from "../utils/resolveRef.js";
21
21
  export const parseSchema = (schema, refs = { seen: new Map(), path: [] }, blockMeta) => {
22
22
  // Ensure ref bookkeeping exists so $ref declarations and getter-based recursion work
23
23
  refs.root = refs.root ?? schema;
@@ -27,8 +27,11 @@ export const parseSchema = (schema, refs = { seen: new Map(), path: [] }, blockM
27
27
  refs.inProgress = refs.inProgress ?? new Set();
28
28
  refs.refNameByPointer = refs.refNameByPointer ?? new Map();
29
29
  refs.usedNames = refs.usedNames ?? new Set();
30
- if (typeof schema !== "object")
31
- return schema ? anyOrUnknown(refs) : "z.never()";
30
+ if (typeof schema !== "object") {
31
+ return schema
32
+ ? anyOrUnknown(refs)
33
+ : { expression: "z.never()", type: "z.ZodNever" };
34
+ }
32
35
  const parentBase = refs.currentBaseUri ?? refs.rootBaseUri ?? "root:///";
33
36
  const baseUri = typeof schema.$id === "string" ? resolveUri(parentBase, schema.$id) : parentBase;
34
37
  const dynamicAnchors = Array.isArray(refs.dynamicAnchors) ? [...refs.dynamicAnchors] : [];
@@ -42,7 +45,8 @@ export const parseSchema = (schema, refs = { seen: new Map(), path: [] }, blockM
42
45
  if (refs.parserOverride) {
43
46
  const custom = refs.parserOverride(schema, { ...refs, currentBaseUri: baseUri, dynamicAnchors });
44
47
  if (typeof custom === "string") {
45
- return custom;
48
+ // ParserOverride returns string for backward compatibility
49
+ return { expression: custom, type: "z.ZodTypeAny" };
46
50
  }
47
51
  }
48
52
  let seen = refs.seen.get(schema);
@@ -91,7 +95,7 @@ const parseRef = (schema, refs) => {
91
95
  const refName = getOrCreateRefName(pointerKey, path, refs);
92
96
  if (!refs.declarations.has(refName) && !refs.inProgress.has(refName)) {
93
97
  refs.inProgress.add(refName);
94
- const declaration = parseSchema(target, {
98
+ const result = parseSchema(target, {
95
99
  ...refs,
96
100
  path,
97
101
  currentBaseUri: resolved.baseUri,
@@ -99,7 +103,7 @@ const parseRef = (schema, refs) => {
99
103
  root: refs.root,
100
104
  });
101
105
  refs.inProgress.delete(refName);
102
- refs.declarations.set(refName, declaration);
106
+ refs.declarations.set(refName, result);
103
107
  }
104
108
  const current = refs.currentSchemaName;
105
109
  if (current) {
@@ -116,23 +120,43 @@ const parseRef = (schema, refs) => {
116
120
  targetComponent !== undefined &&
117
121
  currentComponent === targetComponent &&
118
122
  refs.cycleRefNames?.has(refName);
119
- // Only lazy if the ref stays inside the current strongly-connected component
120
- // (or is currently being resolved). This avoids TDZ on true cycles while
121
- // letting ordered, acyclic refs stay direct.
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;
123
+ // Check if this is a true forward reference (target not yet declared)
124
+ // We only need z.lazy() for forward refs, not for back-refs to already-declared schemas
125
+ const isForwardRef = refs.inProgress.has(refName);
126
+ const refType = `typeof ${refName}`;
127
+ // For same-cycle refs, check if we need special handling
128
+ if (isSameCycle || isForwardRef) {
129
+ // Check context: are we inside an object property where getters work?
130
+ // IMPORTANT: additionalProperties becomes z.record() which does NOT support getters
131
+ // Only named properties (properties, patternProperties) can use getters
132
+ const inNamedProperty = refs.path.includes("properties") ||
133
+ refs.path.includes("patternProperties");
134
+ // additionalProperties becomes z.record() value - getters don't work there
135
+ // Per Zod issue #4881: z.record() with recursive values REQUIRES z.lazy()
136
+ const inRecordContext = refs.path.includes("additionalProperties");
137
+ // Self-recursion in named object properties: use direct ref (getter handles deferred eval)
138
+ const isSelfRecursion = refName === refs.currentSchemaName;
139
+ if (inNamedProperty && isSelfRecursion) {
140
+ return { expression: refName, type: refType };
141
+ }
142
+ // Cross-schema refs in named object properties within same cycle: use direct ref
143
+ // The getter in parseObject.ts will handle deferred evaluation
144
+ if (inNamedProperty && isSameCycle && !isForwardRef) {
145
+ return { expression: refName, type: refType };
146
+ }
147
+ // z.record() values with recursive refs MUST use z.lazy() (Colin confirmed in #4881)
148
+ // Also arrays, unions, and other non-object contexts with forward refs need z.lazy()
149
+ if (isForwardRef || inRecordContext) {
150
+ return {
151
+ expression: `z.lazy(() => ${refName})`,
152
+ type: `z.ZodLazy<${refType}>`
153
+ };
130
154
  }
131
- return `z.lazy(() => ${refName})`;
132
155
  }
133
- return refName;
156
+ return { expression: refName, type: refType };
134
157
  };
135
158
  const addDescribes = (schema, parsed, refs) => {
159
+ let { expression, type } = parsed;
136
160
  // Use .meta() for richer metadata when withMeta is enabled
137
161
  if (refs?.withMeta) {
138
162
  const meta = {};
@@ -147,103 +171,13 @@ const addDescribes = (schema, parsed, refs) => {
147
171
  if (schema.deprecated)
148
172
  meta.deprecated = schema.deprecated;
149
173
  if (Object.keys(meta).length > 0) {
150
- parsed += `.meta(${JSON.stringify(meta)})`;
174
+ expression += `.meta(${JSON.stringify(meta)})`;
151
175
  }
152
176
  }
153
177
  else if (schema.description) {
154
- parsed += `.describe(${JSON.stringify(schema.description)})`;
178
+ expression += `.describe(${JSON.stringify(schema.description)})`;
155
179
  }
156
- return parsed;
157
- };
158
- const resolveRef = (schemaNode, ref, refs) => {
159
- const base = refs.currentBaseUri ?? refs.rootBaseUri ?? "root:///";
160
- // Handle dynamicRef lookup via dynamicAnchors stack
161
- const isDynamic = typeof schemaNode.$dynamicRef === "string";
162
- if (isDynamic && refs.dynamicAnchors && ref.startsWith("#")) {
163
- const name = ref.slice(1);
164
- for (let i = refs.dynamicAnchors.length - 1; i >= 0; i -= 1) {
165
- const entry = refs.dynamicAnchors[i];
166
- if (entry.name === name) {
167
- const key = `${entry.uri}#${name}`;
168
- const target = refs.refRegistry?.get(key);
169
- if (target) {
170
- return { schema: target.schema, path: target.path, baseUri: target.baseUri, pointerKey: key };
171
- }
172
- }
173
- }
174
- }
175
- // Resolve URI against base
176
- const resolvedUri = resolveUri(base, ref);
177
- const [uriBase, fragment] = resolvedUri.split("#");
178
- const key = fragment ? `${uriBase}#${fragment}` : uriBase;
179
- let regEntry = refs.refRegistry?.get(key);
180
- if (regEntry) {
181
- return { schema: regEntry.schema, path: regEntry.path, baseUri: regEntry.baseUri, pointerKey: key };
182
- }
183
- // Legacy recursive ref: treat as dynamic to __recursive__
184
- if (schemaNode.$recursiveRef) {
185
- const recursiveKey = `${base}#__recursive__`;
186
- regEntry = refs.refRegistry?.get(recursiveKey);
187
- if (regEntry) {
188
- return {
189
- schema: regEntry.schema,
190
- path: regEntry.path,
191
- baseUri: regEntry.baseUri,
192
- pointerKey: recursiveKey,
193
- };
194
- }
195
- }
196
- // External resolver hook
197
- const extBase = uriBaseFromRef(resolvedUri);
198
- if (refs.resolveExternalRef && extBase && !isLocalBase(extBase, refs.rootBaseUri ?? "")) {
199
- const loaded = refs.resolveExternalRef(extBase);
200
- if (loaded) {
201
- // If async resolver is used synchronously here, it will be ignored; keep simple sync for now
202
- const maybePromise = loaded;
203
- const schema = typeof maybePromise.then === "function"
204
- ? undefined
205
- : loaded;
206
- if (schema) {
207
- const { registry } = buildRefRegistry(schema, extBase);
208
- registry.forEach((entry, k) => refs.refRegistry?.set(k, entry));
209
- regEntry = refs.refRegistry?.get(key);
210
- if (regEntry) {
211
- return {
212
- schema: regEntry.schema,
213
- path: regEntry.path,
214
- baseUri: regEntry.baseUri,
215
- pointerKey: key,
216
- };
217
- }
218
- }
219
- }
220
- }
221
- // Backward compatibility: JSON Pointer into root
222
- if (refs.root && ref.startsWith("#/")) {
223
- const rawSegments = ref
224
- .slice(2)
225
- .split("/")
226
- .filter((segment) => segment.length > 0)
227
- .map(decodePointerSegment);
228
- let current = refs.root;
229
- for (const segment of rawSegments) {
230
- if (typeof current !== "object" || current === null)
231
- return undefined;
232
- current = current[segment];
233
- }
234
- return { schema: current, path: rawSegments, baseUri: base, pointerKey: ref };
235
- }
236
- return undefined;
237
- };
238
- const decodePointerSegment = (segment) => segment.replace(/~1/g, "/").replace(/~0/g, "~");
239
- const uriBaseFromRef = (resolvedUri) => {
240
- const hashIdx = resolvedUri.indexOf("#");
241
- return hashIdx === -1 ? resolvedUri : resolvedUri.slice(0, hashIdx);
242
- };
243
- const isLocalBase = (base, rootBase) => {
244
- if (!rootBase)
245
- return false;
246
- return base === rootBase;
180
+ return { expression, type };
247
181
  };
248
182
  const getOrCreateRefName = (pointer, path, refs) => {
249
183
  if (refs.refNameByPointer?.has(pointer)) {
@@ -297,16 +231,20 @@ const sanitizeIdentifier = (value) => {
297
231
  };
298
232
  const capitalize = (value) => value.length ? value[0].toUpperCase() + value.slice(1) : value;
299
233
  const addDefaults = (schema, parsed) => {
234
+ let { expression, type } = parsed;
300
235
  if (schema.default !== undefined) {
301
- parsed += `.default(${JSON.stringify(schema.default)})`;
236
+ expression += `.default(${JSON.stringify(schema.default)})`;
237
+ type = `z.ZodDefault<${type}>`;
302
238
  }
303
- return parsed;
239
+ return { expression, type };
304
240
  };
305
241
  const addAnnotations = (schema, parsed) => {
242
+ let { expression, type } = parsed;
306
243
  if (schema.readOnly) {
307
- parsed += ".readonly()";
244
+ expression += ".readonly()";
245
+ type = `z.ZodReadonly<${type}>`;
308
246
  }
309
- return parsed;
247
+ return { expression, type };
310
248
  };
311
249
  const selectParser = (schema, refs) => {
312
250
  if (its.a.nullable(schema)) {
@@ -334,7 +272,7 @@ const selectParser = (schema, refs) => {
334
272
  return parseNot(schema, refs);
335
273
  }
336
274
  else if (its.an.enum(schema)) {
337
- return parseEnum(schema); //<-- needs to come before primitives
275
+ return parseEnum(schema);
338
276
  }
339
277
  else if (its.a.const(schema)) {
340
278
  return parseConst(schema);
@@ -0,0 +1,24 @@
1
+ import { parseSchema } from "./parseSchema.js";
2
+ import { anyOrUnknown } from "../utils/anyOrUnknown.js";
3
+ export const parseSimpleDiscriminatedOneOf = (schema, refs) => {
4
+ const discriminator = schema.discriminator.propertyName;
5
+ const options = schema.oneOf.map((option, i) => parseSchema(option, {
6
+ ...refs,
7
+ path: [...refs.path, "oneOf", i],
8
+ }));
9
+ if (!schema.oneOf.length) {
10
+ return anyOrUnknown(refs);
11
+ }
12
+ if (schema.oneOf.length === 1) {
13
+ return parseSchema(schema.oneOf[0], {
14
+ ...refs,
15
+ path: [...refs.path, "oneOf", 0],
16
+ });
17
+ }
18
+ const expressions = options.map(o => o.expression).join(", ");
19
+ const types = options.map(o => o.type).join(", ");
20
+ return {
21
+ expression: `z.discriminatedUnion("${discriminator}", [${expressions}])`,
22
+ type: `z.ZodDiscriminatedUnion<"${discriminator}", [${types}]>`,
23
+ };
24
+ };