@gabrielbryk/json-schema-to-zod 2.10.0 → 2.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (138) hide show
  1. package/AGENTS.md +44 -0
  2. package/CHANGELOG.md +35 -0
  3. package/README.md +6 -33
  4. package/check-types-lift.sh +23 -0
  5. package/check-types.sh +20 -0
  6. package/dist/{esm/cli.js → cli.js} +0 -6
  7. package/dist/{esm/core → core}/analyzeSchema.js +4 -5
  8. package/dist/core/emitZod.js +263 -0
  9. package/dist/{esm/generators → generators}/generateBundle.js +225 -67
  10. package/dist/{esm/index.js → index.js} +6 -0
  11. package/dist/jsonSchemaToZod.js +17 -0
  12. package/dist/parsers/parseAllOf.js +125 -0
  13. package/dist/parsers/parseAnyOf.js +28 -0
  14. package/dist/{esm/parsers → parsers}/parseArray.js +27 -11
  15. package/dist/parsers/parseBoolean.js +4 -0
  16. package/dist/parsers/parseConst.js +22 -0
  17. package/dist/parsers/parseEnum.js +35 -0
  18. package/dist/{esm/parsers → parsers}/parseIfThenElse.js +11 -7
  19. package/dist/parsers/parseMultipleType.js +10 -0
  20. package/dist/parsers/parseNot.js +14 -0
  21. package/dist/parsers/parseNull.js +4 -0
  22. package/dist/parsers/parseNullable.js +12 -0
  23. package/dist/{esm/parsers → parsers}/parseNumber.js +4 -1
  24. package/dist/{esm/parsers → parsers}/parseObject.js +168 -29
  25. package/dist/parsers/parseOneOf.js +365 -0
  26. package/dist/{esm/parsers → parsers}/parseSchema.js +56 -110
  27. package/dist/parsers/parseSimpleDiscriminatedOneOf.js +24 -0
  28. package/dist/{esm/parsers → parsers}/parseString.js +29 -18
  29. package/dist/types/Types.d.ts +32 -4
  30. package/dist/types/core/analyzeSchema.d.ts +3 -2
  31. package/dist/types/generators/generateBundle.d.ts +0 -2
  32. package/dist/types/index.d.ts +6 -0
  33. package/dist/types/parsers/parseAllOf.d.ts +2 -2
  34. package/dist/types/parsers/parseAnyOf.d.ts +2 -2
  35. package/dist/types/parsers/parseArray.d.ts +2 -2
  36. package/dist/types/parsers/parseBoolean.d.ts +2 -1
  37. package/dist/types/parsers/parseConst.d.ts +2 -2
  38. package/dist/types/parsers/parseDefault.d.ts +2 -2
  39. package/dist/types/parsers/parseEnum.d.ts +2 -2
  40. package/dist/types/parsers/parseIfThenElse.d.ts +2 -2
  41. package/dist/types/parsers/parseMultipleType.d.ts +2 -2
  42. package/dist/types/parsers/parseNot.d.ts +2 -2
  43. package/dist/types/parsers/parseNull.d.ts +2 -1
  44. package/dist/types/parsers/parseNullable.d.ts +2 -2
  45. package/dist/types/parsers/parseNumber.d.ts +2 -2
  46. package/dist/types/parsers/parseObject.d.ts +2 -2
  47. package/dist/types/parsers/parseOneOf.d.ts +2 -2
  48. package/dist/types/parsers/parseSchema.d.ts +2 -2
  49. package/dist/types/parsers/parseSimpleDiscriminatedOneOf.d.ts +2 -2
  50. package/dist/types/parsers/parseString.d.ts +2 -2
  51. package/dist/types/utils/anyOrUnknown.d.ts +5 -4
  52. package/dist/types/utils/esmEmitter.d.ts +29 -0
  53. package/dist/types/utils/extractInlineObject.d.ts +15 -0
  54. package/dist/types/utils/liftInlineObjects.d.ts +21 -0
  55. package/dist/types/utils/namingService.d.ts +21 -0
  56. package/dist/types/utils/resolveRef.d.ts +7 -0
  57. package/dist/types/utils/schemaRepresentation.d.ts +71 -0
  58. package/dist/utils/anyOrUnknown.js +13 -0
  59. package/dist/{esm/utils → utils}/buildRefRegistry.js +4 -0
  60. package/dist/utils/esmEmitter.js +87 -0
  61. package/dist/utils/extractInlineObject.js +119 -0
  62. package/dist/utils/liftInlineObjects.js +476 -0
  63. package/dist/utils/namingService.js +58 -0
  64. package/dist/utils/resolveRef.js +92 -0
  65. package/dist/utils/schemaRepresentation.js +569 -0
  66. package/docs/IMPROVEMENT-PLAN.md +243 -0
  67. package/docs/ZOD-V4-RECURSIVE-TYPE-LIMITATIONS.md +292 -0
  68. package/docs/proposals/bundle-refactor.md +1 -1
  69. package/docs/proposals/discriminated-union-with-default.md +248 -0
  70. package/docs/proposals/inline-object-lifting.md +77 -0
  71. package/eslint.config.js +4 -2
  72. package/jest.config.mjs +19 -0
  73. package/package.json +17 -20
  74. package/scripts/generateWorkflowSchema.ts +0 -1
  75. package/dist/cjs/Types.js +0 -2
  76. package/dist/cjs/cli.js +0 -70
  77. package/dist/cjs/core/analyzeSchema.js +0 -62
  78. package/dist/cjs/core/emitZod.js +0 -141
  79. package/dist/cjs/generators/generateBundle.js +0 -365
  80. package/dist/cjs/index.js +0 -50
  81. package/dist/cjs/jsonSchemaToZod.js +0 -10
  82. package/dist/cjs/package.json +0 -1
  83. package/dist/cjs/parsers/parseAllOf.js +0 -46
  84. package/dist/cjs/parsers/parseAnyOf.js +0 -18
  85. package/dist/cjs/parsers/parseArray.js +0 -90
  86. package/dist/cjs/parsers/parseBoolean.js +0 -5
  87. package/dist/cjs/parsers/parseConst.js +0 -7
  88. package/dist/cjs/parsers/parseDefault.js +0 -8
  89. package/dist/cjs/parsers/parseEnum.js +0 -21
  90. package/dist/cjs/parsers/parseIfThenElse.js +0 -35
  91. package/dist/cjs/parsers/parseMultipleType.js +0 -10
  92. package/dist/cjs/parsers/parseNot.js +0 -12
  93. package/dist/cjs/parsers/parseNull.js +0 -5
  94. package/dist/cjs/parsers/parseNullable.js +0 -12
  95. package/dist/cjs/parsers/parseNumber.js +0 -116
  96. package/dist/cjs/parsers/parseObject.js +0 -315
  97. package/dist/cjs/parsers/parseOneOf.js +0 -53
  98. package/dist/cjs/parsers/parseSchema.js +0 -411
  99. package/dist/cjs/parsers/parseSimpleDiscriminatedOneOf.js +0 -21
  100. package/dist/cjs/parsers/parseString.js +0 -317
  101. package/dist/cjs/utils/anyOrUnknown.js +0 -14
  102. package/dist/cjs/utils/buildRefRegistry.js +0 -56
  103. package/dist/cjs/utils/cliTools.js +0 -108
  104. package/dist/cjs/utils/cycles.js +0 -113
  105. package/dist/cjs/utils/half.js +0 -7
  106. package/dist/cjs/utils/jsdocs.js +0 -20
  107. package/dist/cjs/utils/omit.js +0 -11
  108. package/dist/cjs/utils/resolveUri.js +0 -16
  109. package/dist/cjs/utils/withMessage.js +0 -21
  110. package/dist/cjs/zodToJsonSchema.js +0 -89
  111. package/dist/esm/core/emitZod.js +0 -137
  112. package/dist/esm/jsonSchemaToZod.js +0 -6
  113. package/dist/esm/package.json +0 -1
  114. package/dist/esm/parsers/parseAllOf.js +0 -43
  115. package/dist/esm/parsers/parseAnyOf.js +0 -14
  116. package/dist/esm/parsers/parseBoolean.js +0 -1
  117. package/dist/esm/parsers/parseConst.js +0 -3
  118. package/dist/esm/parsers/parseEnum.js +0 -17
  119. package/dist/esm/parsers/parseMultipleType.js +0 -6
  120. package/dist/esm/parsers/parseNot.js +0 -8
  121. package/dist/esm/parsers/parseNull.js +0 -1
  122. package/dist/esm/parsers/parseNullable.js +0 -8
  123. package/dist/esm/parsers/parseOneOf.js +0 -49
  124. package/dist/esm/parsers/parseSimpleDiscriminatedOneOf.js +0 -17
  125. package/dist/esm/utils/anyOrUnknown.js +0 -10
  126. package/jest.config.cjs +0 -4
  127. package/postcjs.cjs +0 -1
  128. package/postesm.cjs +0 -1
  129. /package/dist/{esm/Types.js → Types.js} +0 -0
  130. /package/dist/{esm/parsers → parsers}/parseDefault.js +0 -0
  131. /package/dist/{esm/utils → utils}/cliTools.js +0 -0
  132. /package/dist/{esm/utils → utils}/cycles.js +0 -0
  133. /package/dist/{esm/utils → utils}/half.js +0 -0
  134. /package/dist/{esm/utils → utils}/jsdocs.js +0 -0
  135. /package/dist/{esm/utils → utils}/omit.js +0 -0
  136. /package/dist/{esm/utils → utils}/resolveUri.js +0 -0
  137. /package/dist/{esm/utils → utils}/withMessage.js +0 -0
  138. /package/dist/{esm/zodToJsonSchema.js → zodToJsonSchema.js} +0 -0
