@gabrielbryk/json-schema-to-zod 2.12.0 → 2.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/.github/RELEASE_SETUP.md +120 -0
  2. package/.github/TOOLING_GUIDE.md +169 -0
  3. package/.github/dependabot.yml +52 -0
  4. package/.github/workflows/ci.yml +33 -0
  5. package/.github/workflows/release.yml +12 -4
  6. package/.github/workflows/security.yml +40 -0
  7. package/.husky/commit-msg +1 -0
  8. package/.husky/pre-commit +1 -0
  9. package/.lintstagedrc.json +3 -0
  10. package/.prettierrc +20 -0
  11. package/AGENTS.md +7 -0
  12. package/CHANGELOG.md +13 -4
  13. package/README.md +9 -9
  14. package/commitlint.config.js +24 -0
  15. package/createIndex.ts +4 -4
  16. package/dist/cli.js +3 -4
  17. package/dist/core/analyzeSchema.js +28 -5
  18. package/dist/core/emitZod.js +11 -4
  19. package/dist/generators/generateBundle.js +67 -92
  20. package/dist/parsers/parseAllOf.js +11 -12
  21. package/dist/parsers/parseAnyOf.js +2 -2
  22. package/dist/parsers/parseArray.js +38 -12
  23. package/dist/parsers/parseMultipleType.js +2 -2
  24. package/dist/parsers/parseNumber.js +44 -102
  25. package/dist/parsers/parseObject.js +138 -393
  26. package/dist/parsers/parseOneOf.js +57 -100
  27. package/dist/parsers/parseSchema.js +132 -55
  28. package/dist/parsers/parseSimpleDiscriminatedOneOf.js +2 -2
  29. package/dist/parsers/parseString.js +113 -253
  30. package/dist/types/Types.d.ts +22 -1
  31. package/dist/types/core/analyzeSchema.d.ts +1 -0
  32. package/dist/types/generators/generateBundle.d.ts +1 -1
  33. package/dist/utils/cliTools.js +1 -2
  34. package/dist/utils/esmEmitter.js +6 -2
  35. package/dist/utils/extractInlineObject.js +1 -3
  36. package/dist/utils/jsdocs.js +1 -4
  37. package/dist/utils/liftInlineObjects.js +76 -15
  38. package/dist/utils/resolveRef.js +35 -10
  39. package/dist/utils/schemaRepresentation.js +35 -66
  40. package/dist/zodToJsonSchema.js +1 -2
  41. package/docs/IMPROVEMENT-PLAN.md +30 -12
  42. package/docs/ZOD-V4-RECURSIVE-TYPE-LIMITATIONS.md +70 -25
  43. package/docs/proposals/allof-required-merging.md +10 -4
  44. package/docs/proposals/bundle-refactor.md +10 -4
  45. package/docs/proposals/discriminated-union-with-default.md +18 -14
  46. package/docs/proposals/inline-object-lifting.md +15 -5
  47. package/docs/proposals/ref-anchor-support.md +11 -0
  48. package/output.txt +67 -0
  49. package/package.json +18 -5
  50. package/scripts/generateWorkflowSchema.ts +5 -14
  51. package/scripts/regenerate_bundle.ts +25 -0
  52. package/tsc_output.txt +542 -0
  53. package/tsc_output_2.txt +489 -0
@@ -13,6 +13,7 @@ This document consolidates findings from multiple GitHub issues to inform our co
13
13
  **TypeScript's type inference breaks when recursive schemas are embedded inside certain Zod APIs.**
14
14
 
