@gabrielbryk/json-schema-to-zod 2.10.0 → 2.11.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 (138) hide show
  1. package/AGENTS.md +44 -0
  2. package/CHANGELOG.md +35 -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 +225 -67
  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 +11 -7
  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 +168 -29
  25. package/dist/parsers/parseOneOf.js +365 -0
  26. package/dist/{esm/parsers → parsers}/parseSchema.js +56 -110
  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 -141
  79. package/dist/cjs/generators/generateBundle.js +0 -365
  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 -315
  97. package/dist/cjs/parsers/parseOneOf.js +0 -53
  98. package/dist/cjs/parsers/parseSchema.js +0 -411
  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 -137
  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,243 @@
1
+ # Improvement Plan: Aligning with Zod v4 Best Practices
2
+
3
+ Based on analysis of our generated output and Zod v4 limitations research.
4
+
5
+ ---
6
+
7
+ ## Current Problems in Our Generated Output
8
+
9
+ ### Problem 1: `z.record()` with recursive values lacks `z.lazy()`
10
+
11
+ **Current output:**
12
+ ```typescript
13
+ export const TaskList: z.ZodArray<z.ZodRecord<typeof z, typeof Task>> =
14
+ z.array(z.record(z.string(), Task).meta({...}))
15
+ ```
16
+
17
+ **Issues:**
18
+ 1. `Task` referenced directly in `z.record()` - Colin confirmed in #4881 this REQUIRES `z.lazy()`
19
+ 2. Type annotation is completely wrong - `typeof z` as key type is nonsense
20
+ 3. This will cause runtime TDZ errors if Task isn't declared yet
21
+
22
+ **Should be:**
23
+ ```typescript
24
+ export const TaskList = z.array(
25
+ z.record(z.string(), z.lazy(() => Task)).meta({...})
26
+ )
27
+ ```
28
+
29
+ ---
30
+
31
+ ### Problem 2: Union type annotations use `z.ZodTypeAny`
32
+
33
+ **Current output:**
34
+ ```typescript
35
+ export const CallTask: z.ZodUnion<readonly z.ZodTypeAny[]> = z.union([...])
36
+ ```
37
+
38
+ **Issues:**
39
+ 1. `z.ZodTypeAny[]` defeats the entire purpose of type safety
40
+ 2. Loses all type information about what's actually in the union
41
+
42
+ **Should be:**
43
+ Either remove the type annotation entirely:
44
+ ```typescript
45
+ export const CallTask = z.union([...])
46
+ ```
47
+
48
+ Or if we must have one (for circular reference reasons), at least don't use `ZodTypeAny`.
49
+
50
+ ---
51
+
52
+ ### Problem 3: Object type annotations use `Record<string, z.ZodTypeAny>`
53
+
54
+ **Current output:**
55
+ ```typescript
56
+ export const DoTask: z.ZodIntersection<
57
+ z.ZodObject<Record<string, z.ZodTypeAny>>,
58
+ z.ZodIntersection<typeof TaskBase, z.ZodObject<Record<string, z.ZodTypeAny>>>
59
+ > = ...
60
+ ```
61
+
62
+ **Issues:**
63
+ 1. `Record<string, z.ZodTypeAny>` loses all property type information
64
+ 2. The intersection type is overly complex and still loses info
65
+
66
+ **Should be:**
67
+ Remove the type annotation and let TypeScript infer:
68
+ ```typescript
69
+ export const DoTask = z.object({}).and(z.intersection(TaskBase, z.object({...})))
70
+ ```
71
+
72
+ ---
73
+
74
+ ### Problem 4: Getters ARE being used correctly ✅
75
+
76
+ **Current output (GOOD):**
77
+ ```typescript
78
+ get "do"(): z.ZodOptional<typeof TaskList>{ return TaskList.optional() }
79
+ ```
80
+
81
+ This follows the Zod v4 pattern correctly! The getter with explicit return type annotation.
82
+
83
+ ---
84
+
85
+ ### Problem 5: Empty object base with `.and()` is wasteful
86
+
87
+ **Current output:**
88
+ ```typescript
89
+ z.object({}).and(z.intersection(TaskBase, z.object({...})))
90
+ ```
91
+
92
+ **Issue:**
93
+ Starting with `z.object({})` then using `.and()` is unnecessary when there are no direct properties.
94
+
95
+ **Should be:**
96
+ ```typescript
97
+ z.intersection(TaskBase, z.object({...}))
98
+ // OR
99
+ TaskBase.and(z.object({...}))
100
+ ```
101
+
102
+ ---
103
+
104
+ ## Specific Code Changes Required
105
+
106
+ ### Change 1: Fix `z.record()` recursive handling
107
+
108
+ **File:** `src/parsers/parseSchema.ts`
109
+
110
+ When reference is inside a `z.record()` context AND the target is recursive, use `z.lazy()`:
111
+
112
+ ```typescript
113
+ // Check if we're inside a record value context
114
+ const inRecordValue = refs.path.some((p, i) =>
115
+ p === "additionalProperties" ||
116
+ (refs.path[i-1] === "record" && p === "1") // second arg to z.record
117
+ );
118
+
119
+ if (inRecordValue && (isSameCycle || isForwardRef)) {
120
+ return `z.lazy(() => ${refName})`;
121
+ }
122
+ ```
123
+
124
+ ---
125
+
126
+ ### Change 2: Remove bad type annotations
127
+
128
+ **File:** `src/core/emitZod.ts`
129
+
130
+ Current logic adds type annotations when there's a cycle/lazy/getter. Change to:
131
+ 1. Only add type annotation if we can infer a GOOD type
132
+ 2. Never use `z.ZodTypeAny` - either infer correctly or don't annotate
133
+
134
+ ```typescript
135
+ if (isCycle || hasLazy || hasGetter) {
136
+ const inferredType = inferTypeFromExpression(value);
137
+ // Skip annotation if it's useless or wrong
138
+ if (inferredType !== "z.ZodTypeAny" &&
139
+ !inferredType.includes("Record<string, z.ZodTypeAny>") &&
140
+ !inferredType.includes("typeof z,")) {
141
+ return `${shouldExport ? "export " : ""}const ${refName}: ${inferredType} = ${value}`;
142
+ }
143
+ }
144
+ // Let TypeScript infer instead
145
+ return `${shouldExport ? "export " : ""}const ${refName} = ${value}`;
146
+ ```
147
+
148
+ ---
149
+
150
+ ### Change 3: Fix `inferTypeFromExpression` for records
151
+
152
+ **File:** `src/utils/schemaRepresentation.ts`
153
+
154
+ The current inference for `z.record()` is broken. Fix it:
155
+
156
+ ```typescript
157
+ // Handle z.record(K, V)
158
+ if (expr.startsWith("z.record(")) {
159
+ const argsStart = 9;
160
+ const argsEnd = findMatchingParen(expr, argsStart - 1);
161
+ if (argsEnd !== -1) {
162
+ const args = expr.substring(argsStart, argsEnd);
163
+ const commaIndex = findTopLevelComma(args);
164
+ if (commaIndex !== -1) {
165
+ const keyExpr = args.substring(0, commaIndex).trim();
166
+ const valueExpr = args.substring(commaIndex + 1).trim();
167
+
168
+ // CRITICAL: Don't use "typeof z" - that's nonsense
169
+ const keyType = keyExpr === "z.string()" ? "z.ZodString" : inferTypeFromExpression(keyExpr);
170
+ const valueType = inferTypeFromExpression(valueExpr);
171
+
172
+ return `z.ZodRecord<${keyType}, ${valueType}>`;
173
+ }
174
+ }
175
+ }
176
+ ```
177
+
178
+ ---
179
+
180
+ ### Change 4: Remove unnecessary `z.object({}).and()`
181
+
182
+ **File:** `src/parsers/parseObject.ts`
183
+
184
+ When there are no direct properties but there IS composition (allOf), don't create empty object:
185
+
186
+ ```typescript
187
+ // Current (line ~218):
188
+ : hasCompositionKeywords
189
+ ? "z.object({})" // Creates empty object unnecessarily
190
+ : `z.record(z.string(), ${anyOrUnknown(refs)})`;
191
+
192
+ // Should be:
193
+ : hasCompositionKeywords
194
+ ? null // Let composition be the base, not empty object
195
+ : `z.record(z.string(), ${anyOrUnknown(refs)})`;
196
+ ```
197
+
198
+ Then when building output with `.and()`:
199
+ ```typescript
200
+ if (output === null && its.an.allOf(objectSchema)) {
201
+ // No base object, just use the composition directly
202
+ output = parseAllOf(...);
203
+ } else if (its.an.allOf(objectSchema)) {
204
+ output += `.and(${parseAllOf(...)})`;
205
+ }
206
+ ```
207
+
208
+ ---
209
+
210
+ ## Summary of Changes
211
+
212
+ | File | Change | Priority |
213
+ |------|--------|----------|
214
+ | `parseSchema.ts` | Add `z.lazy()` for refs inside `z.record()` | HIGH |
215
+ | `emitZod.ts` | Don't add `z.ZodTypeAny` annotations | HIGH |
216
+ | `schemaRepresentation.ts` | Fix `z.record()` type inference | HIGH |
217
+ | `parseObject.ts` | Remove unnecessary `z.object({}).and()` | MEDIUM |
218
+
219
+ ---
220
+
221
+ ## Expected Outcome
222
+
223
+ **Before:**
224
+ ```typescript
225
+ export const TaskList: z.ZodArray<z.ZodRecord<typeof z, typeof Task>> =
226
+ z.array(z.record(z.string(), Task).meta({...}))
227
+
228
+ export const CallTask: z.ZodUnion<readonly z.ZodTypeAny[]> = z.union([...])
229
+ ```
230
+
231
+ **After:**
232
+ ```typescript
233
+ export const TaskList = z.array(
234
+ z.record(z.string(), z.lazy(() => Task)).meta({...})
235
+ )
236
+
237
+ export const CallTask = z.union([...]) // Let TS infer
238
+ ```
239
+
240
+ This aligns with:
241
+ 1. Colin's guidance that `z.record()` REQUIRES `z.lazy()` for recursive values
242
+ 2. Best practice of not using `z.ZodTypeAny` which defeats type safety
243
+ 3. Zod v4's getter pattern for recursive object properties (which we already do)
@@ -0,0 +1,292 @@
1
+ # Zod v4 Recursive Type Inference: TypeScript Limitations & Workarounds
2
+
3
+ ## Executive Summary
4
+
5
+ Zod v4 introduced a new getter-based approach for recursive schemas that eliminates the need for `z.lazy()` in many cases. However, **this approach has significant TypeScript limitations**, particularly when recursive schemas are used within unions, discriminated unions, records, or when chaining multiple methods.
6
+
7
+ This document consolidates findings from multiple GitHub issues to inform our code generation strategy.
8
+
9
+ ---
10
+
11
+ ## The Core Problem
12
+
13
+ **TypeScript's type inference breaks when recursive schemas are embedded inside certain Zod APIs.**
14
+
15
+ From Colin McDonnell (Zod creator) in [#4691](https://github.com/colinhacks/zod/issues/4691):
16
+ > "You're hitting against TypeScript limitations, not Zod limitations."
17
+
18
+ ### What Works (Standalone Recursive Object)
19
+
20
+ ```typescript
21
+ const Category = z.object({
22
+ name: z.string(),
23
+ get subcategories() {
24
+ return z.array(Category);
25
+ },
26
+ });
27
+
28
+ type Category = z.infer<typeof Category>;
29
+ // { name: string; subcategories: Category[] } ✅ Correct inference
30
+ ```
31
+
32
+ ### What Breaks (Recursive Object in Union)
33
+
34
+ ```typescript
35
+ const ActivityUnion = z.union([
36
+ z.object({
37
+ name: z.string(),
38
+ get subactivities() {
39
+ return z.nullable(z.array(ActivityUnion));
40
+ },
41
+ }),
42
+ z.string(),
43
+ ]);
44
+
45
+ type Activity = z.infer<typeof ActivityUnion>;
46
+ // string | { name: string; subactivities: unknown[] | null } ❌ Lost type info
47
+ ```
48
+
49
+ ---
50
+
51
+ ## Affected Scenarios
52
+
53
+ Based on issues [#4691](https://github.com/colinhacks/zod/issues/4691), [#4351](https://github.com/colinhacks/zod/issues/4351), [#4561](https://github.com/colinhacks/zod/issues/4561), [#4264](https://github.com/colinhacks/zod/issues/4264), [#4502](https://github.com/colinhacks/zod/issues/4502), [#4881](https://github.com/colinhacks/zod/issues/4881):
54
+
55
+ | Scenario | Getter Works? | Notes |
56
+ |----------|---------------|-------|
57
+ | Self-recursive object property | ✅ Yes | The happy path |
58
+ | Mutually recursive objects | ✅ Yes | With explicit type annotations |
59
+ | Recursive inside `z.union()` | ❌ No | Falls back to `unknown` |
60
+ | Recursive inside `z.discriminatedUnion()` | ❌ No | Requires `z.lazy()` |
61
+ | Recursive inside `z.record()` | ❌ No | Must use `z.lazy()` |
62
+ | Recursive inside `z.array()` nested in union | ❌ No | Complex nesting breaks inference |
63
+ | Chaining methods on recursive type | ⚠️ Partial | `.optional()` often breaks it |
64
+ | Using `.extend()` with recursive getters | ⚠️ Partial | Can cause TDZ errors |
65
+ | Using spread operator `{...schema.shape}` | ❌ No | Breaks recursive inference |
66
+
67
+ ---
68
+
69
+ ## Key Rules from Colin McDonnell
70
+
71
+ ### Rule 1: Put Object Types at Top-Level
72
+ From [#4691](https://github.com/colinhacks/zod/issues/4691):
73
+ > "Embedding the object schema declaration inside other APIs can break things."
74
+
75
+ **Bad:**
76
+ ```typescript
77
+ const Schema = z.union([
78
+ z.object({ get recursive() { return Schema; } }), // Inside union
79
+ z.string()
80
+ ]);
81
+ ```
82
+
83
+ **Good:**
84
+ ```typescript
85
+ const RecursiveObject = z.object({
86
+ get recursive() { return RecursiveObject; }
87
+ });
88
+ const Schema = z.union([RecursiveObject, z.string()]);
89
+ ```
90
+
91
+ ### Rule 2: Don't Chain Methods on Recursive Types
92
+ From [#4570](https://github.com/colinhacks/zod/issues/4570):
93
+ > "Rule of thumb: do not directly chain method calls on the recursive types"
94
+
95
+ **Bad:**
96
+ ```typescript
97
+ const Category = z.object({
98
+ get subcategories() {
99
+ return z.array(Category);
100
+ },
101
+ }).meta({ description: "..." }); // .meta() forces eager evaluation
102
+ ```
103
+
104
+ **Good:**
105
+ ```typescript
106
+ const _Category = z.object({
107
+ get subcategories() {
108
+ return z.array(_Category);
109
+ },
110
+ });
111
+ const Category = _Category.meta({ description: "..." });
112
+ ```
113
+
114
+ ### Rule 3: Avoid Nesting Function Calls in Getters
115
+ From [#4264](https://github.com/colinhacks/zod/issues/4264):
116
+ > "You'll have a hard time using top-level functions like `z.optional()` or `z.discriminatedUnion()` inside getters. [...] type checking is the enemy of recursive type inference"
117
+
118
+ **Bad:**
119
+ ```typescript
120
+ get children() {
121
+ return z.optional(z.array(Schema)); // Function call
122
+ }
123
+ ```
124
+
125
+ **Good:**
126
+ ```typescript
127
+ get children() {
128
+ return z.array(Schema).optional(); // Method chain
129
+ }
130
+ ```
131
+
132
+ ### Rule 4: Use Explicit Type Annotations When Needed
133
+ From [#4351](https://github.com/colinhacks/zod/issues/4351):
134
+
135
+ ```typescript
136
+ const Activity = z.object({
137
+ name: z.string(),
138
+ get subactivities(): z.ZodNullable<z.ZodArray<typeof Activity>> {
139
+ return z.nullable(z.array(Activity));
140
+ },
141
+ });
142
+ ```
143
+
144
+ ### Rule 5: Use `readonly` in Union Type Annotations
145
+ From [#4502](https://github.com/colinhacks/zod/issues/4502):
146
+
147
+ ```typescript
148
+ // Without readonly - FAILS
149
+ get children(): ZodArray<ZodUnion<[typeof span, typeof tagB]>> { ... }
150
+
151
+ // With readonly - WORKS
152
+ get children(): ZodArray<ZodUnion<readonly [typeof span, typeof tagB]>> { ... }
153
+ ```
154
+
155
+ ---
156
+
157
+ ## When `z.lazy()` Is Still Required
158
+
159
+ From [#4881](https://github.com/colinhacks/zod/issues/4881), Colin confirms:
160
+ > "Stick with `z.lazy` as you're doing. Getters provided a clean way to 'lazify' object types [...] I could add callback-style APIs to every `z` factory [...] but it would be a big lift for comparatively minimal upside."
161
+
162
+ **`z.lazy()` is required for:**
163
+ 1. Recursive types inside `z.record()`
164
+ 2. Complex recursive unions where getters fail
165
+ 3. Non-object recursive schemas (arrays, tuples used at top-level)
166
+ 4. When TypeScript inference completely fails
167
+
168
+ ---
169
+
170
+ ## Working Patterns
171
+
172
+ ### Pattern 1: Extract and Compose (Recommended for Unions)
173
+ From [#4691](https://github.com/colinhacks/zod/issues/4691):
174
+
175
+ ```typescript
176
+ const ActivitySchemaBase = z.object({
177
+ name: z.string(),
178
+ });
179
+
180
+ const Subactivity = ActivitySchemaBase.extend({
181
+ get subactivities(): z.ZodNullable<z.ZodArray<typeof ActivitySchema>> {
182
+ return z.nullable(z.array(ActivitySchema));
183
+ },
184
+ });
185
+
186
+ const ActivitySchema = z.union([z.string(), Subactivity]);
187
+ ```
188
+
189
+ ### Pattern 2: Explicit Input/Output Types
190
+ From [#4691](https://github.com/colinhacks/zod/issues/4691):
191
+
192
+ ```typescript
193
+ type IActivityInput = string | { name: string; subactivities: IActivityInput[] | null };
194
+ type IActivityOutput = string | { name: string; subactivities: IActivityOutput[] | null };
195
+
196
+ const ActivitySchema: z.ZodType<IActivityOutput, IActivityInput> = z.union([
197
+ z.string(),
198
+ z.object({
199
+ name: z.string(),
200
+ get subactivities() {
201
+ return z.nullable(z.array(ActivitySchema));
202
+ },
203
+ }),
204
+ ]);
205
+ ```
206
+
207
+ ### Pattern 3: Use `z.lazy()` for Records
208
+ From [#4881](https://github.com/colinhacks/zod/issues/4881):
209
+
210
+ ```typescript
211
+ const TreeNode = z.object({
212
+ value: z.string(),
213
+ children: z.record(z.string(), z.lazy(() => TreeNode)),
214
+ });
215
+ ```
216
+
217
+ ### Pattern 4: Avoid `.extend()` TDZ Issues
218
+ From [#4691](https://github.com/colinhacks/zod/issues/4691) (kfranqueiro's comment):
219
+
220
+ **Bad (TDZ error at runtime):**
221
+ ```typescript
222
+ const Recursive = z.object({
223
+ get children() { return ArrayOfThings.optional(); }
224
+ });
225
+ const ArrayOfThings = z.array(Reusable.extend(Recursive.shape)); // TDZ!
226
+ ```
227
+
228
+ **Good:**
229
+ ```typescript
230
+ const ArrayOfThings = z.array(
231
+ Reusable.extend({
232
+ get children() { return ArrayOfThings.optional(); }
233
+ })
234
+ );
235
+ ```
236
+
237
+ ---
238
+
239
+ ## Implications for json-schema-to-zod
240
+
241
+ ### What We Should Do
242
+
243
+ 1. **For recursive object properties**: Use getters with explicit type annotations
244
+ ```typescript
245
+ get subcategories(): z.ZodArray<typeof Category> {
246
+ return z.array(Category);
247
+ }
248
+ ```
249
+
250
+ 2. **For unions containing recursive schemas**: Define member schemas at top-level first, then compose
251
+ ```typescript
252
+ export const CallTask = z.object({ ... });
253
+ export const DoTask = z.object({ ... });
254
+ export const Task = z.union([CallTask, DoTask, ...]); // Direct refs
255
+ ```
256
+
257
+ 3. **For `z.record()` with recursive values**: Use `z.lazy()`
258
+ ```typescript
259
+ z.record(z.string(), z.lazy(() => Task))
260
+ ```
261
+
262
+ 4. **For type annotations**: Use correct types with `readonly` where needed
263
+ ```typescript
264
+ z.ZodUnion<readonly [typeof A, typeof B]> // Not z.ZodTypeAny!
265
+ ```
266
+
267
+ 5. **Avoid chaining methods** on schemas that contain recursive getters
268
+
269
+ ### What We Should NOT Do
270
+
271
+ 1. Don't wrap union members in `z.lazy()` if they're already defined
272
+ 2. Don't use `z.ZodTypeAny` as a type annotation - it defeats the purpose
273
+ 3. Don't embed object schema definitions inside union calls
274
+ 4. Don't use spread operator for recursive schema composition
275
+
276
+ ---
277
+
278
+ ## References
279
+
280
+ - [#4691](https://github.com/colinhacks/zod/issues/4691) - Recursive union type inference (main issue)
281
+ - [#4351](https://github.com/colinhacks/zod/issues/4351) - Recursive record inference
282
+ - [#4561](https://github.com/colinhacks/zod/issues/4561) - z.lazy with discriminatedUnion
283
+ - [#4570](https://github.com/colinhacks/zod/issues/4570) - Method chaining breaks recursion
284
+ - [#4592](https://github.com/colinhacks/zod/issues/4592) - Optional breaking inference
285
+ - [#4610](https://github.com/colinhacks/zod/issues/4610) - Complex nesting exceeds TS limits
286
+ - [#4625](https://github.com/colinhacks/zod/issues/4625) - `.optional()` breaks mutual recursion
287
+ - [#4264](https://github.com/colinhacks/zod/issues/4264) - Discriminated union recursion
288
+ - [#4502](https://github.com/colinhacks/zod/issues/4502) - Mapped type circular reference
289
+ - [#4783](https://github.com/colinhacks/zod/issues/4783) - discriminatedUnion inference
290
+ - [#4881](https://github.com/colinhacks/zod/issues/4881) - Recursive types in z.record()
291
+ - [Zod v4 Docs: Recursive Objects](https://zod.dev/api#recursive-objects)
292
+ - [Zod v4 Docs: Circularity Errors](https://zod.dev/api#circularity-errors)
@@ -13,7 +13,7 @@
13
13
  ## Proposed Architecture
14
14
  - **Analyzer (`analyzeSchema`)**: Convert JsonSchema + options into an intermediate representation (IR) containing symbols, ref pointer map, dependency graph, cycle info, and metadata flags. No code strings.
15
15
  - **Emitters**:
16
- - `emitZod(ir, emitOptions)`: IR → zod code (esm/cjs/none), with naming hooks and export policies.
16
+ - `emitZod(ir, emitOptions)`: IR → zod code (esm), with naming hooks and export policies.
17
17
  - `emitTypes(ir, typeOptions)`: optional type-only exports (for nested types or barrel typing).
18
18
  - **Strategies**:
19
19
  - `SingleFileStrategy`: analyze root → emit zod once.