@@ -3,16 +3,20 @@ import { parseSchema } from "./parseSchema.js";
3
3
  import { anyOrUnknown } from "../utils/anyOrUnknown.js";
4
4
  export const parseArray = (schema, refs) => {
5
5
  if (Array.isArray(schema.items)) {
6
- let tuple = `z.tuple([${schema.items.map((v, i) => parseSchema(v, { ...refs, path: [...refs.path, "items", i] }))}])`;
6
+ // Tuple case
7
+ const itemResults = schema.items.map((v, i) => parseSchema(v, { ...refs, path: [...refs.path, "items", i] }));
8
+ let tuple = `z.tuple([${itemResults.map(r => r.expression).join(", ")}])`;
9
+ const tupleTypes = itemResults.map(r => r.type).join(", ");
10
+ let tupleType = `z.ZodTuple<[${tupleTypes}]>`;
7
11
  if (schema.contains) {
8
- const containsSchema = parseSchema(schema.contains, {
12
+ const containsResult = parseSchema(schema.contains, {
9
13
  ...refs,
10
14
  path: [...refs.path, "contains"],
11
15
  });
12
16
  const minContains = schema.minContains ?? (schema.contains ? 1 : undefined);
13
17
  const maxContains = schema.maxContains;
14
18
  tuple += `.superRefine((arr, ctx) => {
15
- const matches = arr.filter((item) => ${containsSchema}.safeParse(item).success).length;
19
+ const matches = arr.filter((item) => ${containsResult.expression}.safeParse(item).success).length;
16
20
  if (${minContains ?? 0} && matches < ${minContains ?? 0}) {
17
21
  ctx.addIssue({ code: "custom", message: "Array contains too few matching items" });
18
22
  }
@@ -20,15 +24,23 @@ export const parseArray = (schema, refs) => {
20
24
  ctx.addIssue({ code: "custom", message: "Array contains too many matching items" });
21
25
  }
22
26
  })`;
27
+ // In Zod v4, .superRefine() doesn't change the type
23
28
  }
24
- return tuple;
29
+ return {
30
+ expression: tuple,
31
+ type: tupleType,
32
+ };
25
33
  }
26
- let r = !schema.items
27
- ? `z.array(${anyOrUnknown(refs)})`
28
- : `z.array(${parseSchema(schema.items, {
34
+ // Array case
35
+ const anyOrUnknownResult = anyOrUnknown(refs);
36
+ const itemResult = !schema.items
37
+ ? anyOrUnknownResult
38
+ : parseSchema(schema.items, {
29
39
  ...refs,
30
40
  path: [...refs.path, "items"],
31
- })})`;
41
+ });
42
+ let r = `z.array(${itemResult.expression})`;
43
+ let arrayType = `z.ZodArray<${itemResult.type}>`;
32
44
  r += withMessage(schema, "minItems", ({ json }) => ({
33
45
  opener: `.min(${json}`,
34
46
  closer: ")",
@@ -66,14 +78,14 @@ export const parseArray = (schema, refs) => {
66
78
  })`;
67
79
  }
68
80
  if (schema.contains) {
69
- const containsSchema = parseSchema(schema.contains, {
81
+ const containsResult = parseSchema(schema.contains, {
70
82
  ...refs,
71
83
  path: [...refs.path, "contains"],
72
84
  });
73
85
  const minContains = schema.minContains ?? (schema.contains ? 1 : undefined);
74
86
  const maxContains = schema.maxContains;
75
87
  r += `.superRefine((arr, ctx) => {
76
- const matches = arr.filter((item) => ${containsSchema}.safeParse(item).success).length;
88
+ const matches = arr.filter((item) => ${containsResult.expression}.safeParse(item).success).length;
77
89
  if (${minContains ?? 0} && matches < ${minContains ?? 0}) {
78
90
  ctx.addIssue({ code: "custom", message: "Array contains too few matching items" });
79
91
  }
@@ -82,5 +94,9 @@ export const parseArray = (schema, refs) => {
82
94
  }
83
95
  })`;
84
96
  }
85
- return r;
97
+ // In Zod v4, .superRefine() doesn't change the type, so no wrapping needed
98
+ return {
99
+ expression: r,
100
+ type: arrayType,
101
+ };
86
102
  };
@@ -0,0 +1,4 @@
1
+ export const parseBoolean = () => ({
2
+ expression: "z.boolean()",
3
+ type: "z.ZodBoolean",
4
+ });
@@ -0,0 +1,22 @@
1
+ export const parseConst = (schema) => {
2
+ const value = schema.const;
3
+ const expression = `z.literal(${JSON.stringify(value)})`;
4
+ // Determine the literal type based on the value type
5
+ let type;
6
+ if (typeof value === "string") {
7
+ type = `z.ZodLiteral<${JSON.stringify(value)}>`;
8
+ }
9
+ else if (typeof value === "number") {
10
+ type = `z.ZodLiteral<${value}>`;
11
+ }
12
+ else if (typeof value === "boolean") {
13
+ type = `z.ZodLiteral<${value}>`;
14
+ }
15
+ else if (value === null) {
16
+ type = "z.ZodLiteral<null>";
17
+ }
18
+ else {
19
+ type = "z.ZodLiteral<unknown>";
20
+ }
21
+ return { expression, type };
22
+ };
@@ -0,0 +1,35 @@
1
+ export const parseEnum = (schema) => {
2
+ if (schema.enum.length === 0) {
3
+ return {
4
+ expression: "z.never()",
5
+ type: "z.ZodNever",
6
+ };
7
+ }
8
+ else if (schema.enum.length === 1) {
9
+ // union does not work when there is only one element
10
+ const value = schema.enum[0];
11
+ return {
12
+ expression: `z.literal(${JSON.stringify(value)})`,
13
+ type: `z.ZodLiteral<${typeof value === "string" ? JSON.stringify(value) : value}>`,
14
+ };
15
+ }
16
+ else if (schema.enum.every((x) => typeof x === "string")) {
17
+ const values = schema.enum;
18
+ // Zod v4 ZodEnum uses object format: { key: "key"; ... }
19
+ const enumObject = values.map((x) => `${JSON.stringify(x)}: ${JSON.stringify(x)}`).join("; ");
20
+ return {
21
+ expression: `z.enum([${values.map((x) => JSON.stringify(x))}])`,
22
+ type: `z.ZodEnum<{ ${enumObject} }>`,
23
+ };
24
+ }
25
+ else {
26
+ // Mixed types: create union of literals
27
+ const literalTypes = schema.enum.map((x) => typeof x === "string" ? JSON.stringify(x) : x === null ? "null" : String(x));
28
+ return {
29
+ expression: `z.union([${schema.enum
30
+ .map((x) => `z.literal(${JSON.stringify(x)})`)
31
+ .join(", ")}])`,
32
+ type: `z.ZodUnion<[${literalTypes.map((t) => `z.ZodLiteral<${t}>`).join(", ")}]>`,
33
+ };
34
+ }
35
+ };
@@ -9,13 +9,13 @@ export const parseIfThenElse = (schema, refs) => {
9
9
  ...refs,
10
10
  path: [...refs.path, "else"],
11
11
  });
12
- let result = `z.union([${$then}, ${$else}]).superRefine((value,ctx) => {
13
- const result = ${$if}.safeParse(value).success
14
- ? ${$then}.safeParse(value)
15
- : ${$else}.safeParse(value);
12
+ let expression = `z.union([${$then.expression}, ${$else.expression}]).superRefine((value,ctx) => {
13
+ const result = ${$if.expression}.safeParse(value).success
14
+ ? ${$then.expression}.safeParse(value)
15
+ : ${$else.expression}.safeParse(value);
16
16
  if (!result.success) {
17
17
  const issues = result.error.issues;
18
- issues.forEach((issue) => ctx.addIssue(issue))
18
+ issues.forEach((issue) => ctx.addIssue({ ...issue }))
19
19
  }
20
20
  })`;
21
21
  // Store original if/then/else for JSON Schema round-trip
@@ -25,7 +25,11 @@ export const parseIfThenElse = (schema, refs) => {
25
25
  then: schema.then,
26
26
  else: schema.else,
27
27
  });
28
- result += `.meta({ __jsonSchema: { conditional: ${conditionalMeta} } })`;
28
+ expression += `.meta({ __jsonSchema: { conditional: ${conditionalMeta} } })`;
29
29
  }
30
- return result;
30
+ return {
31
+ expression,
32
+ // In Zod v4, .superRefine() doesn't change the type
33
+ type: `z.ZodUnion<[${$then.type}, ${$else.type}]>`,
34
+ };
31
35
  };
@@ -0,0 +1,10 @@
1
+ import { parseSchema } from "./parseSchema.js";
2
+ 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(", ");
6
+ return {
7
+ expression: `z.union([${expressions}])`,
8
+ type: `z.ZodUnion<[${types}]>`,
9
+ };
10
+ };
@@ -0,0 +1,14 @@
1
+ import { parseSchema } from "./parseSchema.js";
2
+ import { anyOrUnknown } from "../utils/anyOrUnknown.js";
3
+ export const parseNot = (schema, refs) => {
4
+ const baseSchema = anyOrUnknown(refs);
5
+ const notSchema = parseSchema(schema.not, {
6
+ ...refs,
7
+ path: [...refs.path, "not"],
8
+ });
9
+ return {
10
+ expression: `${baseSchema.expression}.refine((value) => !${notSchema.expression}.safeParse(value).success, "Invalid input: Should NOT be valid against schema")`,
11
+ // In Zod v4, .refine() doesn't change the type
12
+ type: baseSchema.type,
13
+ };
14
+ };
@@ -0,0 +1,4 @@
1
+ export const parseNull = () => ({
2
+ expression: "z.null()",
3
+ type: "z.ZodNull",
4
+ });
@@ -0,0 +1,12 @@
1
+ import { omit } from "../utils/omit.js";
2
+ import { parseSchema } from "./parseSchema.js";
3
+ /**
4
+ * For compatibility with open api 3.0 nullable
5
+ */
6
+ export const parseNullable = (schema, refs) => {
7
+ const innerSchema = parseSchema(omit(schema, "nullable"), refs, true);
8
+ return {
9
+ expression: `${innerSchema.expression}.nullable()`,
10
+ type: `z.ZodNullable<${innerSchema.type}>`,
11
+ };
12
+ };
@@ -108,5 +108,8 @@ export const parseNumber = (schema) => {
108
108
  messageCloser: " })",
109
109
  }));