15
15
  From Colin McDonnell (Zod creator) in [#4691](https://github.com/colinhacks/zod/issues/4691):
16
+
16
17
  > "You're hitting against TypeScript limitations, not Zod limitations."
17
18
 
18
19
  ### What Works (Standalone Recursive Object)
@@ -52,56 +53,72 @@ type Activity = z.infer<typeof ActivityUnion>;
52
53
 
53
54
  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
 
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 |
56
+ | Scenario | Getter Works? | Notes |
57
+ | -------------------------------------------- | ------------- | -------------------------------- |
58
+ | Self-recursive object property | ✅ Yes | The happy path |
59
+ | Mutually recursive objects | ✅ Yes | With explicit type annotations |
60
+ | Recursive inside `z.union()` | ❌ No | Falls back to `unknown` |
61
+ | Recursive inside `z.discriminatedUnion()` | ❌ No | Requires `z.lazy()` |
62
+ | Recursive inside `z.record()` | ❌ No | Must use `z.lazy()` |
63
+ | Recursive inside `z.array()` nested in union | ❌ No | Complex nesting breaks inference |
64
+ | Chaining methods on recursive type | ⚠️ Partial | `.optional()` often breaks it |
65
+ | Using `.extend()` with recursive getters | ⚠️ Partial | Can cause TDZ errors |
66
+ | Using spread operator `{...schema.shape}` | ❌ No | Breaks recursive inference |
66
67
 
67
68
  ---
68
69
 
69
70
  ## Key Rules from Colin McDonnell
70
71
 
71
72
  ### Rule 1: Put Object Types at Top-Level
73
+
72
74
  From [#4691](https://github.com/colinhacks/zod/issues/4691):
75
+
73
76
  > "Embedding the object schema declaration inside other APIs can break things."
74
77
 
75
78
  **Bad:**
79
+
76
80
  ```typescript
77
81
  const Schema = z.union([
78
- z.object({ get recursive() { return Schema; } }), // Inside union
79
- z.string()
82
+ z.object({
83
+ get recursive() {
84
+ return Schema;
85
+ },
86
+ }), // Inside union
87
+ z.string(),
80
88
  ]);
81
89
  ```
82
90
 
83
91
  **Good:**
92
+
84
93
  ```typescript
85
94
  const RecursiveObject = z.object({
86
- get recursive() { return RecursiveObject; }
95
+ get recursive() {
96
+ return RecursiveObject;
97
+ },
87
98
  });
88
99
  const Schema = z.union([RecursiveObject, z.string()]);
89
100
  ```
90
101
 
91
102
  ### Rule 2: Don't Chain Methods on Recursive Types
103
+
92
104
  From [#4570](https://github.com/colinhacks/zod/issues/4570):
105
+
93
106
  > "Rule of thumb: do not directly chain method calls on the recursive types"
94
107
 
95
108
  **Bad:**
109
+
96
110
  ```typescript
97
- const Category = z.object({
98
- get subcategories() {
99
- return z.array(Category);
100
- },
101
- }).meta({ description: "..." }); // .meta() forces eager evaluation
111
+ const Category = z
112
+ .object({
113
+ get subcategories() {
114
+ return z.array(Category);
115
+ },
116
+ })
117
+ .meta({ description: "..." }); // .meta() forces eager evaluation
102
118
  ```
103
119
 
104
120
  **Good:**
121
+
105
122
  ```typescript
106
123
  const _Category = z.object({
107
124
  get subcategories() {
@@ -112,10 +129,13 @@ const Category = _Category.meta({ description: "..." });
112
129
  ```
113
130
 
114
131
  ### Rule 3: Avoid Nesting Function Calls in Getters
132
+
115
133
  From [#4264](https://github.com/colinhacks/zod/issues/4264):
134
+
116
135
  > "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
136
 
118
137
  **Bad:**
138
+
119
139
  ```typescript
120
140
  get children() {
121
141
  return z.optional(z.array(Schema)); // Function call
@@ -123,6 +143,7 @@ get children() {
123
143
  ```
124
144
 
125
145
  **Good:**
146
+
126
147
  ```typescript
127
148
  get children() {
128
149
  return z.array(Schema).optional(); // Method chain
@@ -130,6 +151,7 @@ get children() {
130
151
  ```
131
152
 
132
153
  ### Rule 4: Use Explicit Type Annotations When Needed
154
+
133
155
  From [#4351](https://github.com/colinhacks/zod/issues/4351):
134
156
 
135
157
  ```typescript
@@ -142,6 +164,7 @@ const Activity = z.object({
142
164
  ```
143
165
 
144
166
  ### Rule 5: Use `readonly` in Union Type Annotations
167
+
145
168
  From [#4502](https://github.com/colinhacks/zod/issues/4502):
146
169
 
147
170
  ```typescript
@@ -157,9 +180,11 @@ get children(): ZodArray<ZodUnion<readonly [typeof span, typeof tagB]>> { ... }
157
180
  ## When `z.lazy()` Is Still Required
158
181
 
159
182
  From [#4881](https://github.com/colinhacks/zod/issues/4881), Colin confirms:
183
+
160
184
  > "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
185
 
162
186
  **`z.lazy()` is required for:**
187
+
163
188
  1. Recursive types inside `z.record()`
164
189
  2. Complex recursive unions where getters fail
165
190
  3. Non-object recursive schemas (arrays, tuples used at top-level)
@@ -170,6 +195,7 @@ From [#4881](https://github.com/colinhacks/zod/issues/4881), Colin confirms:
170
195
  ## Working Patterns
171
196
 
172
197
  ### Pattern 1: Extract and Compose (Recommended for Unions)
198
+
173
199
  From [#4691](https://github.com/colinhacks/zod/issues/4691):
174
200
 
175
201
  ```typescript
@@ -187,6 +213,7 @@ const ActivitySchema = z.union([z.string(), Subactivity]);
187
213
  ```
188
214
 
189
215
  ### Pattern 2: Explicit Input/Output Types
216
+
190
217
  From [#4691](https://github.com/colinhacks/zod/issues/4691):
191
218
 
192
219
  ```typescript
@@ -205,31 +232,42 @@ const ActivitySchema: z.ZodType<IActivityOutput, IActivityInput> = z.union([
205
232
  ```
206
233
 
207
234
  ### Pattern 3: Use `z.lazy()` for Records
235
+
208
236
  From [#4881](https://github.com/colinhacks/zod/issues/4881):
209
237
 
210
238
  ```typescript
211
239
  const TreeNode = z.object({
212
240
  value: z.string(),
213
- children: z.record(z.string(), z.lazy(() => TreeNode)),
241
+ children: z.record(
242
+ z.string(),
243
+ z.lazy(() => TreeNode)
244
+ ),
214
245
  });
215
246
  ```
216
247
 
217
248
  ### Pattern 4: Avoid `.extend()` TDZ Issues
249
+
218
250
  From [#4691](https://github.com/colinhacks/zod/issues/4691) (kfranqueiro's comment):
219
251
 
220
252
  **Bad (TDZ error at runtime):**
253
+
221
254
  ```typescript
222
255
  const Recursive = z.object({
223
- get children() { return ArrayOfThings.optional(); }
256
+ get children() {
257
+ return ArrayOfThings.optional();
258
+ },
224
259
  });
225
- const ArrayOfThings = z.array(Reusable.extend(Recursive.shape)); // TDZ!
260
+ const ArrayOfThings = z.array(Reusable.extend(Recursive.shape)); // TDZ!
226
261
  ```
227
262
 
228
263
  **Good:**
264
+
229
265
  ```typescript
230
266
  const ArrayOfThings = z.array(
231
267
  Reusable.extend({
232
- get children() { return ArrayOfThings.optional(); }
268
+ get children() {
269
+ return ArrayOfThings.optional();
270
+ },
233
271
  })
234
272
  );
235
273
  ```
@@ -241,6 +279,7 @@ const ArrayOfThings = z.array(
241
279
  ### What We Should Do
242
280
 
243
281
  1. **For recursive object properties**: Use getters with explicit type annotations
282
+
244
283
  ```typescript
245
284
  get subcategories(): z.ZodArray<typeof Category> {
246
285
  return z.array(Category);
@@ -248,6 +287,7 @@ const ArrayOfThings = z.array(
248
287
  ```
249
288
 
250
289
  2. **For unions containing recursive schemas**: Define member schemas at top-level first, then compose
290
+
251
291
  ```typescript
252
292
  export const CallTask = z.object({ ... });
253
293
  export const DoTask = z.object({ ... });
@@ -255,13 +295,18 @@ const ArrayOfThings = z.array(
255
295
  ```
256
296
 
257
297
  3. **For `z.record()` with recursive values**: Use `z.lazy()`
298
+
258
299
  ```typescript
259
- z.record(z.string(), z.lazy(() => Task))
300
+ z.record(
301
+ z.string(),
302
+ z.lazy(() => Task)
303
+ );
260
304
  ```
261
305
 
262
306
  4. **For type annotations**: Use correct types with `readonly` where needed
307
+
263
308
  ```typescript
264
- z.ZodUnion<readonly [typeof A, typeof B]> // Not z.ZodTypeAny!
309
+ z.ZodUnion<readonly [typeof A, typeof B]>; // Not z.ZodTypeAny!
265
310
  ```
266
311
 
267
312
  5. **Avoid chaining methods** on schemas that contain recursive getters
@@ -1,6 +1,7 @@
1
1
  # Proposal: Strengthen `allOf` required handling
2
2
 
3
3
  ## Problem
4
+
4
5
  - Required keys are dropped when properties live in a different `allOf` member than the `required` array (e.g., workflow schema `call`/`with`).
5
6
  - Spread path only looks at each member’s own `required`, ignoring parent + sibling required-only members.
6
7
  - Intersection fallback also skips parent required enforcement because `parseObject` disables missing-key checks when composition keywords exist.
@@ -8,12 +9,14 @@
8
9
  - Conflicting property definitions across `allOf` members fail silently (often ending up as permissive intersections).
9
10
 
10
11
  ## Goals
11
- 1) Preserve required semantics across `allOf`, even when properties and `required` are split across members.
12
- 2) Keep spread optimization where safe; otherwise, enforce required keys in the intersection path.
13
- 3) Respect `unevaluatedProperties`/`additionalProperties` constraints from stricter members.
14
- 4) Surface conflicts clearly instead of silently widening to `z.any()`.
12
+
13
+ 1. Preserve required semantics across `allOf`, even when properties and `required` are split across members.
14
+ 2. Keep spread optimization where safe; otherwise, enforce required keys in the intersection path.
15
+ 3. Respect `unevaluatedProperties`/`additionalProperties` constraints from stricter members.
16
+ 4. Surface conflicts clearly instead of silently widening to `z.any()`.
15
17
 
16
18
  ## Proposed changes
19
+
17
20
  - **Normalize `allOf` members up front (done partially):**
18
21
  - Add `type: "object"` when shape hints exist and merge parent+member required into any member that actually declares those properties (already implemented for the spread path; extend to intersection path and `$ref` resolution).
19
22
 
@@ -34,6 +37,7 @@
34
37
  - If multiple members define the same property with incompatible primitive types (e.g., `string` vs `number`), emit a `superRefine` that fails with a clear message instead of silently widening.
35
38
 
36
39
  ## Testing
40
+
37
41
  - Unit tests in `test/parsers/parseAllOf.test.ts` and `parseObject.test.ts` covering:
38
42
  - properties-only + required-only split (current workflow pattern).
39
43
  - Overlapping required across multiple members (including parent required).
@@ -43,11 +47,13 @@
43
47
  - Conflict detection on incompatible property types.
44
48
 
45
49
  ## Risks & mitigations
50
+
46
51
  - **Over-enforcement**: Ensure required keys are only checked when at least one member defines the property. Filter combined required sets accordingly.
47
52
  - **Ref resolution cost**: Cache resolved `$ref` shapes when harvesting required sets to avoid repeated work.
48
53
  - **False conflicts**: Limit conflict detection to clear primitive mismatches; avoid flagging unions/anyOf/any as conflicts.
49
54
 
50
55
  ## Rollout
56
+
51
57
  - Implement normalization + intersection required enforcement first, add tests for workflow fixture regression.
52
58
  - Follow with `$ref`-aware required merge and policy reconciliation tests.
53
59
  - Add conflict detection last (guarded behind a clear error message) to avoid unexpected breakage.
@@ -1,16 +1,19 @@
1
1
  # Schema Bundling Refactor (Analyzer + Emitters)
2
2
 
3
3
  ## Context
4
+
4
5
  - `generateSchemaBundle` currently recurses via `parserOverride` and can overflow the stack when inline `$defs` are present (root hits immediately). Inline `$defs` inside `$defs` are also overwritten when stitching schemas.
5
6
  - The conversion pipeline mixes concerns: parsing/analysis, code emission, and bundling strategy live together in `jsonSchemaToZod` and the bundle generator.
6
7
 
7
8
  ## Goals
9
+
8
10
  - Single responsibility: analyze JsonSchema once, emit code through pluggable strategies (single file, bundle, nested types).
9
11
  - Open for extension: new emitters (e.g., type-only), new ref resolution policies, without touching the analyzer.
10
12
  - Safer bundling: no recursive parser overrides; import-aware ref resolution; preserve inline `$defs`.
11
13
  - Testable units: analyzer IR and emitters have focused tests; bundle strategy tested with snapshots.
12
14
 
13
15
  ## Proposed Architecture
16
+
14
17
  - **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
18
  - **Emitters**:
16
19
  - `emitZod(ir, emitOptions)`: IR → zod code (esm), with naming hooks and export policies.
@@ -26,18 +29,21 @@
26
29
  - `jsonSchemaToZod(schema, options): string` becomes a thin wrapper (analyze + emit single file).
27
30
 
28
31
  ## SOLID Alignment
32
+
29
33
  - SRP: analyzer, emitter, strategy are separate modules.
30
34
  - OCP: new emitters/strategies plug in without changing analyzer.
31
35
  - LSP/ISP: narrow contracts (naming hooks, ref resolution hooks) instead of monolithic option bags.
32
36
  - DIP: bundle strategy depends on IR abstractions, not on concrete `jsonSchemaToZod` string output.
33
37
 
34
38
  ## Migration Plan
35
- 1) **Foundations**: Extract analyzer + zod emitter modules; make `jsonSchemaToZod` call them. Preserve output parity and option validation. Add tests around analyzer/emitter.
36
- 2) **Bundle Strategy**: Rework `generateSchemaBundle` to use the analyzer IR and an import-aware ref strategy; remove recursive `parserOverride`; preserve inline `$defs` within defs.
37
- 3) **Nested Types**: Move nested type extraction to IR-based walker; emit via `emitTypes`.
38
- 4) **Cleanups & API polish**: Reduce option bag coupling; document new APIs; consider default export ergonomics.
39
+
40
+ 1. **Foundations**: Extract analyzer + zod emitter modules; make `jsonSchemaToZod` call them. Preserve output parity and option validation. Add tests around analyzer/emitter.
41
+ 2. **Bundle Strategy**: Rework `generateSchemaBundle` to use the analyzer IR and an import-aware ref strategy; remove recursive `parserOverride`; preserve inline `$defs` within defs.
42
+ 3. **Nested Types**: Move nested type extraction to IR-based walker; emit via `emitTypes`.
43
+ 4. **Cleanups & API polish**: Reduce option bag coupling; document new APIs; consider default export ergonomics.
39
44
 
40
45
  ## Risks / Mitigations
46
+
41
47
  - Risk: Output regressions. Mitigation: snapshot tests for single-file and bundle outputs.
42
48
  - Risk: Bundle import mapping errors. Mitigation: ref-strategy unit tests (cycles, unknown refs, cross-def).
43
49
  - Risk: Incremental refactor churn. Mitigation: keep `jsonSchemaToZod` wrapper stable while internals shift; land in stages with tests.
@@ -25,7 +25,7 @@ callTask:
25
25
  properties:
26
26
  call: { const: "http" }
27
27
  # ... more known variants
28
- - title: CallFunction # Default/catch-all
28
+ - title: CallFunction # Default/catch-all
29
29
  properties:
30
30
  call:
31
31
  not:
@@ -44,8 +44,8 @@ z.union([
44
44
  openapiSchema,
45
45
  a2aSchema,
46
46
  mcpSchema,
47
- callFunctionSchema // Default case
48
- ])
47
+ callFunctionSchema, // Default case
48
+ ]);
49
49
  ```
50
50
 
51
51
  This uses O(n) sequential matching - Zod tries each schema until one passes.
@@ -60,13 +60,14 @@ z.union([
60
60
  httpSchema,
61
61
  openapiSchema,
62
62
  a2aSchema,
63
- mcpSchema
63
+ mcpSchema,
64
64
  ]),
65
- callFunctionSchema // Default case
66
- ])
65
+ callFunctionSchema, // Default case
66
+ ]);
67
67
  ```
68
68
 
69
69
  **Benefits:**
70
+
70
71
  - Known values (`"asyncapi"`, `"grpc"`, etc.) use O(1) discriminated union lookup
71
72
  - Unknown values fail fast from discriminatedUnion, then try the default case
72
73
  - More efficient runtime validation for the common case
@@ -76,12 +77,14 @@ z.union([
76
77
  ### Step 1: Identify Discriminator Candidates
77
78
 
78
79
  For each property key that appears in all oneOf options:
80
+
79
81
  1. Collect options where the property has a constant value (`const` or `enum: [single]`)
80
82
  2. Identify if exactly ONE option has `not: { enum: [...] }` for the same property
81
83
 
82
84
  ### Step 2: Validate Default Case Pattern
83
85
 
84
86
  The "default case" pattern is valid when:
87
+
85
88
  1. All other options have constant discriminator values
86
89
  2. Exactly one option has `not: { enum: values }`
87
90
  3. The `values` in the negated enum **exactly match** the const values from other options
@@ -91,8 +94,8 @@ The "default case" pattern is valid when:
91
94
 
92
95
  ```typescript
93
96
  type DiscriminatorResult =
94
- | { type: 'full'; key: string }
95
- | { type: 'withDefault'; key: string; defaultIndex: number; constValues: string[] }
97
+ | { type: "full"; key: string }
98
+ | { type: "withDefault"; key: string; defaultIndex: number; constValues: string[] }
96
99
  | undefined;
97
100
  ```
98
101
 
@@ -103,10 +106,7 @@ type DiscriminatorResult =
103
106
  1. **Enhance `findImplicitDiscriminator`** to detect the default case pattern:
104
107
 
105
108
  ```typescript
106
- const findImplicitDiscriminator = (
107
- options: JsonSchema[],
108
- refs: Refs
109
- ): DiscriminatorResult => {
109
+ const findImplicitDiscriminator = (options: JsonSchema[], refs: Refs): DiscriminatorResult => {
110
110
  // ... existing logic to collect properties and required fields
111
111
 
112
112
  for (const key of candidateKeys) {
@@ -138,13 +138,13 @@ const findImplicitDiscriminator = (
138
138
  const constSet = new Set(constValues);
139
139
  const enumSet = new Set(defaultEnumValues);
140
140
  if (setsEqual(constSet, enumSet)) {
141
- return { type: 'withDefault', key, defaultIndex, constValues };
141
+ return { type: "withDefault", key, defaultIndex, constValues };
142
142
  }
143
143
  }
144
144
 
145
145
  // All have const values
146
146
  if (constValues.length === resolvedOptions.length) {
147
- return { type: 'full', key };
147
+ return { type: "full", key };
148
148
  }
149
149
  }
150
150
 
@@ -186,6 +186,7 @@ export const parseOneOf = (schema, refs) => {
186
186
  **Problem**: In Zod v4, `ZodDiscriminatedUnion` cannot be used as a member of `ZodUnion` at the type level.
187
187
 
188
188
  When we generate:
189
+
189
190
  ```typescript
190
191
  z.union([
191
192
  z.discriminatedUnion("call", [known1, known2, ...]),
@@ -194,6 +195,7 @@ z.union([
194
195
  ```
195
196
 
196
197
  The runtime works correctly, but TypeScript fails with:
198
+
197
199
  ```
198
200
  Type 'ZodDiscriminatedUnion<...>' is not assignable to type 'SomeType'.
199
201
  The types of '_zod.values' are incompatible between these types.
@@ -204,6 +206,7 @@ The types of '_zod.values' are incompatible between these types.
204
206
  **Workaround Attempted**: Use a type annotation listing all individual variants while keeping the optimized runtime expression. This fails because Zod v4's strict tuple checking requires the type annotation tuple length to match the runtime tuple length.
205
207
 
206
208
  **Potential Solutions**:
209
+
207
210
  1. **Wait for Zod v4 update**: If Zod adds `ZodDiscriminatedUnion` to `SomeType`, this would work
208
211
  2. **Use type assertion**: Cast the result with `as unknown as ZodUnion<...>` - unsafe but functional
209
212
  3. **Request Zod feature**: Open an issue requesting discriminated union composability
@@ -235,6 +238,7 @@ z.ZodUnion<readonly [
235
238
  ## Testing
236
239
 
237
240
  Add test cases for:
241
+
238
242
  1. Basic discriminated union with default case
239
243
  2. Default case with exact enum match
240
244
  3. Default case with partial enum match (should fall back to union)
@@ -1,44 +1,50 @@
1
1
  # Inline Object Lifting (Top-Level Reusable Types)
2
2
 
3
3
  ## Goal
4
+
4
5
  Lift inline, non-cyclic object schemas into top-level `$defs` so both bundle and single-file outputs emit reusable Zod schemas (e.g., CallTask `with` objects such as AsyncAPI become first-class exports). This is now the default behavior (opt-out via a flag). Move lifting into an IR transformation pipeline (not just a raw-schema pre-pass) for stronger guarantees and shared behavior.
5
6
 
6
7
  ## Scope
8
+
7
9
  - Applies to both `jsonSchemaToZod` (single file) and `generateSchemaBundle` (multi-file).
8
10
  - Targets inline object-like schemas (properties/patternProperties/additionalProperties/items/allOf/anyOf/oneOf/if/then/else/dependentSchemas/contains/not).
9
11
  - Skip cyclic/self-referential candidates or ones where lifting would change semantics; err on not lifting when uncertain.
10
12
 
11
13
  ## High-Level Flow (IR-centric)
12
- 1) **Analyze to IR**: ingest JSON Schema → IR with refs, dependencies, SCCs, and registry (as per bundle refactor proposal).
13
- 2) **Hoist transform (IR pass)**:
14
+
15
+ 1. **Analyze to IR**: ingest JSON Schema → IR with refs, dependencies, SCCs, and registry (as per bundle refactor proposal).
16
+ 2. **Hoist transform (IR pass)**:
14
17
  - Detect inline object-like nodes (properties/items/allOf/anyOf/oneOf/if/then/else/dependentSchemas/contains/not, etc.).
15
18
  - Skip boolean schemas, `$ref`/`$dynamicRef`, and `$defs` members.
16
19
  - Use IR dependency graph/ref registry for accurate ancestor/self cycle detection; if ambiguous, skip.
17
20
  - Optionally deduplicate by structural hash (excluding titles/descriptions) so identical shapes share one hoisted def.
18
- 3) **Name** via a centralized naming service:
21
+ 3. **Name** via a centralized naming service:
19
22
  - Base on nearest named parent (root/def) + path; include discriminator/const-enum hints; suffix on collisions.
20
23
  - Allow `nameForPath` hook; all passes/emitters call the same service.
21
- 4) **Rewrite IR**:
24
+ 4. **Rewrite IR**:
22
25
  - Create top-level def nodes for hoisted shapes; replace inline nodes with ref nodes.
23
26
  - Preserve annotations; keep ASCII identifiers.
24
27
  - Emit debug metadata (path → def name) for tests/telemetry.
25
- 5) **Emit**:
28
+ 5. **Emit**:
26
29
  - Single-file: emit Zod using transformed IR.
27
30
  - Bundle: build defInfoMap/plan targets/imports from transformed IR; lifted defs flow like native `$defs`.
28
31
 
29
32
  ## Options
33
+
30
34
  - Extend `Options` and `GenerateBundleOptions` with:
31
35
  - `liftInlineObjects?: { enable?: boolean; nameForPath?: (path, ctx) => string }`
32
36
  - Default: `enable` is true; set `enable: false` to opt out.
33
37
  - Use `name`/`rootName` as the base parent name; fallback to `Root` when absent.
34
38
 
35
39
  ## Integration Points
40
+
36
41
  - **Shared IR pass**: integrate the hoist transform into the IR pipeline used by both `jsonSchemaToZod` and `generateSchemaBundle`.
37
42
  - **Single file**: run analysis → hoist pass → emit.
38
43
  - **Bundle**: run analysis → hoist pass → plan/emit; new defs appear in defInfoMap/imports.
39
44
  - **Nested types**: lifted items are no longer “nested” (expected when the flag is on).
40
45
 
41
46
  ## Naming Strategy (default)
47
+
42
48
  - Centralized naming service (IR utility) used by all passes/emitters.
43
49
  - Base: nearest named ancestor (def name → PascalCase; root → `Root` or provided `name`/`rootName`).
44
50
  - Path segments: PascalCase property names; union branches include discriminator const/enum when present; else index (`Option1`).
@@ -46,11 +52,13 @@ Lift inline, non-cyclic object schemas into top-level `$defs` so both bundle and
46
52
  - Hook: `nameForPath(path, { parentName, branchInfo, existingNames })`.
47
53
 
48
54
  ## Safety / Skip Rules
55
+
49
56
  - Skip boolean schemas, pure meta-only objects (no constraints), ambiguous `unevaluatedProperties` contexts where lifting could alter validation, and any detected cycles.
50
57
  - Use ref registry/IR deps for cycle detection; if unclear, do not lift.
51
58
  - Structural hash dedup optional: only if shapes match; otherwise, keep distinct.
52
59
 
53
60
  ## Testing Plan
61
+
54
62
  - **Unit (hoist IR pass)**:
55
63
  - Lifts simple inline property object → top-level def + ref node.
56
64
  - Lifts inside allOf/oneOf/anyOf branches; names stable and unique.
@@ -64,6 +72,7 @@ Lift inline, non-cyclic object schemas into top-level `$defs` so both bundle and
64
72
  - Maintain snapshots for both modes to contain churn while default-on is adopted.
65
73
 
66
74
  ## Risks / Mitigations
75
+
67
76
  - **Semantics drift**: lifting could change validation if sibling keywords depend on inline position (e.g., `unevaluatedProperties`, composition). Mitigate with conservative skip logic; if context is ambiguous, do not lift. Default-on but easily opt-out with `enable: false`.
68
77
  - **Cycle misdetection**: identity-only checks can miss `$ref`/`$dynamicRef` loops. Mitigate by reusing ref registry/IR deps for ancestor/self detection in the hoist pass.
69
78
  - **Naming collisions**: new defs could clash with existing `$defs` or generated names. Mitigate with centralized naming service, suffixing, optional hook, and checks against defInfoMap inputs.
@@ -71,6 +80,7 @@ Lift inline, non-cyclic object schemas into top-level `$defs` so both bundle and
71
80
  - **Single/bundle divergence**: if only bundle is wired, single-file outputs diverge. Mitigate by invoking the same IR hoist pass in `jsonSchemaToZod` and exposing the flag in shared `Options`.
72
81
 
73
82
  ## Implementation Notes
83
+
74
84
  - New IR transform: `src/utils/liftInlineObjects.ts` (pure, no side effects) operating on IR nodes rather than raw schema where possible; if raw is needed, still leverage ref registry.
75
85
  - Returns `{ rootSchema, defs, addedDefNames, pathToDefName }` (or IR equivalents) for debugging/tests.
76
86
  - Central naming service (shared util) used by hoist, emitters, and other passes; reuse `toPascalCase` and keep ASCII identifiers.
@@ -1,6 +1,7 @@
1
1
  # Proposal: Robust `$ref` / `$id` / `$anchor` / `$dynamicRef` Support
2
2
 
3
3
  ## Goals
4
+
4
5
  - Resolve `$ref` using full URI semantics (RFC 3986), not just `#/` pointers.
5
6
  - Support `$id`/`$anchor`/`$dynamicAnchor`/`$dynamicRef` (and legacy `$recursiveRef/$recursiveAnchor`).
6
7
  - Keep resolver logic in the analyzer/IR layer so emitters/strategies stay SOLID (SRP/OCP).
@@ -8,6 +9,7 @@
8
9
  - Preserve existing `$defs`/JSON Pointer behavior for compatibility.
9
10
 
10
11
  ## Architecture alignment (with bundle refactor)
12
+
11
13
  - Implement ref/anchor logic in the analyzer; emitters consume IR edges, not URIs.
12
14
  - Define a pluggable `RefResolutionStrategy` used by the analyzer:
13
15
  - Inputs: `ref`, `contextBaseUri`, `dynamicStack`, `registry`, optional `externalResolver`, `onUnresolvedRef`.
@@ -17,12 +19,14 @@
17
19
  ## Plan
18
20
 
19
21
  ### 1) Build a URI/anchor registry (analyzer prepass)
22
+
20
23
  - Walk the schema once, tracking base URI (respect `$id`).
21
24
  - Register base URI entries, `$anchor` (base#anchor), `$dynamicAnchor` (base#anchor, dynamic flag).
22
25
  - Handle relative `$id` resolution per RFC 3986.
23
26
  - Attach registry to IR/context.
24
27
 
25
28
  ### 2) URI-based ref resolution
29
+
26
30
  - `resolveRef(ref, contextBaseUri, registry, dynamicStack)`:
27
31
  - Resolve against `contextBaseUri` → absolute URI; split base/fragment.
28
32
  - For `$dynamicRef`, search `dynamicStack` top-down for matching anchor; else fallback to registry lookup.
@@ -31,33 +35,40 @@
31
35
  - Analyzer produces IR references keyed by resolved URI+fragment; name generation uses this key.
32
36
 
33
37
  ### 3) Thread base URI & dynamic stack in analyzer
38
+
34
39
  - Extend analyzer traversal context (similar to Refs) with `currentBaseUri`, `dynamicAnchors`.
35
40
  - On `$id`, compute new base; pass to children.
36
41
  - On `$dynamicAnchor`, push onto stack for node scope; pop on exit.
37
42
  - Emitters receive IR that already encodes resolved refs.
38
43
 
39
44
  ### 4) Legacy recursive keywords
45
+
40
46
  - Treat `$recursiveAnchor` as a special dynamic anchor name.
41
47
  - Treat `$recursiveRef` like `$dynamicRef` targeting that name.
42
48
 
43
49
  ### 5) External refs (optional, pluggable)
50
+
44
51
  - Analyzer option `resolveExternalRef(uri)` (sync/async) to fetch external schemas.
45
52
  - On external base URI miss, call resolver, prewalk and cache registry for that URI, then resolve.
46
53
  - Guard against cycles with in-progress cache.
47
54
 
48
55
  ### 6) Naming & cycles
56
+
49
57
  - Key ref names by resolved URI+fragment; store map in IR for consistent imports/aliases.
50
58
  - Preserve cycle detection using these names.
51
59
 
52
60
  ### 7) Error/warning handling
61
+
53
62
  - Option `onUnresolvedRef(uri, path)` for logging/throwing.
54
63
  - Policy for fallback (`z.any()`/`z.unknown()` or error) lives in emitter/strategy but is driven by analyzer’s unresolved marker.
55
64
 
56
65
  ### 8) Tests
66
+
57
67
  - Analyzer-level tests: `$id`/`$anchor` resolution (absolute/relative), `$dynamicAnchor`/`$dynamicRef` scoping, legacy recursive, external resolver stub, cycles, backward-compatible `#/` refs.
58
68
  - Strategy/emitter tests: bundle imports for cross-file refs, naming stability with URI keys.
59
69
 
60
70
  ### 9) Migration steps
71
+
61
72
  - Add registry prepass and URI resolver in analyzer.
62
73
  - Thread `currentBaseUri`/`dynamicAnchors` through analysis context.
63
74
  - Produce IR refs keyed by resolved URI; update naming map/cycle tracking.