@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 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 expressions = members.map((m) => m.expression).join(", ");
23
- const types = members.map((m) => m.type).join(", ");
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 schemas = schema.type.map((type) => parseSchema({ ...schema, type }, { ...refs, withoutDefaults: true }));
4
- const expressions = schemas.map((s) => s.expression).join(", ");
5
- const types = schemas.map((s) => s.type).join(", ");
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}]>`,
@@ -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 baseSchema = anyOrUnknown(refs);
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: `${baseSchema.expression}.refine((value) => !${notSchema.expression}.safeParse(value).success, "Invalid input: Should NOT be valid against schema")`,
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: baseSchema.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 requiredProps = Array.isArray(objectSchema.required) ? objectSchema.required : [];
8
- const allProps = [...new Set([...explicitProps, ...requiredProps])];
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 propSchema = objectSchema.properties?.[key];
17
- const parsedProp = propSchema
18
- ? parseSchema(propSchema, { ...refs, path: [...refs.path, "properties", key] })
19
- : { expression: "z.any()", type: "z.ZodAny" };
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 = Array.isArray(objectSchema.required)
23
- ? objectSchema.required.includes(key)
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
- let finalExpr = baseObjectModified;
79
- const intersectionTypes = [];
80
- if (hasPattern) {
81
- for (const [pattern, schema] of Object.entries(patternProps)) {
82
- const validSchema = parseSchema(schema, {
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
- finalExpr += `.superRefine((value, ctx) => {
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
- finalExpr = `z.intersection(${finalExpr}, ${res.expression})`;
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
- finalExpr = `z.intersection(${finalExpr}, ${res.expression})`;
128
- intersectionTypes.push(res.type);
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
- finalExpr = `z.intersection(${finalExpr}, ${res.expression})`;
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
- let type = "z.ZodObject<any>";
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 an object property where getters work?
130
- // IMPORTANT: additionalProperties becomes z.record() (or .catchall()) which does NOT support getters for deferred evaluation
131
- // Only named properties (properties, patternProperties) can use getters
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
- // For recursive refs, use ZodTypeAny to avoid TypeScript circular inference errors ("implicitly has type 'any'")
137
- // User feedback: relying on ZodTypeAny loses type safety. We will try to rely on inference or ZodType<unknown>.
138
- // However, TS 4.x/5.x often requires explicit type for recursive inferred types.
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 || inRecordContext) {
145
- // We MUST use z.lazy() for ANY recursive reference, even in named properties given that z.object() is eager.
146
- // The previous optimization (skipping lazy for named properties) caused TDZ errors because getters on the arg object are evaluated immediately.
147
- return {
148
- expression: `z.lazy(() => ${refName})`,
149
- type: `z.ZodLazy<${refType}>`,
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
  };
@@ -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,2 @@
1
+ import { SchemaRepresentation } from "../Types.js";
2
+ export declare const buildIntersectionTree: (members: SchemaRepresentation[]) => SchemaRepresentation;
@@ -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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gabrielbryk/json-schema-to-zod",
3
- "version": "2.14.0",
3
+ "version": "2.14.2",
4
4
  "description": "Converts JSON schema objects or files into Zod schemas",
5
5
  "type": "module",
6
6
  "types": "./dist/types/index.d.ts",