110
110
  }
111
- return r;
111
+ return {
112
+ expression: r,
113
+ type: "z.ZodNumber",
114
+ };
112
115
  };
@@ -5,8 +5,36 @@ import { parseAllOf } from "./parseAllOf.js";
5
5
  import { parseIfThenElse } from "./parseIfThenElse.js";
6
6
  import { addJsdocs } from "../utils/jsdocs.js";
7
7
  import { anyOrUnknown } from "../utils/anyOrUnknown.js";
8
+ import { containsRecursiveRef, inferTypeFromExpression } from "../utils/schemaRepresentation.js";
8
9
  export function parseObject(objectSchema, refs) {
10
+ // Optimization: if we have composition keywords (allOf/anyOf/oneOf) but no direct properties,
11
+ // delegate entirely to the composition parser to avoid generating z.object({}).and(...)
12
+ const hasDirectProperties = objectSchema.properties && Object.keys(objectSchema.properties).length > 0;
13
+ const hasAdditionalProperties = objectSchema.additionalProperties !== undefined;
14
+ const hasPatternProperties = objectSchema.patternProperties !== undefined;
15
+ const hasNoDirectSchema = !hasDirectProperties && !hasAdditionalProperties && !hasPatternProperties;
16
+ // Helper to add type: "object" to composition members that have properties but no explicit type
17
+ const addObjectType = (members) => members.map((x) => typeof x === "object" &&
18
+ x !== null &&
19
+ !x.type &&
20
+ (x.properties || x.additionalProperties || x.patternProperties)
21
+ ? { ...x, type: "object" }
22
+ : x);
23
+ // If only allOf, delegate to parseAllOf
24
+ if (hasNoDirectSchema && its.an.allOf(objectSchema) && !its.an.anyOf(objectSchema) && !its.a.oneOf(objectSchema) && !its.a.conditional(objectSchema)) {
25
+ return parseAllOf({ ...objectSchema, allOf: addObjectType(objectSchema.allOf) }, refs);
26
+ }
27
+ // If only anyOf, delegate to parseAnyOf
28
+ if (hasNoDirectSchema && its.an.anyOf(objectSchema) && !its.an.allOf(objectSchema) && !its.a.oneOf(objectSchema) && !its.a.conditional(objectSchema)) {
29
+ return parseAnyOf({ ...objectSchema, anyOf: addObjectType(objectSchema.anyOf) }, refs);
30
+ }
31
+ // If only oneOf, delegate to parseOneOf
32
+ if (hasNoDirectSchema && its.a.oneOf(objectSchema) && !its.an.allOf(objectSchema) && !its.an.anyOf(objectSchema) && !its.a.conditional(objectSchema)) {
33
+ return parseOneOf({ ...objectSchema, oneOf: addObjectType(objectSchema.oneOf) }, refs);
34
+ }
9
35
  let properties = undefined;
36
+ // Track property types for building proper object type annotations
37
+ const propertyTypes = [];
10
38
  if (objectSchema.properties) {
11
39
  if (!Object.keys(objectSchema.properties).length) {
12
40
  properties = "z.object({})";
@@ -26,10 +54,18 @@ export function parseObject(objectSchema, refs) {
26
54
  : typeof propSchema === "object" && propSchema.required === true;
27
55
  const optional = !hasDefault && !required;
28
56
  const valueWithOptional = optional
29
- ? `${parsedProp}.optional()`
30
- : parsedProp;
31
- let result = shouldUseGetter(valueWithOptional, refs)
32
- ? `get ${JSON.stringify(key)}(){ return ${valueWithOptional} }`
57
+ ? `${parsedProp.expression}.optional()`
58
+ : parsedProp.expression;
59
+ // Calculate the type for getters (needed for recursive type inference)
60
+ const valueType = optional
61
+ ? `z.ZodOptional<${parsedProp.type}>`
62
+ : parsedProp.type;
63
+ // Track the property type for building the object type
64
+ propertyTypes.push({ key, type: valueType });
65
+ const useGetter = shouldUseGetter(valueWithOptional, refs);
66
+ let result = useGetter
67
+ // Type annotation on getter is required for recursive type inference in unions
68
+ ? `get ${JSON.stringify(key)}(): ${valueType} { return ${valueWithOptional} }`
33
69
  : `${JSON.stringify(key)}: ${valueWithOptional}`;
34
70
  if (refs.withJsdocs && typeof propSchema === "object") {
35
71
  result = addJsdocs(propSchema, result);
@@ -47,6 +83,10 @@ export function parseObject(objectSchema, refs) {
47
83
  })
48
84
  : undefined;
49
85
  const unevaluated = objectSchema.unevaluatedProperties;
86
+ const definedPropertyKeys = objectSchema.properties ? Object.keys(objectSchema.properties) : [];
87
+ const missingRequiredKeys = Array.isArray(objectSchema.required)
88
+ ? objectSchema.required.filter((key) => !definedPropertyKeys.includes(key))
89
+ : [];
50
90
  let patternProperties = undefined;
51
91
  if (objectSchema.patternProperties) {
52
92
  const parsedPatternProperties = Object.fromEntries(Object.entries(objectSchema.patternProperties).map(([key, value]) => {
@@ -58,33 +98,35 @@ export function parseObject(objectSchema, refs) {
58
98
  }),
59
99
  ];
60
100
  }, {}));
101
+ // Helper to get expressions from parsed pattern properties
102
+ const patternExprs = Object.values(parsedPatternProperties).map(r => r.expression);
61
103
  patternProperties = "";
62
104
  if (properties) {
63
105
  if (additionalProperties) {
64
106
  patternProperties += `.catchall(z.union([${[
65
- ...Object.values(parsedPatternProperties),
66
- additionalProperties,
107
+ ...patternExprs,
108
+ additionalProperties.expression,
67
109
  ].join(", ")}]))`;
68
110
  }
69
111
  else if (Object.keys(parsedPatternProperties).length > 1) {
70
- patternProperties += `.catchall(z.union([${Object.values(parsedPatternProperties).join(", ")}]))`;
112
+ patternProperties += `.catchall(z.union([${patternExprs.join(", ")}]))`;
71
113
  }
72
114
  else {
73
- patternProperties += `.catchall(${Object.values(parsedPatternProperties)})`;
115
+ patternProperties += `.catchall(${patternExprs.join("")})`;
74
116
  }
75
117
  }
76
118
  else {
77
119
  if (additionalProperties) {
78
120
  patternProperties += `z.record(z.string(), z.union([${[
79
- ...Object.values(parsedPatternProperties),
80
- additionalProperties,
121
+ ...patternExprs,
122
+ additionalProperties.expression,
81
123
  ].join(", ")}]))`;
82
124
  }
83
125
  else if (Object.keys(parsedPatternProperties).length > 1) {
84
- patternProperties += `z.record(z.string(), z.union([${Object.values(parsedPatternProperties).join(", ")}]))`;
126
+ patternProperties += `z.record(z.string(), z.union([${patternExprs.join(", ")}]))`;
85
127
  }
86
128
  else {
87
- patternProperties += `z.record(z.string(), ${Object.values(parsedPatternProperties)})`;
129
+ patternProperties += `z.record(z.string(), ${patternExprs.join("")})`;
88
130
  }
89
131
  }
90
132
  patternProperties += ".superRefine((value, ctx) => {\n";
@@ -107,7 +149,7 @@ export function parseObject(objectSchema, refs) {
107
149
  }
108
150
  patternProperties +=
109
151
  "const result = " +
110
- parsedPatternProperties[key] +
152
+ parsedPatternProperties[key].expression +
111
153
  ".safeParse(value[key])\n";
112
154
  patternProperties += "if (!result.success) {\n";
113
155
  patternProperties += `ctx.addIssue({
@@ -124,7 +166,7 @@ export function parseObject(objectSchema, refs) {
124
166
  if (additionalProperties) {
125
167
  patternProperties += "if (!evaluated) {\n";
126
168
  patternProperties +=
127
- "const result = " + additionalProperties + ".safeParse(value[key])\n";
169
+ "const result = " + additionalProperties.expression + ".safeParse(value[key])\n";
128
170
  patternProperties += "if (!result.success) {\n";
129
171
  patternProperties += `ctx.addIssue({
130
172
  path: [...(ctx.path ?? []), key],
@@ -152,22 +194,32 @@ export function parseObject(objectSchema, refs) {
152
194
  // In that case, we should NOT use .strict() because it will reject the additional keys
153
195
  // before the union gets a chance to validate them.
154
196
  const hasCompositionKeywords = its.an.anyOf(objectSchema) || its.a.oneOf(objectSchema) || its.an.allOf(objectSchema) || its.a.conditional(objectSchema);
197
+ // When there are composition keywords (allOf, anyOf, oneOf, if-then-else) but no direct properties,
198
+ // we should NOT default to z.record(z.string(), z.any()) because that would allow any properties.
199
+ // Instead, use z.object({}) and let the .and() call add properties from the composition.
200
+ // This is especially important when unevaluatedProperties: false is set.
201
+ const fallback = anyOrUnknown(refs);
155
202
  let output = properties
156
203
  ? patternProperties
157
204
  ? properties + patternProperties
158
205
  : additionalProperties
159
- ? additionalProperties === "z.never()"
206
+ ? additionalProperties.expression === "z.never()"
160
207
  // Don't use .strict() if there are composition keywords that add properties
161
208
  ? hasCompositionKeywords
162
209
  ? properties
163
210
  : properties + ".strict()"
164
- : properties + `.catchall(${additionalProperties})`
211
+ : properties + `.catchall(${additionalProperties.expression})`
165
212
  : properties
166
213
  : patternProperties
167
214
  ? patternProperties
168
215
  : additionalProperties
169
- ? `z.record(z.string(), ${additionalProperties})`
170
- : `z.record(z.string(), ${anyOrUnknown(refs)})`;
216
+ ? `z.record(z.string(), ${additionalProperties.expression})`
217
+ // If we have composition keywords, start with empty object instead of z.record()
218
+ // The composition will provide the actual schema via .and()
219
+ : hasCompositionKeywords
220
+ ? "z.object({})"
221
+ // No constraints = any object. Use z.record() which is cleaner than z.object({}).catchall()
222
+ : `z.record(z.string(), ${fallback.expression})`;
171
223
  if (unevaluated === false && properties && !hasCompositionKeywords) {
172
224
  output += ".strict()";
173
225
  }
@@ -185,7 +237,7 @@ export function parseObject(objectSchema, refs) {
185
237
  const isKnown = ${JSON.stringify(knownKeys)}.includes(key);
186
238
  const matchesPattern = ${patterns.length ? "[" + patterns.map((r) => r.toString()).join(",") + "]" : "[]"}.some((r) => r.test(key));
187
239
  if (!isKnown && !matchesPattern) {
188
- const result = ${unevaluatedSchema}.safeParse(value[key]);
240
+ const result = ${unevaluatedSchema.expression}.safeParse(value[key]);
189
241
  if (!result.success) {
190
242
  ctx.addIssue({ code: "custom", path: [key], message: "Invalid unevaluated property", params: { issues: result.error.issues } });
191
243
  }
@@ -193,8 +245,10 @@ export function parseObject(objectSchema, refs) {
193
245
  }
194
246
  })`;
195
247
  }
248
+ // Track intersection types added via .and() calls
249
+ const intersectionTypes = [];
196
250
  if (its.an.anyOf(objectSchema)) {
197
- output += `.and(${parseAnyOf({
251
+ const anyOfResult = parseAnyOf({
198
252
  ...objectSchema,
199
253
  anyOf: objectSchema.anyOf.map((x) => typeof x === "object" &&
200
254
  x !== null &&
@@ -202,10 +256,12 @@ export function parseObject(objectSchema, refs) {
202
256
  (x.properties || x.additionalProperties || x.patternProperties)
203
257
  ? { ...x, type: "object" }
204
258
  : x),
205
- }, refs)})`;
259
+ }, refs);
260
+ output += `.and(${anyOfResult.expression})`;
261
+ intersectionTypes.push(anyOfResult.type);
206
262
  }
207
263
  if (its.a.oneOf(objectSchema)) {
208
- output += `.and(${parseOneOf({
264
+ const oneOfResult = parseOneOf({
209
265
  ...objectSchema,
210
266
  oneOf: objectSchema.oneOf.map((x) => typeof x === "object" &&
211
267
  x !== null &&
@@ -213,10 +269,21 @@ export function parseObject(objectSchema, refs) {
213
269
  (x.properties || x.additionalProperties || x.patternProperties)
214
270
  ? { ...x, type: "object" }
215
271
  : x),
216
- }, refs)})`;
272
+ }, refs);
273
+ // Check if this is a refinement-only result (required fields validation)
274
+ // If so, apply superRefine directly instead of creating an intersection
275
+ const resultWithRefinement = oneOfResult;
276
+ if (resultWithRefinement.isRefinementOnly && resultWithRefinement.refinementBody) {
277
+ output += `.superRefine(${resultWithRefinement.refinementBody})`;
278
+ // No intersection type needed - superRefine doesn't change the type
279
+ }
280
+ else {
281
+ output += `.and(${oneOfResult.expression})`;
282
+ intersectionTypes.push(oneOfResult.type);
283
+ }
217
284
  }
218
285
  if (its.an.allOf(objectSchema)) {
219
- output += `.and(${parseAllOf({
286
+ const allOfResult = parseAllOf({
220
287
  ...objectSchema,
221
288
  allOf: objectSchema.allOf.map((x) => typeof x === "object" &&
222
289
  x !== null &&
@@ -224,11 +291,23 @@ export function parseObject(objectSchema, refs) {
224
291
  (x.properties || x.additionalProperties || x.patternProperties)
225
292
  ? { ...x, type: "object" }
226
293
  : x),
227
- }, refs)})`;
294
+ }, refs);
295
+ output += `.and(${allOfResult.expression})`;
296
+ intersectionTypes.push(allOfResult.type);
228
297
  }
229
298
  // Handle if/then/else conditionals on object schemas
230
299
  if (its.a.conditional(objectSchema)) {
231
- output += `.and(${parseIfThenElse(objectSchema, refs)})`;
300
+ const conditionalResult = parseIfThenElse(objectSchema, refs);
301
+ output += `.and(${conditionalResult.expression})`;
302
+ intersectionTypes.push(conditionalResult.type);
303
+ }
304
+ // Only add required validation for missing keys when there are no composition keywords
305
+ // When allOf/anyOf/oneOf exist, they should define the properties and handle required validation
306
+ if (missingRequiredKeys.length > 0 && !hasCompositionKeywords) {
307
+ const checks = missingRequiredKeys
308
+ .map((key) => `if (!Object.prototype.hasOwnProperty.call(value, ${JSON.stringify(key)})) { ctx.addIssue({ code: "custom", path: [${JSON.stringify(key)}], message: "Required property missing" }); }`)
309
+ .join(" ");
310
+ output += `.superRefine((value, ctx) => { if (value && typeof value === "object") { ${checks} } })`;
232
311
  }
233
312
  // propertyNames
234
313
  if (objectSchema.propertyNames) {
@@ -300,13 +379,73 @@ export function parseObject(objectSchema, refs) {
300
379
  })`;
301
380
  }
302
381
  }
303
- return output;
382
+ // Build the type representation from tracked property types
383
+ let type;
384
+ if (propertyTypes.length > 0) {
385
+ // Build proper object type with actual property types
386
+ const typeShape = propertyTypes
387
+ .map(({ key, type: propType }) => `${JSON.stringify(key)}: ${propType}`)
388
+ .join("; ");
389
+ type = `z.ZodObject<{ ${typeShape} }>`;
390
+ }
391
+ else if (properties === "z.object({})") {
392
+ // Empty object
393
+ type = "z.ZodObject<{}>";
394
+ }
395
+ else {
396
+ // Fallback for complex cases (patternProperties, record, etc.)
397
+ type = inferTypeFromExpression(output);
398
+ }
399
+ // Wrap in intersection types if .and() calls were added
400
+ for (const intersectionType of intersectionTypes) {
401
+ type = `z.ZodIntersection<${type}, ${intersectionType}>`;
402
+ }
403
+ return {
404
+ expression: output,
405
+ type,
406
+ };
304
407
  }
408
+ /**
409
+ * Determines if a property should use getter syntax for recursive references.
410
+ * Getters defer evaluation until access time, which is the Zod v4 recommended
411
+ * approach for handling recursive schemas in object properties.
412
+ */
305
413
  const shouldUseGetter = (parsed, refs) => {
306
414
  if (!parsed)
307
415
  return false;
308
- if (refs.currentSchemaName && parsed.includes(refs.currentSchemaName)) {
416
+ // Check for z.lazy() - these should use getters
417
+ if (parsed.includes("z.lazy("))
309
418
  return true;
419
+ // Check for direct self-recursion (expression contains the current schema name)
420
+ // This handles cases like generateSchemaBundle where the schema name is different
421
+ // from the def name (e.g., NodeSchema vs node)
422
+ if (refs.currentSchemaName) {
423
+ const selfRefPattern = new RegExp(`\\b${refs.currentSchemaName}\\b`);
424
+ if (selfRefPattern.test(parsed)) {
425
+ return true;
426
+ }
427
+ }
428
+ // Check for direct recursive references in the same SCC
429
+ if (refs.currentSchemaName && refs.cycleRefNames && refs.cycleComponentByName) {
430
+ const cycleRefNames = refs.cycleRefNames;
431
+ const cycleComponentByName = refs.cycleComponentByName;
432
+ const refNameArray = Array.from(cycleRefNames);
433
+ // Check if expression contains a reference to a cycle member in the same component
434
+ if (containsRecursiveRef(parsed, cycleRefNames)) {
435
+ const currentComponent = cycleComponentByName.get(refs.currentSchemaName);
436
+ if (currentComponent !== undefined) {
437
+ for (let i = 0; i < refNameArray.length; i++) {
438
+ const refName = refNameArray[i];
439
+ const pattern = new RegExp(`\\b${refName}\\b`);
440
+ if (pattern.test(parsed)) {
441
+ const refComponent = cycleComponentByName.get(refName);
442
+ if (refComponent === currentComponent) {
443
+ return true;
444
+ }
445
+ }
446
+ }
447
+ }
448
+ }
310
449
  }
311
- return Boolean(refs.inProgress && refs.inProgress.has(parsed));
450
+ return false;
312
451
  };