@gabrielbryk/json-schema-to-zod 2.14.0 → 2.14.2
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/CHANGELOG.md +12 -0
- package/README.md +4 -0
- package/dist/index.js +3 -0
- package/dist/parsers/parseAnyOf.js +10 -2
- package/dist/parsers/parseMultipleType.js +12 -3
- package/dist/parsers/parseNot.js +6 -3
- package/dist/parsers/parseObject.js +117 -37
- package/dist/parsers/parseOneOf.js +1 -47
- package/dist/parsers/parseSchema.js +26 -18
- package/dist/types/index.d.ts +3 -0
- package/dist/types/utils/buildIntersectionTree.d.ts +2 -0
- package/dist/types/utils/collectSchemaProperties.d.ts +11 -0
- package/dist/types/utils/normalizeUnion.d.ts +6 -0
- package/dist/utils/buildIntersectionTree.js +23 -0
- package/dist/utils/collectSchemaProperties.js +55 -0
- package/dist/utils/normalizeUnion.js +132 -0
- package/dist/utils/schemaRepresentation.js +4 -0
- package/docs/zod-from-json-schema-research.md +260 -0
- package/open-issues.md +140 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# @gabrielbryk/json-schema-to-zod
|
|
2
2
|
|
|
3
|
+
## 2.14.2
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- b4460e4: Restore getter-based recursion for named properties to preserve inferred types in recursive schemas.
|
|
8
|
+
|
|
9
|
+
## 2.14.1
|
|
10
|
+
|
|
11
|
+
### Patch Changes
|
|
12
|
+
|
|
13
|
+
- 98f75f5: Normalize unions (dedupe/flatten, fold nullable) and balance object-level intersections for simpler output and faster type checking. Preserve base types for `not` schemas and keep required-only `oneOf` refinements from erasing base object types.
|
|
14
|
+
|
|
3
15
|
## 2.14.0
|
|
4
16
|
|
|
5
17
|
### Minor Changes
|
package/README.md
CHANGED
|
@@ -17,6 +17,10 @@ Since v2 the CLI supports piped JSON.
|
|
|
17
17
|
|
|
18
18
|
_Looking for the exact opposite? Check out [zod-to-json-schema](https://npmjs.org/package/zod-to-json-schema)_
|
|
19
19
|
|
|
20
|
+
## Open issues
|
|
21
|
+
|
|
22
|
+
See `open-issues.md` for known correctness, type-safety, and performance gaps.
|
|
23
|
+
|
|
20
24
|
## Usage
|
|
21
25
|
|
|
22
26
|
### Online
|
package/dist/index.js
CHANGED
|
@@ -22,7 +22,9 @@ export * from "./parsers/parseSchema.js";
|
|
|
22
22
|
export * from "./parsers/parseSimpleDiscriminatedOneOf.js";
|
|
23
23
|
export * from "./parsers/parseString.js";
|
|
24
24
|
export * from "./utils/anyOrUnknown.js";
|
|
25
|
+
export * from "./utils/buildIntersectionTree.js";
|
|
25
26
|
export * from "./utils/buildRefRegistry.js";
|
|
27
|
+
export * from "./utils/collectSchemaProperties.js";
|
|
26
28
|
export * from "./utils/cycles.js";
|
|
27
29
|
export * from "./utils/esmEmitter.js";
|
|
28
30
|
export * from "./utils/extractInlineObject.js";
|
|
@@ -30,6 +32,7 @@ export * from "./utils/half.js";
|
|
|
30
32
|
export * from "./utils/jsdocs.js";
|
|
31
33
|
export * from "./utils/liftInlineObjects.js";
|
|
32
34
|
export * from "./utils/namingService.js";
|
|
35
|
+
export * from "./utils/normalizeUnion.js";
|
|
33
36
|
export * from "./utils/omit.js";
|
|
34
37
|
export * from "./utils/resolveRef.js";
|
|
35
38
|
export * from "./utils/resolveUri.js";
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { parseSchema } from "./parseSchema.js";
|
|
2
2
|
import { anyOrUnknown } from "../utils/anyOrUnknown.js";
|
|
3
3
|
import { extractInlineObject } from "../utils/extractInlineObject.js";
|
|
4
|
+
import { normalizeUnionMembers } from "../utils/normalizeUnion.js";
|
|
4
5
|
export const parseAnyOf = (schema, refs) => {
|
|
5
6
|
if (!schema.anyOf.length) {
|
|
6
7
|
return anyOrUnknown(refs);
|
|
@@ -19,8 +20,15 @@ export const parseAnyOf = (schema, refs) => {
|
|
|
19
20
|
}
|
|
20
21
|
return parseSchema(memberSchema, { ...refs, path: [...refs.path, "anyOf", i] });
|
|
21
22
|
});
|
|
22
|
-
const
|
|
23
|
-
|
|
23
|
+
const normalized = normalizeUnionMembers(members, { foldNullable: true });
|
|
24
|
+
if (normalized.length === 0) {
|
|
25
|
+
return anyOrUnknown(refs);
|
|
26
|
+
}
|
|
27
|
+
if (normalized.length === 1) {
|
|
28
|
+
return normalized[0];
|
|
29
|
+
}
|
|
30
|
+
const expressions = normalized.map((m) => m.expression).join(", ");
|
|
31
|
+
const types = normalized.map((m) => m.type).join(", ");
|
|
24
32
|
const expression = `z.union([${expressions}])`;
|
|
25
33
|
// Use readonly tuple for union type annotations (required for recursive type inference)
|
|
26
34
|
const type = `z.ZodUnion<readonly [${types}]>`;
|
|
@@ -1,8 +1,17 @@
|
|
|
1
1
|
import { parseSchema } from "./parseSchema.js";
|
|
2
|
+
import { normalizeUnionMembers } from "../utils/normalizeUnion.js";
|
|
2
3
|
export const parseMultipleType = (schema, refs) => {
|
|
3
|
-
const
|
|
4
|
-
const
|
|
5
|
-
const
|
|
4
|
+
const uniqueTypes = Array.from(new Set(schema.type));
|
|
5
|
+
const schemas = uniqueTypes.map((type) => parseSchema({ ...schema, type }, { ...refs, withoutDefaults: true }));
|
|
6
|
+
const normalized = normalizeUnionMembers(schemas, { foldNullable: true });
|
|
7
|
+
if (normalized.length === 0) {
|
|
8
|
+
return { expression: "z.never()", type: "z.ZodNever" };
|
|
9
|
+
}
|
|
10
|
+
if (normalized.length === 1) {
|
|
11
|
+
return normalized[0];
|
|
12
|
+
}
|
|
13
|
+
const expressions = normalized.map((s) => s.expression).join(", ");
|
|
14
|
+
const types = normalized.map((s) => s.type).join(", ");
|
|
6
15
|
return {
|
|
7
16
|
expression: `z.union([${expressions}])`,
|
|
8
17
|
type: `z.ZodUnion<[${types}]>`,
|
package/dist/parsers/parseNot.js
CHANGED
|
@@ -1,14 +1,17 @@
|
|
|
1
1
|
import { parseSchema } from "./parseSchema.js";
|
|
2
2
|
import { anyOrUnknown } from "../utils/anyOrUnknown.js";
|
|
3
3
|
export const parseNot = (schema, refs) => {
|
|
4
|
-
const
|
|
4
|
+
const baseSchemaInput = { ...schema };
|
|
5
|
+
delete baseSchemaInput.not;
|
|
6
|
+
const baseSchema = parseSchema(baseSchemaInput, refs, true);
|
|
7
|
+
const resolvedBase = baseSchema.expression === "z.never()" ? anyOrUnknown(refs) : baseSchema;
|
|
5
8
|
const notSchema = parseSchema(schema.not, {
|
|
6
9
|
...refs,
|
|
7
10
|
path: [...refs.path, "not"],
|
|
8
11
|
});
|
|
9
12
|
return {
|
|
10
|
-
expression: `${
|
|
13
|
+
expression: `${resolvedBase.expression}.refine((value) => !${notSchema.expression}.safeParse(value).success, "Invalid input: Should NOT be valid against schema")`,
|
|
11
14
|
// In Zod v4, .refine() doesn't change the type
|
|
12
|
-
type:
|
|
15
|
+
type: resolvedBase.type,
|
|
13
16
|
};
|
|
14
17
|
};
|
|
@@ -2,25 +2,81 @@ import { parseAnyOf } from "./parseAnyOf.js";
|
|
|
2
2
|
import { parseOneOf } from "./parseOneOf.js";
|
|
3
3
|
import { parseSchema } from "./parseSchema.js";
|
|
4
4
|
import { addJsdocs } from "../utils/jsdocs.js";
|
|
5
|
+
import { anyOrUnknown } from "../utils/anyOrUnknown.js";
|
|
6
|
+
import { buildIntersectionTree } from "../utils/buildIntersectionTree.js";
|
|
7
|
+
import { collectSchemaProperties } from "../utils/collectSchemaProperties.js";
|
|
8
|
+
import { shouldUseGetter } from "../utils/schemaRepresentation.js";
|
|
5
9
|
export function parseObject(objectSchema, refs) {
|
|
10
|
+
const collectedProperties = objectSchema.allOf
|
|
11
|
+
? collectSchemaProperties(objectSchema, refs)
|
|
12
|
+
: undefined;
|
|
6
13
|
const explicitProps = objectSchema.properties ? Object.keys(objectSchema.properties) : [];
|
|
7
|
-
const
|
|
8
|
-
const
|
|
14
|
+
const collectedProps = collectedProperties ? Object.keys(collectedProperties.properties) : [];
|
|
15
|
+
const requiredProps = collectedProperties
|
|
16
|
+
? collectedProperties.required
|
|
17
|
+
: Array.isArray(objectSchema.required)
|
|
18
|
+
? objectSchema.required
|
|
19
|
+
: [];
|
|
20
|
+
const allProps = [...new Set([...explicitProps, ...requiredProps, ...collectedProps])];
|
|
9
21
|
const hasProperties = allProps.length > 0;
|
|
22
|
+
const requiredSet = new Set(requiredProps);
|
|
23
|
+
const isPropertyOnlyAllOfMember = (member) => {
|
|
24
|
+
if (typeof member !== "object" || member === null)
|
|
25
|
+
return false;
|
|
26
|
+
const obj = member;
|
|
27
|
+
if (obj.$ref || obj.$dynamicRef)
|
|
28
|
+
return false;
|
|
29
|
+
const keys = Object.keys(obj);
|
|
30
|
+
if (keys.length === 0)
|
|
31
|
+
return false;
|
|
32
|
+
return keys.every((key) => key === "properties" || key === "required");
|
|
33
|
+
};
|
|
34
|
+
const propertyOnlyOverlapKeys = new Set();
|
|
35
|
+
const propertyOnlyKeysByIndex = new Map();
|
|
36
|
+
if (objectSchema.allOf) {
|
|
37
|
+
const keyCounts = new Map();
|
|
38
|
+
const addKey = (key) => {
|
|
39
|
+
keyCounts.set(key, (keyCounts.get(key) ?? 0) + 1);
|
|
40
|
+
};
|
|
41
|
+
for (const key of Object.keys(objectSchema.properties ?? {})) {
|
|
42
|
+
addKey(key);
|
|
43
|
+
}
|
|
44
|
+
objectSchema.allOf.forEach((member, index) => {
|
|
45
|
+
if (!isPropertyOnlyAllOfMember(member))
|
|
46
|
+
return;
|
|
47
|
+
const obj = member;
|
|
48
|
+
const keys = Object.keys(obj.properties ?? {});
|
|
49
|
+
if (keys.length) {
|
|
50
|
+
propertyOnlyKeysByIndex.set(index, keys);
|
|
51
|
+
keys.forEach(addKey);
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
for (const [key, count] of keyCounts) {
|
|
55
|
+
if (count > 1) {
|
|
56
|
+
propertyOnlyOverlapKeys.add(key);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
10
60
|
// 1. Process Properties (Base Object)
|
|
11
61
|
let baseObjectExpr = "z.object({";
|
|
12
62
|
const propertyTypes = [];
|
|
13
63
|
if (hasProperties) {
|
|
14
64
|
baseObjectExpr += allProps
|
|
15
65
|
.map((key) => {
|
|
16
|
-
const
|
|
17
|
-
const
|
|
18
|
-
?
|
|
19
|
-
:
|
|
66
|
+
const hasDirectProp = Object.prototype.hasOwnProperty.call(objectSchema.properties ?? {}, key);
|
|
67
|
+
const propSchema = hasDirectProp
|
|
68
|
+
? objectSchema.properties?.[key]
|
|
69
|
+
: collectedProperties?.properties[key];
|
|
70
|
+
const propPath = hasDirectProp
|
|
71
|
+
? [...refs.path, "properties", key]
|
|
72
|
+
: (collectedProperties?.propertyPaths[key] ?? [...refs.path, "properties", key]);
|
|
73
|
+
const parsedProp = propSchema !== undefined
|
|
74
|
+
? parseSchema(propSchema, { ...refs, path: propPath })
|
|
75
|
+
: anyOrUnknown(refs);
|
|
20
76
|
const hasDefault = typeof propSchema === "object" && propSchema.default !== undefined;
|
|
21
77
|
// Check "required" array from parent
|
|
22
|
-
const isRequired =
|
|
23
|
-
?
|
|
78
|
+
const isRequired = requiredSet.has(key)
|
|
79
|
+
? true
|
|
24
80
|
: typeof propSchema === "object" && propSchema.required === true;
|
|
25
81
|
const isOptional = !hasDefault && !isRequired;
|
|
26
82
|
let valueExpr = parsedProp.expression;
|
|
@@ -29,7 +85,16 @@ export function parseObject(objectSchema, refs) {
|
|
|
29
85
|
valueExpr = `${parsedProp.expression}.exactOptional()`;
|
|
30
86
|
valueType = `z.ZodExactOptional<${parsedProp.type}>`;
|
|
31
87
|
}
|
|
88
|
+
const valueRep = { expression: valueExpr, type: valueType };
|
|
32
89
|
propertyTypes.push({ key, type: valueType });
|
|
90
|
+
const useGetter = shouldUseGetter(valueRep, refs.currentSchemaName, refs.cycleRefNames, refs.cycleComponentByName);
|
|
91
|
+
if (useGetter) {
|
|
92
|
+
let result = `get ${JSON.stringify(key)}(): ${valueType} { return ${valueExpr} }`;
|
|
93
|
+
if (refs.withJsdocs && typeof propSchema === "object") {
|
|
94
|
+
result = addJsdocs(propSchema, result);
|
|
95
|
+
}
|
|
96
|
+
return result;
|
|
97
|
+
}
|
|
33
98
|
if (refs.withJsdocs && typeof propSchema === "object") {
|
|
34
99
|
valueExpr = addJsdocs(propSchema, valueExpr);
|
|
35
100
|
}
|
|
@@ -75,24 +140,19 @@ export function parseObject(objectSchema, refs) {
|
|
|
75
140
|
}
|
|
76
141
|
}
|
|
77
142
|
// 3. Handle patternProperties using Intersection with z.looseRecord
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
if (
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
...refs,
|
|
84
|
-
path: [...refs.path, "patternProperties", pattern],
|
|
85
|
-
});
|
|
86
|
-
const keySchema = `z.string().regex(new RegExp(${JSON.stringify(pattern)}))`;
|
|
87
|
-
const recordExpr = `z.looseRecord(${keySchema}, ${validSchema.expression})`;
|
|
88
|
-
finalExpr = `z.intersection(${finalExpr}, ${recordExpr})`;
|
|
89
|
-
intersectionTypes.push(`z.ZodRecord<z.ZodString, ${validSchema.type}>`);
|
|
90
|
-
}
|
|
143
|
+
const intersectionMembers = [];
|
|
144
|
+
let baseType = "z.ZodObject<any>";
|
|
145
|
+
if (propertyTypes.length) {
|
|
146
|
+
const shape = propertyTypes.map((p) => `${JSON.stringify(p.key)}: ${p.type}`).join("; ");
|
|
147
|
+
baseType = `z.ZodObject<{${shape}}>`;
|
|
91
148
|
}
|
|
149
|
+
intersectionMembers.push({ expression: baseObjectModified, type: baseType });
|
|
92
150
|
// 3b. Add manual additionalProperties check if needed
|
|
151
|
+
let manualAdditionalRefine = "";
|
|
152
|
+
let oneOfRefinement = "";
|
|
93
153
|
if (manualAdditionalProps) {
|
|
94
154
|
const definedProps = objectSchema.properties ? Object.keys(objectSchema.properties) : [];
|
|
95
|
-
|
|
155
|
+
manualAdditionalRefine = `.superRefine((value, ctx) => {
|
|
96
156
|
for (const key in value) {
|
|
97
157
|
if (${JSON.stringify(definedProps)}.includes(key)) continue;
|
|
98
158
|
let matched = false;
|
|
@@ -109,6 +169,20 @@ export function parseObject(objectSchema, refs) {
|
|
|
109
169
|
})`;
|
|
110
170
|
}
|
|
111
171
|
// 4. Handle composition (allOf, oneOf, anyOf) via Intersection
|
|
172
|
+
if (hasPattern) {
|
|
173
|
+
for (const [pattern, schema] of Object.entries(patternProps)) {
|
|
174
|
+
const validSchema = parseSchema(schema, {
|
|
175
|
+
...refs,
|
|
176
|
+
path: [...refs.path, "patternProperties", pattern],
|
|
177
|
+
});
|
|
178
|
+
const keySchema = `z.string().regex(new RegExp(${JSON.stringify(pattern)}))`;
|
|
179
|
+
const recordExpr = `z.looseRecord(${keySchema}, ${validSchema.expression})`;
|
|
180
|
+
intersectionMembers.push({
|
|
181
|
+
expression: recordExpr,
|
|
182
|
+
type: `z.ZodRecord<z.ZodString, ${validSchema.type}>`,
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
}
|
|
112
186
|
if (objectSchema.allOf) {
|
|
113
187
|
// Cast because we checked it exists
|
|
114
188
|
const schemaWithAllOf = objectSchema;
|
|
@@ -116,24 +190,38 @@ export function parseObject(objectSchema, refs) {
|
|
|
116
190
|
// But typically allOf implies intersection.
|
|
117
191
|
// If we just use simple intersection:
|
|
118
192
|
schemaWithAllOf.allOf.forEach((s, i) => {
|
|
193
|
+
if (isPropertyOnlyAllOfMember(s)) {
|
|
194
|
+
const keys = propertyOnlyKeysByIndex.get(i) ?? [];
|
|
195
|
+
const hasOverlap = keys.some((key) => propertyOnlyOverlapKeys.has(key));
|
|
196
|
+
if (!hasOverlap) {
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
119
200
|
const res = parseSchema(s, { ...refs, path: [...refs.path, "allOf", i] });
|
|
120
|
-
|
|
121
|
-
intersectionTypes.push(res.type);
|
|
201
|
+
intersectionMembers.push(res);
|
|
122
202
|
});
|
|
123
203
|
}
|
|
124
204
|
if (objectSchema.oneOf) {
|
|
125
205
|
const schemaWithOneOf = objectSchema;
|
|
126
206
|
const res = parseOneOf(schemaWithOneOf, refs);
|
|
127
|
-
|
|
128
|
-
|
|
207
|
+
const refinementBody = res.refinementBody;
|
|
208
|
+
if ("isRefinementOnly" in res &&
|
|
209
|
+
res.isRefinementOnly === true &&
|
|
210
|
+
typeof refinementBody === "string") {
|
|
211
|
+
oneOfRefinement = `.superRefine(${refinementBody})`;
|
|
212
|
+
}
|
|
213
|
+
else {
|
|
214
|
+
intersectionMembers.push(res);
|
|
215
|
+
}
|
|
129
216
|
}
|
|
130
217
|
if (objectSchema.anyOf) {
|
|
131
218
|
const schemaWithAnyOf = objectSchema;
|
|
132
219
|
const res = parseAnyOf(schemaWithAnyOf, refs);
|
|
133
|
-
|
|
134
|
-
intersectionTypes.push(res.type);
|
|
220
|
+
intersectionMembers.push(res);
|
|
135
221
|
}
|
|
136
222
|
// 5. propertyNames, unevaluatedProperties, dependentSchemas etc.
|
|
223
|
+
const final = buildIntersectionTree(intersectionMembers);
|
|
224
|
+
let finalExpr = `${final.expression}${manualAdditionalRefine}${oneOfRefinement}`;
|
|
137
225
|
if (objectSchema.propertyNames) {
|
|
138
226
|
const normalizedPropNames = typeof objectSchema.propertyNames === "object" &&
|
|
139
227
|
objectSchema.propertyNames !== null &&
|
|
@@ -207,15 +295,7 @@ export function parseObject(objectSchema, refs) {
|
|
|
207
295
|
}
|
|
208
296
|
}
|
|
209
297
|
// Calculate Type
|
|
210
|
-
|
|
211
|
-
if (propertyTypes.length) {
|
|
212
|
-
const shape = propertyTypes.map((p) => `${JSON.stringify(p.key)}: ${p.type}`).join("; ");
|
|
213
|
-
type = `z.ZodObject<{${shape}}>`;
|
|
214
|
-
}
|
|
215
|
-
// If intersections
|
|
216
|
-
intersectionTypes.forEach((t) => {
|
|
217
|
-
type = `z.ZodIntersection<${type}, ${t}>`;
|
|
218
|
-
});
|
|
298
|
+
const type = final.type;
|
|
219
299
|
return {
|
|
220
300
|
expression: finalExpr,
|
|
221
301
|
type,
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { parseSchema } from "./parseSchema.js";
|
|
2
2
|
import { anyOrUnknown } from "../utils/anyOrUnknown.js";
|
|
3
3
|
import { resolveRef } from "../utils/resolveRef.js";
|
|
4
|
+
import { collectSchemaProperties } from "../utils/collectSchemaProperties.js";
|
|
4
5
|
/**
|
|
5
6
|
* Check if a schema is a "required-only" validation constraint.
|
|
6
7
|
* These are schemas that only specify `required` without defining types.
|
|
@@ -49,53 +50,6 @@ const generateRequiredFieldsRefinement = (requiredCombinations) => {
|
|
|
49
50
|
refinementBody,
|
|
50
51
|
};
|
|
51
52
|
};
|
|
52
|
-
/**
|
|
53
|
-
* Collects all properties from a schema, including properties defined in allOf members.
|
|
54
|
-
* Returns merged properties object and combined required array.
|
|
55
|
-
*/
|
|
56
|
-
const collectSchemaProperties = (schema, refs) => {
|
|
57
|
-
let properties = {};
|
|
58
|
-
let required = [];
|
|
59
|
-
// Collect direct properties
|
|
60
|
-
if (schema.properties) {
|
|
61
|
-
properties = { ...properties, ...schema.properties };
|
|
62
|
-
}
|
|
63
|
-
// Collect direct required
|
|
64
|
-
if (Array.isArray(schema.required)) {
|
|
65
|
-
required = [...required, ...schema.required];
|
|
66
|
-
}
|
|
67
|
-
// Collect from allOf members
|
|
68
|
-
if (Array.isArray(schema.allOf)) {
|
|
69
|
-
for (const member of schema.allOf) {
|
|
70
|
-
if (typeof member !== "object" || member === null)
|
|
71
|
-
continue;
|
|
72
|
-
let resolvedMember = member;
|
|
73
|
-
// Resolve $ref if needed
|
|
74
|
-
if (resolvedMember.$ref || resolvedMember.$dynamicRef) {
|
|
75
|
-
const resolved = resolveRef(resolvedMember, (resolvedMember.$ref || resolvedMember.$dynamicRef), refs);
|
|
76
|
-
if (resolved && typeof resolved.schema === "object" && resolved.schema !== null) {
|
|
77
|
-
resolvedMember = resolved.schema;
|
|
78
|
-
}
|
|
79
|
-
else {
|
|
80
|
-
continue;
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
// Merge properties from this allOf member
|
|
84
|
-
if (resolvedMember.properties) {
|
|
85
|
-
properties = { ...properties, ...resolvedMember.properties };
|
|
86
|
-
}
|
|
87
|
-
// Merge required from this allOf member
|
|
88
|
-
if (Array.isArray(resolvedMember.required)) {
|
|
89
|
-
required = [...required, ...resolvedMember.required];
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
}
|
|
93
|
-
// Return undefined if no properties found
|
|
94
|
-
if (Object.keys(properties).length === 0) {
|
|
95
|
-
return undefined;
|
|
96
|
-
}
|
|
97
|
-
return { properties, required: [...new Set(required)] };
|
|
98
|
-
};
|
|
99
53
|
/**
|
|
100
54
|
* Check if two sets contain the same elements.
|
|
101
55
|
*/
|
|
@@ -126,28 +126,36 @@ const parseRef = (schema, refs) => {
|
|
|
126
126
|
// Check if this is a true forward reference (target not yet declared)
|
|
127
127
|
// We only need z.lazy() for forward refs, not for back-refs to already-declared schemas
|
|
128
128
|
const isForwardRef = refs.inProgress.has(refName);
|
|
129
|
-
// Check context: are we inside
|
|
130
|
-
// IMPORTANT: additionalProperties
|
|
131
|
-
//
|
|
129
|
+
// Check context: are we inside a named object property where getters work?
|
|
130
|
+
// IMPORTANT: additionalProperties/patternProperties become z.record() (or .catchall())
|
|
131
|
+
// and do NOT support getters for deferred evaluation.
|
|
132
|
+
const inNamedProperty = refs.path.includes("properties");
|
|
132
133
|
// additionalProperties becomes z.record() value - getters don't work there
|
|
133
134
|
// Per Zod issue #4881: z.record() with recursive values REQUIRES z.lazy()
|
|
134
|
-
// We also force ZodTypeAny here to break TypeScript circular inference loops
|
|
135
135
|
const inRecordContext = refs.path.includes("additionalProperties");
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
// Zod documentation recommends: z.ZodType<MyType> = z.lazy(...)
|
|
140
|
-
// Since we don't have the named type available here easily, we rely on inference by removing the generic.
|
|
141
|
-
const isRecursive = isSameCycle || isForwardRef || refName === refs.currentSchemaName;
|
|
142
|
-
const refType = isRecursive || inRecordContext ? "z.ZodTypeAny" : `typeof ${refName}`;
|
|
136
|
+
const isSelfRecursion = refName === refs.currentSchemaName;
|
|
137
|
+
const isRecursive = isSameCycle || isForwardRef || isSelfRecursion;
|
|
138
|
+
const refType = `typeof ${refName}`;
|
|
143
139
|
// Use deferred/lazy logic if recursive or in a context that requires it (record/catchall)
|
|
144
|
-
if (isRecursive
|
|
145
|
-
|
|
146
|
-
//
|
|
147
|
-
|
|
148
|
-
expression:
|
|
149
|
-
|
|
150
|
-
|
|
140
|
+
if (isRecursive) {
|
|
141
|
+
const needsLazy = isForwardRef || inRecordContext || !inNamedProperty;
|
|
142
|
+
// Self-recursion in named object properties: use direct ref (getter handles deferred eval)
|
|
143
|
+
if (inNamedProperty && isSelfRecursion) {
|
|
144
|
+
return { expression: refName, type: refType };
|
|
145
|
+
}
|
|
146
|
+
// Cross-schema refs in named object properties within same cycle: use direct ref
|
|
147
|
+
// The getter in parseObject.ts will handle deferred evaluation
|
|
148
|
+
if (inNamedProperty && isSameCycle && !isForwardRef) {
|
|
149
|
+
return { expression: refName, type: refType };
|
|
150
|
+
}
|
|
151
|
+
if (needsLazy) {
|
|
152
|
+
// z.record() values with recursive refs MUST use z.lazy() (Colin confirmed in #4881)
|
|
153
|
+
// Also arrays, unions, and other non-object contexts with forward refs need z.lazy()
|
|
154
|
+
return {
|
|
155
|
+
expression: `z.lazy(() => ${refName})`,
|
|
156
|
+
type: `z.ZodLazy<${refType}>`,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
151
159
|
}
|
|
152
160
|
return { expression: refName, type: refType };
|
|
153
161
|
};
|
package/dist/types/index.d.ts
CHANGED
|
@@ -22,7 +22,9 @@ export * from "./parsers/parseSchema.js";
|
|
|
22
22
|
export * from "./parsers/parseSimpleDiscriminatedOneOf.js";
|
|
23
23
|
export * from "./parsers/parseString.js";
|
|
24
24
|
export * from "./utils/anyOrUnknown.js";
|
|
25
|
+
export * from "./utils/buildIntersectionTree.js";
|
|
25
26
|
export * from "./utils/buildRefRegistry.js";
|
|
27
|
+
export * from "./utils/collectSchemaProperties.js";
|
|
26
28
|
export * from "./utils/cycles.js";
|
|
27
29
|
export * from "./utils/esmEmitter.js";
|
|
28
30
|
export * from "./utils/extractInlineObject.js";
|
|
@@ -30,6 +32,7 @@ export * from "./utils/half.js";
|
|
|
30
32
|
export * from "./utils/jsdocs.js";
|
|
31
33
|
export * from "./utils/liftInlineObjects.js";
|
|
32
34
|
export * from "./utils/namingService.js";
|
|
35
|
+
export * from "./utils/normalizeUnion.js";
|
|
33
36
|
export * from "./utils/omit.js";
|
|
34
37
|
export * from "./utils/resolveRef.js";
|
|
35
38
|
export * from "./utils/resolveUri.js";
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { JsonSchemaObject, JsonSchema, Refs } from "../Types.js";
|
|
2
|
+
export type CollectedSchemaProperties = {
|
|
3
|
+
properties: Record<string, JsonSchema>;
|
|
4
|
+
required: string[];
|
|
5
|
+
propertyPaths: Record<string, (string | number)[]>;
|
|
6
|
+
};
|
|
7
|
+
/**
|
|
8
|
+
* Collects all properties from a schema, including properties defined in allOf members.
|
|
9
|
+
* Returns merged properties object, combined required array, and property source paths.
|
|
10
|
+
*/
|
|
11
|
+
export declare const collectSchemaProperties: (schema: JsonSchemaObject, refs: Refs) => CollectedSchemaProperties | undefined;
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { SchemaRepresentation } from "../Types.js";
|
|
2
|
+
type NormalizeUnionOptions = {
|
|
3
|
+
foldNullable?: boolean;
|
|
4
|
+
};
|
|
5
|
+
export declare const normalizeUnionMembers: (members: SchemaRepresentation[], options?: NormalizeUnionOptions) => SchemaRepresentation[];
|
|
6
|
+
export {};
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { half } from "./half.js";
|
|
2
|
+
export const buildIntersectionTree = (members) => {
|
|
3
|
+
if (members.length === 0) {
|
|
4
|
+
return { expression: "z.never()", type: "z.ZodNever" };
|
|
5
|
+
}
|
|
6
|
+
if (members.length === 1) {
|
|
7
|
+
return members[0];
|
|
8
|
+
}
|
|
9
|
+
if (members.length === 2) {
|
|
10
|
+
const [left, right] = members;
|
|
11
|
+
return {
|
|
12
|
+
expression: `z.intersection(${left.expression}, ${right.expression})`,
|
|
13
|
+
type: `z.ZodIntersection<${left.type}, ${right.type}>`,
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
const [leftItems, rightItems] = half(members);
|
|
17
|
+
const left = buildIntersectionTree(leftItems);
|
|
18
|
+
const right = buildIntersectionTree(rightItems);
|
|
19
|
+
return {
|
|
20
|
+
expression: `z.intersection(${left.expression}, ${right.expression})`,
|
|
21
|
+
type: `z.ZodIntersection<${left.type}, ${right.type}>`,
|
|
22
|
+
};
|
|
23
|
+
};
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { resolveRef } from "./resolveRef.js";
|
|
2
|
+
const mergeProperties = (target, targetPaths, props, basePath) => {
|
|
3
|
+
for (const [key, schema] of Object.entries(props)) {
|
|
4
|
+
if (!(key in target)) {
|
|
5
|
+
target[key] = schema;
|
|
6
|
+
targetPaths[key] = [...basePath, "properties", key];
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
};
|
|
10
|
+
/**
|
|
11
|
+
* Collects all properties from a schema, including properties defined in allOf members.
|
|
12
|
+
* Returns merged properties object, combined required array, and property source paths.
|
|
13
|
+
*/
|
|
14
|
+
export const collectSchemaProperties = (schema, refs) => {
|
|
15
|
+
let properties = {};
|
|
16
|
+
let required = [];
|
|
17
|
+
const propertyPaths = {};
|
|
18
|
+
// Collect direct properties
|
|
19
|
+
if (schema.properties) {
|
|
20
|
+
mergeProperties(properties, propertyPaths, schema.properties, refs.path);
|
|
21
|
+
}
|
|
22
|
+
// Collect direct required
|
|
23
|
+
if (Array.isArray(schema.required)) {
|
|
24
|
+
required = [...required, ...schema.required];
|
|
25
|
+
}
|
|
26
|
+
// Collect from allOf members
|
|
27
|
+
if (Array.isArray(schema.allOf)) {
|
|
28
|
+
schema.allOf.forEach((member, index) => {
|
|
29
|
+
if (typeof member !== "object" || member === null)
|
|
30
|
+
return;
|
|
31
|
+
let resolvedMember = member;
|
|
32
|
+
let memberPath = [...refs.path, "allOf", index];
|
|
33
|
+
if (resolvedMember.$ref || resolvedMember.$dynamicRef) {
|
|
34
|
+
const resolved = resolveRef(resolvedMember, (resolvedMember.$ref || resolvedMember.$dynamicRef), refs);
|
|
35
|
+
if (resolved && typeof resolved.schema === "object" && resolved.schema !== null) {
|
|
36
|
+
resolvedMember = resolved.schema;
|
|
37
|
+
memberPath = resolved.path;
|
|
38
|
+
}
|
|
39
|
+
else {
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
if (resolvedMember.properties) {
|
|
44
|
+
mergeProperties(properties, propertyPaths, resolvedMember.properties, memberPath);
|
|
45
|
+
}
|
|
46
|
+
if (Array.isArray(resolvedMember.required)) {
|
|
47
|
+
required = [...required, ...resolvedMember.required];
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
if (Object.keys(properties).length === 0) {
|
|
52
|
+
return undefined;
|
|
53
|
+
}
|
|
54
|
+
return { properties, required: [...new Set(required)], propertyPaths };
|
|
55
|
+
};
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
const unionPrefix = "z.union([";
|
|
2
|
+
const unionSuffix = "])";
|
|
3
|
+
const splitTopLevelList = (input, options) => {
|
|
4
|
+
const parts = [];
|
|
5
|
+
let current = "";
|
|
6
|
+
let depth = 0;
|
|
7
|
+
let angleDepth = 0;
|
|
8
|
+
let inString = null;
|
|
9
|
+
let escapeNext = false;
|
|
10
|
+
for (let i = 0; i < input.length; i += 1) {
|
|
11
|
+
const char = input[i];
|
|
12
|
+
if (escapeNext) {
|
|
13
|
+
current += char;
|
|
14
|
+
escapeNext = false;
|
|
15
|
+
continue;
|
|
16
|
+
}
|
|
17
|
+
if (inString) {
|
|
18
|
+
current += char;
|
|
19
|
+
if (char === "\\") {
|
|
20
|
+
escapeNext = true;
|
|
21
|
+
}
|
|
22
|
+
else if (char === inString) {
|
|
23
|
+
inString = null;
|
|
24
|
+
}
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
if (char === "'" || char === '"' || char === "`") {
|
|
28
|
+
inString = char;
|
|
29
|
+
current += char;
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
if (char === "(" || char === "[" || char === "{") {
|
|
33
|
+
depth += 1;
|
|
34
|
+
current += char;
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
if (char === ")" || char === "]" || char === "}") {
|
|
38
|
+
depth -= 1;
|
|
39
|
+
current += char;
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
if (options?.includeAngles) {
|
|
43
|
+
if (char === "<") {
|
|
44
|
+
angleDepth += 1;
|
|
45
|
+
current += char;
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
if (char === ">") {
|
|
49
|
+
angleDepth -= 1;
|
|
50
|
+
current += char;
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
if (char === "," && depth === 0 && angleDepth === 0) {
|
|
55
|
+
parts.push(current.trim());
|
|
56
|
+
current = "";
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
current += char;
|
|
60
|
+
}
|
|
61
|
+
if (current.trim().length > 0) {
|
|
62
|
+
parts.push(current.trim());
|
|
63
|
+
}
|
|
64
|
+
return parts;
|
|
65
|
+
};
|
|
66
|
+
const isPlainUnionExpression = (expression) => expression.startsWith(unionPrefix) && expression.endsWith(unionSuffix);
|
|
67
|
+
const isPlainUnionType = (type) => type.startsWith("z.ZodUnion<") && type.endsWith(">");
|
|
68
|
+
const extractUnionMembers = (rep) => {
|
|
69
|
+
if (!isPlainUnionExpression(rep.expression) || !isPlainUnionType(rep.type)) {
|
|
70
|
+
return undefined;
|
|
71
|
+
}
|
|
72
|
+
const exprInner = rep.expression.slice(unionPrefix.length, -unionSuffix.length).trim();
|
|
73
|
+
if (!exprInner)
|
|
74
|
+
return undefined;
|
|
75
|
+
const typeStart = rep.type.indexOf("[");
|
|
76
|
+
const typeEnd = rep.type.lastIndexOf("]");
|
|
77
|
+
if (typeStart === -1 || typeEnd === -1 || typeEnd <= typeStart) {
|
|
78
|
+
return undefined;
|
|
79
|
+
}
|
|
80
|
+
const typeInner = rep.type.slice(typeStart + 1, typeEnd).trim();
|
|
81
|
+
if (!typeInner)
|
|
82
|
+
return undefined;
|
|
83
|
+
const expressions = splitTopLevelList(exprInner);
|
|
84
|
+
const types = splitTopLevelList(typeInner, { includeAngles: true });
|
|
85
|
+
if (expressions.length === 0 || expressions.length !== types.length) {
|
|
86
|
+
return undefined;
|
|
87
|
+
}
|
|
88
|
+
return expressions.map((expression, index) => ({
|
|
89
|
+
expression,
|
|
90
|
+
type: types[index] ?? "z.ZodTypeAny",
|
|
91
|
+
}));
|
|
92
|
+
};
|
|
93
|
+
const isPlainNull = (rep) => rep.expression === "z.null()" && rep.type === "z.ZodNull";
|
|
94
|
+
const isNullable = (rep) => rep.expression.endsWith(".nullable()") || rep.type.startsWith("z.ZodNullable<");
|
|
95
|
+
const makeNullable = (rep) => {
|
|
96
|
+
if (isNullable(rep))
|
|
97
|
+
return rep;
|
|
98
|
+
return {
|
|
99
|
+
expression: `${rep.expression}.nullable()`,
|
|
100
|
+
type: `z.ZodNullable<${rep.type}>`,
|
|
101
|
+
};
|
|
102
|
+
};
|
|
103
|
+
export const normalizeUnionMembers = (members, options) => {
|
|
104
|
+
const flattened = [];
|
|
105
|
+
for (const member of members) {
|
|
106
|
+
const extracted = extractUnionMembers(member);
|
|
107
|
+
if (extracted) {
|
|
108
|
+
flattened.push(...extracted);
|
|
109
|
+
}
|
|
110
|
+
else {
|
|
111
|
+
flattened.push(member);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
const seen = new Set();
|
|
115
|
+
const unique = [];
|
|
116
|
+
for (const member of flattened) {
|
|
117
|
+
if (seen.has(member.expression))
|
|
118
|
+
continue;
|
|
119
|
+
seen.add(member.expression);
|
|
120
|
+
unique.push(member);
|
|
121
|
+
}
|
|
122
|
+
if (options?.foldNullable) {
|
|
123
|
+
const nullIndex = unique.findIndex(isPlainNull);
|
|
124
|
+
if (nullIndex !== -1) {
|
|
125
|
+
const nonNull = unique.filter((_, index) => index !== nullIndex);
|
|
126
|
+
if (nonNull.length === 1) {
|
|
127
|
+
return [makeNullable(nonNull[0])];
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return unique;
|
|
132
|
+
};
|
|
@@ -519,6 +519,10 @@ export const shouldUseGetter = (rep, currentSchemaName, cycleRefNames, cycleComp
|
|
|
519
519
|
// Check if the expression directly references the current schema (self-recursion)
|
|
520
520
|
if (rep.expression === currentSchemaName)
|
|
521
521
|
return true;
|
|
522
|
+
// Handle wrappers like .exactOptional() or z.lazy(() => RefName)
|
|
523
|
+
const selfRefPattern = new RegExp(`\\b${currentSchemaName}\\b`);
|
|
524
|
+
if (selfRefPattern.test(rep.expression))
|
|
525
|
+
return true;
|
|
522
526
|
// Check if expression contains a reference to a cycle member in the same SCC
|
|
523
527
|
if (!cycleRefNames || cycleRefNames.size === 0)
|
|
524
528
|
return false;
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
# Zod v4 native JSON Schema -> Zod conversion (fromJSONSchema)
|
|
2
|
+
|
|
3
|
+
This document captures how Zod implements its native JSON Schema to Zod conversion in v4, based on the local repo at `/Users/gbryk/Repos/zod`. It focuses on the concrete implementation in `from-json-schema.ts` and the related tests/docs, and then compares it to our `json-schema-to-zod` approach.
|
|
4
|
+
|
|
5
|
+
## Scope and sources
|
|
6
|
+
|
|
7
|
+
Primary implementation:
|
|
8
|
+
|
|
9
|
+
- Zod v4 classic converter: `packages/zod/src/v4/classic/from-json-schema.ts`.
|
|
10
|
+
- Export surface: `packages/zod/src/v4/classic/external.ts` (re-exports `fromJSONSchema`).
|
|
11
|
+
- Docs: `packages/docs/content/json-schema.mdx`.
|
|
12
|
+
- Tests: `packages/zod/src/v4/classic/tests/from-json-schema.test.ts`.
|
|
13
|
+
|
|
14
|
+
Our repo reference points (for comparison):
|
|
15
|
+
|
|
16
|
+
- Entry: `src/jsonSchemaToZod.ts`.
|
|
17
|
+
- Analysis pass + cycle detection: `src/core/analyzeSchema.ts`.
|
|
18
|
+
- Parsing: `src/parsers/parseSchema.ts`, `src/parsers/parseObject.ts`, `src/parsers/parseAllOf.ts`, `src/parsers/parseAnyOf.ts`, `src/parsers/parseOneOf.ts`.
|
|
19
|
+
- allOf property collection: `src/utils/collectSchemaProperties.ts`.
|
|
20
|
+
- Ref resolution and external registry: `src/utils/resolveRef.ts`.
|
|
21
|
+
- Emission: `src/core/emitZod.ts`.
|
|
22
|
+
|
|
23
|
+
## High-level architecture (Zod)
|
|
24
|
+
|
|
25
|
+
Zod's native converter is runtime-only and returns a `ZodType` instance, not generated source code. The entire pipeline is implemented in `from-json-schema.ts` and follows this flow:
|
|
26
|
+
|
|
27
|
+
1. `fromJSONSchema(...)` handles boolean schemas and creates a conversion context. (`packages/zod/src/v4/classic/from-json-schema.ts:622-642`)
|
|
28
|
+
2. Version detection uses `$schema` with a default fallback. (`packages/zod/src/v4/classic/from-json-schema.ts:104-119`)
|
|
29
|
+
3. The converter calls `convertSchema(...)`, which:
|
|
30
|
+
- Builds the base schema ignoring composition keywords via `convertBaseSchema(...)`.
|
|
31
|
+
- Applies composition (anyOf/oneOf/allOf) after the base schema is built.
|
|
32
|
+
- Applies OpenAPI `nullable`, `readOnly`.
|
|
33
|
+
- Captures metadata for unknown keys in a registry. (`packages/zod/src/v4/classic/from-json-schema.ts:541-616`)
|
|
34
|
+
|
|
35
|
+
The converter uses a local `z` object to avoid circular dependencies with `../index.js` by directly spreading internal module exports. (`packages/zod/src/v4/classic/from-json-schema.ts:8-13`)
|
|
36
|
+
|
|
37
|
+
## Entry point and conversion context
|
|
38
|
+
|
|
39
|
+
### fromJSONSchema
|
|
40
|
+
|
|
41
|
+
- Entry point: `fromJSONSchema(schema, params)` in `packages/zod/src/v4/classic/from-json-schema.ts:622-642`.
|
|
42
|
+
- Boolean schema handling:
|
|
43
|
+
- `true` => `z.any()`.
|
|
44
|
+
- `false` => `z.never()`.
|
|
45
|
+
- Builds a `ConversionContext` with:
|
|
46
|
+
- `version`: draft-2020-12, draft-7, draft-4, or openapi-3.0.
|
|
47
|
+
- `defs`: `$defs` or `definitions` map.
|
|
48
|
+
- `refs`: cache of resolved refs.
|
|
49
|
+
- `processing`: cycle detection set.
|
|
50
|
+
- `rootSchema` and `registry`.
|
|
51
|
+
|
|
52
|
+
### Version detection
|
|
53
|
+
|
|
54
|
+
- `detectVersion` reads `$schema` and maps known draft URLs; otherwise defaults to draft-2020-12 unless `defaultTarget` is set. (`packages/zod/src/v4/classic/from-json-schema.ts:104-119`)
|
|
55
|
+
|
|
56
|
+
## Ref handling and cycles (Zod)
|
|
57
|
+
|
|
58
|
+
### resolveRef
|
|
59
|
+
|
|
60
|
+
- Only local refs are supported. Any `$ref` not starting with `#` throws an error. (`packages/zod/src/v4/classic/from-json-schema.ts:121-124`)
|
|
61
|
+
- Ref targets are limited to `$defs` (draft-2020-12) or `definitions` (draft-7/4) only. (`packages/zod/src/v4/classic/from-json-schema.ts:133-141`)
|
|
62
|
+
- `#` by itself references the root schema. (`packages/zod/src/v4/classic/from-json-schema.ts:128-131`)
|
|
63
|
+
|
|
64
|
+
### Cycle handling
|
|
65
|
+
|
|
66
|
+
- When a `$ref` is encountered, `convertBaseSchema`:
|
|
67
|
+
- Returns cached value if already resolved.
|
|
68
|
+
- Detects an in-flight ref via `processing` and returns `z.lazy(...)` to break cycles. (`packages/zod/src/v4/classic/from-json-schema.ts:169-183`)
|
|
69
|
+
- Otherwise resolves the ref and stores the resulting Zod schema in `refs` cache. (`packages/zod/src/v4/classic/from-json-schema.ts:185-190`)
|
|
70
|
+
|
|
71
|
+
There is no explicit graph analysis. Cycle handling is purely on the `$ref` resolution stack.
|
|
72
|
+
|
|
73
|
+
## Unsupported keywords and error strategy
|
|
74
|
+
|
|
75
|
+
Zod refuses some schema keywords outright by throwing errors in `convertBaseSchema`:
|
|
76
|
+
|
|
77
|
+
- `not` (except `{ not: {} }` which becomes `z.never()`). (`packages/zod/src/v4/classic/from-json-schema.ts:146-154`)
|
|
78
|
+
- `unevaluatedItems`, `unevaluatedProperties`. (`packages/zod/src/v4/classic/from-json-schema.ts:155-160`)
|
|
79
|
+
- `if/then/else`. (`packages/zod/src/v4/classic/from-json-schema.ts:161-163`)
|
|
80
|
+
- `dependentSchemas` and `dependentRequired`. (`packages/zod/src/v4/classic/from-json-schema.ts:164-165`)
|
|
81
|
+
|
|
82
|
+
This means Zod's converter is intentionally partial and avoids emulating these features with refinements.
|
|
83
|
+
|
|
84
|
+
## Base schema conversion (convertBaseSchema)
|
|
85
|
+
|
|
86
|
+
### Enum and const
|
|
87
|
+
|
|
88
|
+
- `enum` cases:
|
|
89
|
+
- Empty enum => `z.never()`.
|
|
90
|
+
- Single value => `z.literal(value)`.
|
|
91
|
+
- String-only enums => `z.enum([...])`.
|
|
92
|
+
- Mixed types => `z.union` of literals.
|
|
93
|
+
- OpenAPI nullable + enum `[null]` special-case returns `z.null()`. (`packages/zod/src/v4/classic/from-json-schema.ts:193-226`)
|
|
94
|
+
- `const` => `z.literal(schema.const)` (`packages/zod/src/v4/classic/from-json-schema.ts:229-231`)
|
|
95
|
+
|
|
96
|
+
### Type arrays
|
|
97
|
+
|
|
98
|
+
- If `type` is an array, it is expanded into a union by cloning the schema per type. (`packages/zod/src/v4/classic/from-json-schema.ts:237-249`)
|
|
99
|
+
|
|
100
|
+
### No explicit type
|
|
101
|
+
|
|
102
|
+
- If `type` is missing, Zod returns `z.any()`. (`packages/zod/src/v4/classic/from-json-schema.ts:252-255`)
|
|
103
|
+
|
|
104
|
+
### Strings
|
|
105
|
+
|
|
106
|
+
- String format mapping uses `.check(...)` with Zod validators for a fixed set of formats: email, url/uri-reference, uuid/guid, date-time/date/time/duration, ipv4/ipv6, mac, cidr, base64, base64url, e164, jwt, emoji, nanoid, cuid/cuid2/ulid/xid/ksuid. (`packages/zod/src/v4/classic/from-json-schema.ts:263-313`)
|
|
107
|
+
- Constraints:
|
|
108
|
+
- `minLength` => `.min(...)`.
|
|
109
|
+
- `maxLength` => `.max(...)`.
|
|
110
|
+
- `pattern` => `.regex(new RegExp(...))` (no implicit anchors). (`packages/zod/src/v4/classic/from-json-schema.ts:318-327`)
|
|
111
|
+
|
|
112
|
+
### Numbers and integers
|
|
113
|
+
|
|
114
|
+
- Integer => `z.number().int()`; number => `z.number()`.
|
|
115
|
+
- Constraints: `minimum`, `maximum`, `exclusiveMinimum`, `exclusiveMaximum`, `multipleOf`. (`packages/zod/src/v4/classic/from-json-schema.ts:334-356`)
|
|
116
|
+
|
|
117
|
+
### Boolean and null
|
|
118
|
+
|
|
119
|
+
- `boolean` => `z.boolean()`.
|
|
120
|
+
- `null` => `z.null()`. (`packages/zod/src/v4/classic/from-json-schema.ts:363-370`)
|
|
121
|
+
|
|
122
|
+
### Objects
|
|
123
|
+
|
|
124
|
+
- Properties are converted to a Zod shape; optionality is based on `required` only. (`packages/zod/src/v4/classic/from-json-schema.ts:373-383`)
|
|
125
|
+
- `propertyNames`:
|
|
126
|
+
- If there are no properties, it becomes `z.record(keySchema, valueSchema)`.
|
|
127
|
+
- Otherwise it intersects `z.object(shape).passthrough()` with `z.looseRecord(keySchema, valueSchema)`. (`packages/zod/src/v4/classic/from-json-schema.ts:385-403`)
|
|
128
|
+
- `patternProperties`:
|
|
129
|
+
- Produces a chain of `z.intersection(...)` with `z.looseRecord` for each pattern. (`packages/zod/src/v4/classic/from-json-schema.ts:406-439`)
|
|
130
|
+
- `additionalProperties`:
|
|
131
|
+
- `false` => `object.strict()`.
|
|
132
|
+
- schema => `object.catchall(schema)`.
|
|
133
|
+
- `true` or omitted => `object.passthrough()`. (`packages/zod/src/v4/classic/from-json-schema.ts:443-456`)
|
|
134
|
+
|
|
135
|
+
### Arrays
|
|
136
|
+
|
|
137
|
+
- Tuples:
|
|
138
|
+
- Draft 2020-12 uses `prefixItems`; draft-7 uses `items` array.
|
|
139
|
+
- Additional items use `items` (2020-12) or `additionalItems` (draft-7).
|
|
140
|
+
- `minItems`/`maxItems` applied to tuples via `.check(z.minLength/maxLength)`.
|
|
141
|
+
- Implementation uses `z.tuple(...).rest(...)`. (`packages/zod/src/v4/classic/from-json-schema.ts:467-505`)
|
|
142
|
+
- Regular arrays:
|
|
143
|
+
- `items` => `z.array(items)` with min/max constraints.
|
|
144
|
+
- Missing items => `z.array(z.any())`. (`packages/zod/src/v4/classic/from-json-schema.ts:505-521`)
|
|
145
|
+
- `uniqueItems`, `contains`, `minContains`, `maxContains` are explicitly TODO (unsupported). (`packages/zod/src/v4/classic/from-json-schema.ts:460-462`)
|
|
146
|
+
|
|
147
|
+
### Description and default
|
|
148
|
+
|
|
149
|
+
- `description` => `.describe(...)`.
|
|
150
|
+
- `default` => `.default(...)`. (`packages/zod/src/v4/classic/from-json-schema.ts:530-536`)
|
|
151
|
+
|
|
152
|
+
## Composition (anyOf, oneOf, allOf)
|
|
153
|
+
|
|
154
|
+
Composition is applied after base conversion in `convertSchema`:
|
|
155
|
+
|
|
156
|
+
- `anyOf` => `z.union([...])`. (`packages/zod/src/v4/classic/from-json-schema.ts:552-556`)
|
|
157
|
+
- `oneOf` => `z.xor([...])` (exclusive union). (`packages/zod/src/v4/classic/from-json-schema.ts:558-563`)
|
|
158
|
+
- `allOf` => chain of `z.intersection(...)`. (`packages/zod/src/v4/classic/from-json-schema.ts:565-575`)
|
|
159
|
+
|
|
160
|
+
If the schema also has an explicit type/enum/const, the base schema is intersected with the composition. Otherwise the composition becomes the base result. (`packages/zod/src/v4/classic/from-json-schema.ts:546-575`)
|
|
161
|
+
|
|
162
|
+
### Empty allOf behavior
|
|
163
|
+
|
|
164
|
+
- No explicit type + empty `allOf` => `z.any()`.
|
|
165
|
+
- Explicit type + empty `allOf` => base schema only. (`packages/zod/src/v4/classic/from-json-schema.ts:566-575`)
|
|
166
|
+
|
|
167
|
+
## OpenAPI extensions
|
|
168
|
+
|
|
169
|
+
- `nullable` (OpenAPI 3.0 only) => `z.nullable(...)`. (`packages/zod/src/v4/classic/from-json-schema.ts:579-582`)
|
|
170
|
+
- `readOnly` => `z.readonly(...)`. (`packages/zod/src/v4/classic/from-json-schema.ts:584-587`)
|
|
171
|
+
|
|
172
|
+
## Metadata capture
|
|
173
|
+
|
|
174
|
+
- Zod collects additional metadata using a registry. It builds a `extraMeta` object with:
|
|
175
|
+
- Core schema ID keys: `$id`, `id`, `$comment`, `$anchor`, `$vocabulary`, `$dynamicRef`, `$dynamicAnchor`.
|
|
176
|
+
- Content keywords: `contentEncoding`, `contentMediaType`, `contentSchema`.
|
|
177
|
+
- Any unrecognized keys (i.e., keys not in `RECOGNIZED_KEYS`). (`packages/zod/src/v4/classic/from-json-schema.ts:589-616`)
|
|
178
|
+
- Those metadata are attached via `ctx.registry.add(baseSchema, extraMeta)`. (`packages/zod/src/v4/classic/from-json-schema.ts:615-616`)
|
|
179
|
+
- The recognized key set is defined in `RECOGNIZED_KEYS` (`packages/zod/src/v4/classic/from-json-schema.ts:31-102`).
|
|
180
|
+
|
|
181
|
+
## Test coverage signals
|
|
182
|
+
|
|
183
|
+
The following tests illustrate intended behaviors:
|
|
184
|
+
|
|
185
|
+
- anyOf/oneOf/allOf and empty allOf handling: `packages/zod/src/v4/classic/tests/from-json-schema.test.ts:162-205`.
|
|
186
|
+
- Intersection behavior with explicit type: `packages/zod/src/v4/classic/tests/from-json-schema.test.ts:210-242`.
|
|
187
|
+
|
|
188
|
+
These tests confirm the “base schema then composition” strategy and the exclusive `oneOf` behavior.
|
|
189
|
+
|
|
190
|
+
## Differences vs our json-schema-to-zod implementation
|
|
191
|
+
|
|
192
|
+
### Output model
|
|
193
|
+
|
|
194
|
+
- Zod returns runtime `ZodType` instances (`fromJSONSchema`). (`packages/zod/src/v4/classic/from-json-schema.ts:622-642`)
|
|
195
|
+
- Our converter emits TypeScript source code strings. (`src/jsonSchemaToZod.ts:1-20`, `src/core/emitZod.ts:1-120`)
|
|
196
|
+
|
|
197
|
+
### Pipeline
|
|
198
|
+
|
|
199
|
+
- Zod uses a single conversion pipeline with a local cache and recursion handling.
|
|
200
|
+
- Our converter uses a two-pass analysis for declarations, deps, and cycles, then a separate emission pass. (`src/core/analyzeSchema.ts:39-153`, `src/core/emitZod.ts:1-120`)
|
|
201
|
+
|
|
202
|
+
### Ref resolution
|
|
203
|
+
|
|
204
|
+
- Zod only supports local refs under `#/$defs` or `#/definitions` and throws on external refs. (`packages/zod/src/v4/classic/from-json-schema.ts:121-143`)
|
|
205
|
+
- Our converter supports `$ref`, `$dynamicRef`, `$recursiveRef`, dynamic anchors, and can load external schemas into a registry. (`src/utils/resolveRef.ts:14-138`)
|
|
206
|
+
|
|
207
|
+
### Object + required + allOf
|
|
208
|
+
|
|
209
|
+
- Zod does not merge `allOf` properties into a base object. Required properties are only applied to keys defined in `properties` on the current schema. (`packages/zod/src/v4/classic/from-json-schema.ts:373-383`)
|
|
210
|
+
- Our `parseObject` collects properties from `allOf` (including ref targets) via `collectSchemaProperties` and uses those to avoid `any` for required-but-missing keys. (`src/parsers/parseObject.ts:14-41`, `src/utils/collectSchemaProperties.ts:24-83`)
|
|
211
|
+
|
|
212
|
+
This is the same class of issue described in `/private/tmp/workflow-schema-any-issue.md` (required key defined only in allOf). Zod’s converter would not enforce such a property at the base object level; it relies on `allOf` intersection to validate the property instead.
|
|
213
|
+
|
|
214
|
+
### Composition logic
|
|
215
|
+
|
|
216
|
+
- Zod: `anyOf` => union, `oneOf` => xor, `allOf` => intersection, always applied after base conversion. (`packages/zod/src/v4/classic/from-json-schema.ts:541-575`)
|
|
217
|
+
- Ours:
|
|
218
|
+
- `anyOf` uses union but may lift inline objects to top-level declarations. (`src/parsers/parseAnyOf.ts:17-41`)
|
|
219
|
+
- `oneOf` supports discriminated-union detection and "required-only" oneOf refinements. (`src/parsers/parseOneOf.ts:12-170`)
|
|
220
|
+
- `allOf` may use a spread merge optimization for inline object-only allOf, otherwise intersection. (`src/parsers/parseAllOf.ts:64-147`)
|
|
221
|
+
|
|
222
|
+
### Keyword support
|
|
223
|
+
|
|
224
|
+
- Zod throws on `not`, `if/then/else`, `dependentSchemas`, `dependentRequired`, `unevaluated*`. (`packages/zod/src/v4/classic/from-json-schema.ts:146-165`)
|
|
225
|
+
- Our converter implements `not` and `if/then/else` using `refine`/`superRefine` and supports dependent schemas/required with additional refinements. (`src/parsers/parseNot.ts:1-17`, `src/parsers/parseIfThenElse.ts:1-36`, `src/parsers/parseObject.ts:209-257`)
|
|
226
|
+
|
|
227
|
+
### Arrays
|
|
228
|
+
|
|
229
|
+
- Zod does not implement `uniqueItems` or `contains` constraints in conversion. (`packages/zod/src/v4/classic/from-json-schema.ts:460-462`)
|
|
230
|
+
- Our converter implements `uniqueItems` and `contains` with `superRefine`. (`src/parsers/parseArray.ts:58-170`)
|
|
231
|
+
|
|
232
|
+
### String format coverage
|
|
233
|
+
|
|
234
|
+
- Zod supports a fixed list of formats and ignores custom ones. (`packages/zod/src/v4/classic/from-json-schema.ts:263-313`)
|
|
235
|
+
- Our converter maps additional formats and implements custom refinements for formats like `ip`, `hostname`, `uri-reference`, etc. (`src/parsers/parseString.ts:12-156`)
|
|
236
|
+
|
|
237
|
+
### Metadata
|
|
238
|
+
|
|
239
|
+
- Zod stores extra metadata in a registry and does not modify the schema via `meta()` calls directly. (`packages/zod/src/v4/classic/from-json-schema.ts:589-616`)
|
|
240
|
+
- Our converter emits `.describe(...)` and `.meta(...)` calls with a curated allowlist for known keywords. (`src/parsers/parseSchema.ts:203-257`)
|
|
241
|
+
|
|
242
|
+
## Implications for the current bug class
|
|
243
|
+
|
|
244
|
+
The issue in `/private/tmp/workflow-schema-any-issue.md` (required keys defined in allOf) is a pattern Zod does not explicitly handle in base object conversion. Zod expects the allOf intersection to enforce those requirements, but it does not rewrite the base object shape to include those properties.
|
|
245
|
+
|
|
246
|
+
Our converter attempts to merge properties from allOf into the base object shape to avoid falling back to `z.any()` for required-but-missing keys. This is implemented in `collectSchemaProperties` and used in `parseObject` (`src/utils/collectSchemaProperties.ts:24-83`, `src/parsers/parseObject.ts:14-41`).
|
|
247
|
+
|
|
248
|
+
If we want to align more with Zod’s approach, we would rely on allOf intersections exclusively. If we want stronger typing and explicit properties, we should keep (and expand) our merge strategy but make sure it is comprehensive (e.g., also consider `oneOf`/`anyOf` property merges where they are structurally safe).
|
|
249
|
+
|
|
250
|
+
## Potential comparison checklist for further analysis
|
|
251
|
+
|
|
252
|
+
If you want a more systematic parity report, these are the main axes to evaluate between Zod and our implementation:
|
|
253
|
+
|
|
254
|
+
- $ref support: local-only vs registry-based with external resolution.
|
|
255
|
+
- Cycle handling: lazy from ref stack vs full cycle graph detection and two-pass parse.
|
|
256
|
+
- Composition ordering: base then composition (Zod) vs parser-first composition rules (ours).
|
|
257
|
+
- Required + allOf property merging: absent in Zod, present in our parseObject.
|
|
258
|
+
- Conditional schemas: Zod throws; we emulate.
|
|
259
|
+
- Unsupported keywords: Zod throws on more features; we implement in refinement.
|
|
260
|
+
- Metadata strategy: registry vs emitted `.meta()`/`.describe()`.
|
package/open-issues.md
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
# Open Issues
|
|
2
|
+
|
|
3
|
+
Standard issue format (use for all entries):
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
## [Title]
|
|
7
|
+
- Status: open | investigating | blocked | fixed
|
|
8
|
+
- Category: correctness | type-safety | performance | ergonomics
|
|
9
|
+
- Summary: <1–2 sentences>
|
|
10
|
+
- Evidence: <file:line or schema path>
|
|
11
|
+
- Impact: <who/what is affected>
|
|
12
|
+
- Proposed fix: <short plan>
|
|
13
|
+
- Related: <issue titles>
|
|
14
|
+
- Depends on: <issue titles>
|
|
15
|
+
- Notes: <optional>
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## [unevaluatedProperties is ignored]
|
|
19
|
+
|
|
20
|
+
- Status: open
|
|
21
|
+
- Category: correctness
|
|
22
|
+
- Summary: `unevaluatedProperties: false` is not enforced, so many generated objects are looser than the schema requires.
|
|
23
|
+
- Evidence: `test/fixtures/workflow.yaml` (multiple occurrences); `src/parsers/parseObject.ts` has no handling; example output `ListenTaskSchema` in `.tmp-workflow-schema-output.ts:1827` uses `z.looseObject`.
|
|
24
|
+
- Impact: Extra keys pass validation and types remain open, diverging from the JSON Schema contract.
|
|
25
|
+
- Proposed fix: Implement `unevaluatedProperties` handling in `parseObject` (at least for non-composed objects; consider strategy for `allOf/oneOf/anyOf`).
|
|
26
|
+
- Related: minProperties/maxProperties are not enforced; Default openness (fallbacks + passthrough) is not configurable
|
|
27
|
+
- Depends on: —
|
|
28
|
+
- Notes: A phased implementation can trade strictness for runtime/type complexity.
|
|
29
|
+
|
|
30
|
+
## [minProperties/maxProperties are not enforced]
|
|
31
|
+
|
|
32
|
+
- Status: open
|
|
33
|
+
- Category: correctness
|
|
34
|
+
- Summary: `minProperties`/`maxProperties` constraints are emitted as metadata but never validated.
|
|
35
|
+
- Evidence: `.tmp-workflow-schema-output.ts:2167` (`SwitchItemSchema`), `.tmp-workflow-schema-output.ts:2436` (`TaskListSchema` item), `.tmp-workflow-schema-output.ts:2495` (`ExtensionItemSchema`), `.tmp-workflow-schema-output.ts:260` (`ErrorFilterSchema`), `.tmp-workflow-schema-output.ts:660` (`DurationInline`).
|
|
36
|
+
- Impact: Objects meant to be non-empty or single-key allow invalid shapes.
|
|
37
|
+
- Proposed fix: Add object-level property count validation in `parseObject` (likely via `superRefine`), with awareness of `additionalProperties`, `patternProperties`, and `unevaluatedProperties`.
|
|
38
|
+
- Related: unevaluatedProperties is ignored
|
|
39
|
+
- Depends on: —
|
|
40
|
+
- Notes: None.
|
|
41
|
+
|
|
42
|
+
## [Required property without schema falls back to z.any]
|
|
43
|
+
|
|
44
|
+
- Status: open
|
|
45
|
+
- Category: type-safety
|
|
46
|
+
- Summary: Required keys that have no property schema are emitted as `z.any()`.
|
|
47
|
+
- Evidence: `.tmp-workflow-schema-output.ts:152` (`McpClientSchema.version`); schema requires `version` but no definition exists (`test/fixtures/workflow.yaml:606-620`).
|
|
48
|
+
- Impact: Output type is overly permissive and hides schema inconsistencies.
|
|
49
|
+
- Proposed fix: Emit a warning when `required` contains undefined properties; optionally support a strict mode that errors on this.
|
|
50
|
+
- Related: Default openness (fallbacks + passthrough) is not configurable
|
|
51
|
+
- Depends on: —
|
|
52
|
+
- Notes: This is a schema authoring issue, but surfacing it improves trust in generated output.
|
|
53
|
+
|
|
54
|
+
## [anyOf with empty schema collapses to z.any]
|
|
55
|
+
|
|
56
|
+
- Status: open
|
|
57
|
+
- Category: type-safety
|
|
58
|
+
- Summary: `anyOf: [<schema>, {}]` becomes `z.union([<schema>, z.any()])`, which is effectively `z.any()`.
|
|
59
|
+
- Evidence: `.tmp-workflow-schema-output.ts:1609` (`EventProperties.data`); source schema uses `{}` in `anyOf` (`test/fixtures/workflow.yaml:1549-1553`).
|
|
60
|
+
- Impact: Types and validation are wider than intended; unions become noisy without adding constraints.
|
|
61
|
+
- Proposed fix: Normalize unions/anyOf to detect empty schemas and collapse explicitly to `z.any()`/`z.unknown()` (or emit a warning).
|
|
62
|
+
- Related: Default openness (fallbacks + passthrough) is not configurable
|
|
63
|
+
- Depends on: —
|
|
64
|
+
- Notes: If `useUnknown` is enabled, prefer `z.unknown()` for better type safety.
|
|
65
|
+
|
|
66
|
+
## [Default openness (fallbacks + passthrough) is not configurable]
|
|
67
|
+
|
|
68
|
+
- Status: open
|
|
69
|
+
- Category: ergonomics
|
|
70
|
+
- Summary: Missing schemas and `additionalProperties` defaults result in permissive output, with no opt-in strictness mode.
|
|
71
|
+
- Evidence: `src/utils/anyOrUnknown.ts`, `src/parsers/parseSchema.ts`, `src/parsers/parseObject.ts`.
|
|
72
|
+
- Impact: Users who want strict types/validation must modify schemas rather than toggling a generator option.
|
|
73
|
+
- Proposed fix: Add a strictness option that defaults to `unknown`, enforces `additionalProperties` as strict/strip, and optionally tightens recursive record handling.
|
|
74
|
+
- Related: unevaluatedProperties is ignored; Required property without schema falls back to z.any; anyOf with empty schema collapses to z.any
|
|
75
|
+
- Depends on: —
|
|
76
|
+
- Notes: Keep default behavior spec-correct; make strictness opt-in.
|
|
77
|
+
|
|
78
|
+
## [additionalProperties forces object typing even when schema is unioned with non-objects]
|
|
79
|
+
|
|
80
|
+
- Status: open
|
|
81
|
+
- Category: correctness
|
|
82
|
+
- Summary: When `additionalProperties` is set alongside `oneOf/anyOf`, the parser assumes the schema is an object and emits an object intersection, which drops non-object branches.
|
|
83
|
+
- Evidence: `HTTPQuerySchema` in `.tmp-workflow-schema-output.ts:171` is `z.intersection(z.looseObject({}), z.xor([object, runtimeExpression]))` while the source allows a runtime-expression branch (`test/fixtures/workflow.yaml:386-398`).
|
|
84
|
+
- Impact: Legitimate non-object values fail validation and types are too narrow.
|
|
85
|
+
- Proposed fix: When `additionalProperties` exists, only force object typing if the schema is explicitly `type: object`; otherwise keep unions intact and apply `additionalProperties` only to object branches.
|
|
86
|
+
- Related: Default openness (fallbacks + passthrough) is not configurable
|
|
87
|
+
- Depends on: —
|
|
88
|
+
- Notes: This is a correctness bug (not just optimization).
|
|
89
|
+
|
|
90
|
+
## [Large union splitting to avoid TS7056]
|
|
91
|
+
|
|
92
|
+
- Status: open
|
|
93
|
+
- Category: performance
|
|
94
|
+
- Summary: Very large unions can trigger TS7056 (“inferred type exceeds maximum length”) when emitting declarations; flattening unions can worsen this by expanding the literal union in `.d.ts`.
|
|
95
|
+
- Evidence: Zod issue https://github.com/colinhacks/zod/issues/1040; multiple reports of TS7056 with big unions in libs that emit declarations.
|
|
96
|
+
- Impact: Builds fail when emitting `.d.ts` for large schemas; forces manual annotations or schema splitting.
|
|
97
|
+
- Proposed fix: Add optional union-splitting options for `anyOf`/`type: []` unions:
|
|
98
|
+
- `maxUnionSize?: number` (threshold to split).
|
|
99
|
+
- `unionSplitMode?: "inline" | "named"` (named emits `const UnionPartN = z.union([...])` to let TS refer to `typeof UnionPartN` instead of serializing the full literal union).
|
|
100
|
+
- `unionSplitStrategy?: "chunk" | "balanced"` (chunk into fixed size or balanced tree).
|
|
101
|
+
- (Optional) `unionTypeAnnotation?: boolean` to emit `z.ZodUnion<[...]>`/`z.ZodType<...>` annotations on named sub-unions, which further reduces serialization size.
|
|
102
|
+
- Related: String-based optimizations are brittle (need structured IR)
|
|
103
|
+
- Depends on: —
|
|
104
|
+
- Notes: Pure nesting (`z.union([z.union([...]), ...])`) is often insufficient because TS flattens unions during inference; naming sub-unions is the more reliable workaround.
|
|
105
|
+
|
|
106
|
+
## [String-based optimizations are brittle (need structured IR)]
|
|
107
|
+
|
|
108
|
+
- Status: open
|
|
109
|
+
- Category: ergonomics
|
|
110
|
+
- Summary: Several optimizations parse emitted expression strings (`z.union(...)`, `z.intersection(...)`) instead of operating on structured data.
|
|
111
|
+
- Evidence: `src/utils/normalizeUnion.ts`, `src/utils/schemaRepresentation.ts`, `src/core/emitZod.ts`.
|
|
112
|
+
- Impact: Output changes can silently disable optimizations; harder to maintain/refactor.
|
|
113
|
+
- Proposed fix: Introduce a structured IR (e.g., `SchemaRepresentation` gains `kind`, `children`, `meta`) and perform normalizations on IR before emitting strings; keep a string emission phase only at the end.
|
|
114
|
+
- Related: Large union splitting to avoid TS7056
|
|
115
|
+
- Depends on: —
|
|
116
|
+
- Notes: This can start with union/intersection nodes before a full rewrite.
|
|
117
|
+
|
|
118
|
+
## [Recursive unions should be wrapped in z.lazy to preserve inference]
|
|
119
|
+
|
|
120
|
+
- Status: open
|
|
121
|
+
- Category: type-safety
|
|
122
|
+
- Summary: TypeScript collapses mutually recursive discriminated unions (especially with optional props) to `{}`/`unknown` when the union is emitted directly; we currently only wrap refs in `z.lazy`, not the union expression itself.
|
|
123
|
+
- Evidence: `src/parsers/parseOneOf.ts` and `src/parsers/parseAnyOf.ts` emit unions directly; Zod issue #5309 reproduces unknown inference with recursive discriminated unions + `.optional()`; workflow task lists are recursive unions (see `test/fixtures/workflow.yaml` Task list references).
|
|
124
|
+
- Impact: Nested task configs (`try`, `fork`, `listen`, `foreach`) can degrade to `{}`/`unknown` in `z.infer`, forcing consumer casts even though runtime validation is correct.
|
|
125
|
+
- Proposed fix: Detect when a union schema participates in a recursion cycle (via `cycleComponentByName`/`cycleRefNames` or dependency graph) and emit `z.lazy(() => z.discriminatedUnion(...))` / `z.lazy(() => z.union([...]))` with `z.ZodLazy<...>` typing; leave non-recursive unions unchanged.
|
|
126
|
+
- Related: Large union splitting to avoid TS7056; Optional explicit type alias emission for recursive schemas
|
|
127
|
+
- Depends on: —
|
|
128
|
+
- Notes: Zod issue #5309 reports explicit getter annotations are insufficient; union-level `z.lazy` is the most reliable workaround today.
|
|
129
|
+
|
|
130
|
+
## [Optional explicit type alias emission for recursive schemas]
|
|
131
|
+
|
|
132
|
+
- Status: open
|
|
133
|
+
- Category: type-safety
|
|
134
|
+
- Summary: Even with lazy/getter patterns, TypeScript can infer `{}`/`unknown` for recursive schemas; emitting explicit TS type aliases (or annotating lazies with `z.ZodType<Foo>`) can stabilize inference for consumers.
|
|
135
|
+
- Evidence: `src/core/emitZod.ts` only emits Zod schema consts; no `type Foo = z.output<typeof FooSchema>` is generated; workflow recursion paths require runtime interfaces today (see `test/fixtures/workflow.yaml` task list recursion).
|
|
136
|
+
- Impact: Consumers must hand-write runtime interfaces or `as` assertions for recursive fields; type safety depends on user code rather than the generator.
|
|
137
|
+
- Proposed fix: Add an opt-in `emitTypeAliases`/`typeExports` mode to export `type Foo = z.output<typeof FooSchema>` (or schema-derived TS types) and optionally annotate lazies as `z.ZodType<Foo>` to preserve inference across cycles.
|
|
138
|
+
- Related: Recursive unions should be wrapped in z.lazy to preserve inference
|
|
139
|
+
- Depends on: —
|
|
140
|
+
- Notes: Keep default output unchanged; opt-in to avoid larger bundle size and to allow staged adoption.
|