@gabrielbryk/json-schema-to-zod 2.10.1 → 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.
- package/AGENTS.md +44 -0
- package/CHANGELOG.md +28 -0
- package/README.md +6 -33
- package/check-types-lift.sh +23 -0
- package/check-types.sh +20 -0
- package/dist/{esm/cli.js → cli.js} +0 -6
- package/dist/{esm/core → core}/analyzeSchema.js +4 -5
- package/dist/core/emitZod.js +263 -0
- package/dist/{esm/generators → generators}/generateBundle.js +26 -13
- package/dist/{esm/index.js → index.js} +6 -0
- package/dist/jsonSchemaToZod.js +17 -0
- package/dist/parsers/parseAllOf.js +125 -0
- package/dist/parsers/parseAnyOf.js +28 -0
- package/dist/{esm/parsers → parsers}/parseArray.js +27 -11
- package/dist/parsers/parseBoolean.js +4 -0
- package/dist/parsers/parseConst.js +22 -0
- package/dist/parsers/parseEnum.js +35 -0
- package/dist/{esm/parsers → parsers}/parseIfThenElse.js +10 -6
- package/dist/parsers/parseMultipleType.js +10 -0
- package/dist/parsers/parseNot.js +14 -0
- package/dist/parsers/parseNull.js +4 -0
- package/dist/parsers/parseNullable.js +12 -0
- package/dist/{esm/parsers → parsers}/parseNumber.js +4 -1
- package/dist/{esm/parsers → parsers}/parseObject.js +167 -31
- package/dist/parsers/parseOneOf.js +365 -0
- package/dist/{esm/parsers → parsers}/parseSchema.js +55 -117
- package/dist/parsers/parseSimpleDiscriminatedOneOf.js +24 -0
- package/dist/{esm/parsers → parsers}/parseString.js +29 -18
- package/dist/types/Types.d.ts +32 -4
- package/dist/types/core/analyzeSchema.d.ts +3 -2
- package/dist/types/generators/generateBundle.d.ts +0 -2
- package/dist/types/index.d.ts +6 -0
- package/dist/types/parsers/parseAllOf.d.ts +2 -2
- package/dist/types/parsers/parseAnyOf.d.ts +2 -2
- package/dist/types/parsers/parseArray.d.ts +2 -2
- package/dist/types/parsers/parseBoolean.d.ts +2 -1
- package/dist/types/parsers/parseConst.d.ts +2 -2
- package/dist/types/parsers/parseDefault.d.ts +2 -2
- package/dist/types/parsers/parseEnum.d.ts +2 -2
- package/dist/types/parsers/parseIfThenElse.d.ts +2 -2
- package/dist/types/parsers/parseMultipleType.d.ts +2 -2
- package/dist/types/parsers/parseNot.d.ts +2 -2
- package/dist/types/parsers/parseNull.d.ts +2 -1
- package/dist/types/parsers/parseNullable.d.ts +2 -2
- package/dist/types/parsers/parseNumber.d.ts +2 -2
- package/dist/types/parsers/parseObject.d.ts +2 -2
- package/dist/types/parsers/parseOneOf.d.ts +2 -2
- package/dist/types/parsers/parseSchema.d.ts +2 -2
- package/dist/types/parsers/parseSimpleDiscriminatedOneOf.d.ts +2 -2
- package/dist/types/parsers/parseString.d.ts +2 -2
- package/dist/types/utils/anyOrUnknown.d.ts +5 -4
- package/dist/types/utils/esmEmitter.d.ts +29 -0
- package/dist/types/utils/extractInlineObject.d.ts +15 -0
- package/dist/types/utils/liftInlineObjects.d.ts +21 -0
- package/dist/types/utils/namingService.d.ts +21 -0
- package/dist/types/utils/resolveRef.d.ts +7 -0
- package/dist/types/utils/schemaRepresentation.d.ts +71 -0
- package/dist/utils/anyOrUnknown.js +13 -0
- package/dist/{esm/utils → utils}/buildRefRegistry.js +4 -0
- package/dist/utils/esmEmitter.js +87 -0
- package/dist/utils/extractInlineObject.js +119 -0
- package/dist/utils/liftInlineObjects.js +476 -0
- package/dist/utils/namingService.js +58 -0
- package/dist/utils/resolveRef.js +92 -0
- package/dist/utils/schemaRepresentation.js +569 -0
- package/docs/IMPROVEMENT-PLAN.md +243 -0
- package/docs/ZOD-V4-RECURSIVE-TYPE-LIMITATIONS.md +292 -0
- package/docs/proposals/bundle-refactor.md +1 -1
- package/docs/proposals/discriminated-union-with-default.md +248 -0
- package/docs/proposals/inline-object-lifting.md +77 -0
- package/eslint.config.js +4 -2
- package/jest.config.mjs +19 -0
- package/package.json +17 -20
- package/scripts/generateWorkflowSchema.ts +0 -1
- package/dist/cjs/Types.js +0 -2
- package/dist/cjs/cli.js +0 -70
- package/dist/cjs/core/analyzeSchema.js +0 -62
- package/dist/cjs/core/emitZod.js +0 -157
- package/dist/cjs/generators/generateBundle.js +0 -510
- package/dist/cjs/index.js +0 -50
- package/dist/cjs/jsonSchemaToZod.js +0 -10
- package/dist/cjs/package.json +0 -1
- package/dist/cjs/parsers/parseAllOf.js +0 -46
- package/dist/cjs/parsers/parseAnyOf.js +0 -18
- package/dist/cjs/parsers/parseArray.js +0 -90
- package/dist/cjs/parsers/parseBoolean.js +0 -5
- package/dist/cjs/parsers/parseConst.js +0 -7
- package/dist/cjs/parsers/parseDefault.js +0 -8
- package/dist/cjs/parsers/parseEnum.js +0 -21
- package/dist/cjs/parsers/parseIfThenElse.js +0 -35
- package/dist/cjs/parsers/parseMultipleType.js +0 -10
- package/dist/cjs/parsers/parseNot.js +0 -12
- package/dist/cjs/parsers/parseNull.js +0 -5
- package/dist/cjs/parsers/parseNullable.js +0 -12
- package/dist/cjs/parsers/parseNumber.js +0 -116
- package/dist/cjs/parsers/parseObject.js +0 -318
- package/dist/cjs/parsers/parseOneOf.js +0 -53
- package/dist/cjs/parsers/parseSchema.js +0 -419
- package/dist/cjs/parsers/parseSimpleDiscriminatedOneOf.js +0 -21
- package/dist/cjs/parsers/parseString.js +0 -317
- package/dist/cjs/utils/anyOrUnknown.js +0 -14
- package/dist/cjs/utils/buildRefRegistry.js +0 -56
- package/dist/cjs/utils/cliTools.js +0 -108
- package/dist/cjs/utils/cycles.js +0 -113
- package/dist/cjs/utils/half.js +0 -7
- package/dist/cjs/utils/jsdocs.js +0 -20
- package/dist/cjs/utils/omit.js +0 -11
- package/dist/cjs/utils/resolveUri.js +0 -16
- package/dist/cjs/utils/withMessage.js +0 -21
- package/dist/cjs/zodToJsonSchema.js +0 -89
- package/dist/esm/core/emitZod.js +0 -153
- package/dist/esm/jsonSchemaToZod.js +0 -6
- package/dist/esm/package.json +0 -1
- package/dist/esm/parsers/parseAllOf.js +0 -43
- package/dist/esm/parsers/parseAnyOf.js +0 -14
- package/dist/esm/parsers/parseBoolean.js +0 -1
- package/dist/esm/parsers/parseConst.js +0 -3
- package/dist/esm/parsers/parseEnum.js +0 -17
- package/dist/esm/parsers/parseMultipleType.js +0 -6
- package/dist/esm/parsers/parseNot.js +0 -8
- package/dist/esm/parsers/parseNull.js +0 -1
- package/dist/esm/parsers/parseNullable.js +0 -8
- package/dist/esm/parsers/parseOneOf.js +0 -49
- package/dist/esm/parsers/parseSimpleDiscriminatedOneOf.js +0 -17
- package/dist/esm/utils/anyOrUnknown.js +0 -10
- package/jest.config.cjs +0 -4
- package/postcjs.cjs +0 -1
- package/postesm.cjs +0 -1
- /package/dist/{esm/Types.js → Types.js} +0 -0
- /package/dist/{esm/parsers → parsers}/parseDefault.js +0 -0
- /package/dist/{esm/utils → utils}/cliTools.js +0 -0
- /package/dist/{esm/utils → utils}/cycles.js +0 -0
- /package/dist/{esm/utils → utils}/half.js +0 -0
- /package/dist/{esm/utils → utils}/jsdocs.js +0 -0
- /package/dist/{esm/utils → utils}/omit.js +0 -0
- /package/dist/{esm/utils → utils}/resolveUri.js +0 -0
- /package/dist/{esm/utils → utils}/withMessage.js +0 -0
- /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
|
|
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.
|