@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.
- package/.github/RELEASE_SETUP.md +120 -0
- package/.github/TOOLING_GUIDE.md +169 -0
- package/.github/dependabot.yml +52 -0
- package/.github/workflows/ci.yml +33 -0
- package/.github/workflows/release.yml +12 -4
- package/.github/workflows/security.yml +40 -0
- package/.husky/commit-msg +1 -0
- package/.husky/pre-commit +1 -0
- package/.lintstagedrc.json +3 -0
- package/.prettierrc +20 -0
- package/AGENTS.md +7 -0
- package/CHANGELOG.md +13 -4
- package/README.md +9 -9
- package/commitlint.config.js +24 -0
- package/createIndex.ts +4 -4
- package/dist/cli.js +3 -4
- package/dist/core/analyzeSchema.js +28 -5
- package/dist/core/emitZod.js +11 -4
- package/dist/generators/generateBundle.js +67 -92
- package/dist/parsers/parseAllOf.js +11 -12
- package/dist/parsers/parseAnyOf.js +2 -2
- package/dist/parsers/parseArray.js +38 -12
- package/dist/parsers/parseMultipleType.js +2 -2
- package/dist/parsers/parseNumber.js +44 -102
- package/dist/parsers/parseObject.js +138 -393
- package/dist/parsers/parseOneOf.js +57 -100
- package/dist/parsers/parseSchema.js +132 -55
- package/dist/parsers/parseSimpleDiscriminatedOneOf.js +2 -2
- package/dist/parsers/parseString.js +113 -253
- package/dist/types/Types.d.ts +22 -1
- package/dist/types/core/analyzeSchema.d.ts +1 -0
- package/dist/types/generators/generateBundle.d.ts +1 -1
- package/dist/utils/cliTools.js +1 -2
- package/dist/utils/esmEmitter.js +6 -2
- package/dist/utils/extractInlineObject.js +1 -3
- package/dist/utils/jsdocs.js +1 -4
- package/dist/utils/liftInlineObjects.js +76 -15
- package/dist/utils/resolveRef.js +35 -10
- package/dist/utils/schemaRepresentation.js +35 -66
- package/dist/zodToJsonSchema.js +1 -2
- package/docs/IMPROVEMENT-PLAN.md +30 -12
- package/docs/ZOD-V4-RECURSIVE-TYPE-LIMITATIONS.md +70 -25
- package/docs/proposals/allof-required-merging.md +10 -4
- package/docs/proposals/bundle-refactor.md +10 -4
- package/docs/proposals/discriminated-union-with-default.md +18 -14
- package/docs/proposals/inline-object-lifting.md +15 -5
- package/docs/proposals/ref-anchor-support.md +11 -0
- package/output.txt +67 -0
- package/package.json +18 -5
- package/scripts/generateWorkflowSchema.ts +5 -14
- package/scripts/regenerate_bundle.ts +25 -0
- package/tsc_output.txt +542 -0
- 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
|
|
56
|
-
|
|
57
|
-
| Self-recursive object property
|
|
58
|
-
| Mutually recursive objects
|
|
59
|
-
| Recursive inside `z.union()`
|
|
60
|
-
| Recursive inside `z.discriminatedUnion()`
|
|
61
|
-
| Recursive inside `z.record()`
|
|
62
|
-
| Recursive inside `z.array()` nested in union | ❌ No
|
|
63
|
-
| Chaining methods on recursive type
|
|
64
|
-
| Using `.extend()` with recursive getters
|
|
65
|
-
| Using spread operator `{...schema.shape}`
|
|
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({
|
|
79
|
-
|
|
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() {
|
|
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
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
}
|
|
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(
|
|
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() {
|
|
256
|
+
get children() {
|
|
257
|
+
return ArrayOfThings.optional();
|
|
258
|
+
},
|
|
224
259
|
});
|
|
225
|
-
const ArrayOfThings = z.array(Reusable.extend(Recursive.shape));
|
|
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() {
|
|
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(
|
|
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]
|
|
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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
|
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
|
|
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
|
|
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:
|
|
95
|
-
| { type:
|
|
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:
|
|
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:
|
|
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
|
-
|
|
13
|
-
|
|
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
|
|
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
|
|
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
|
|
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.
|