@gabrielbryk/json-schema-to-zod 2.10.1 → 2.11.1
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/AGENTS.md +44 -0
- package/CHANGELOG.md +38 -0
- package/README.md +6 -33
- package/check-types-lift.sh +23 -0
- package/check-types.sh +20 -0
- package/dist/{esm/cli.js → cli.js} +0 -6
- package/dist/{esm/core → core}/analyzeSchema.js +4 -5
- package/dist/core/emitZod.js +263 -0
- package/dist/{esm/generators → generators}/generateBundle.js +26 -13
- package/dist/{esm/index.js → index.js} +6 -0
- package/dist/jsonSchemaToZod.js +17 -0
- package/dist/parsers/parseAllOf.js +125 -0
- package/dist/parsers/parseAnyOf.js +28 -0
- package/dist/{esm/parsers → parsers}/parseArray.js +27 -11
- package/dist/parsers/parseBoolean.js +4 -0
- package/dist/parsers/parseConst.js +22 -0
- package/dist/parsers/parseEnum.js +35 -0
- package/dist/{esm/parsers → parsers}/parseIfThenElse.js +10 -6
- package/dist/parsers/parseMultipleType.js +10 -0
- package/dist/parsers/parseNot.js +14 -0
- package/dist/parsers/parseNull.js +4 -0
- package/dist/parsers/parseNullable.js +12 -0
- package/dist/{esm/parsers → parsers}/parseNumber.js +4 -1
- package/dist/{esm/parsers → parsers}/parseObject.js +200 -37
- package/dist/parsers/parseOneOf.js +365 -0
- package/dist/{esm/parsers → parsers}/parseSchema.js +55 -117
- package/dist/parsers/parseSimpleDiscriminatedOneOf.js +24 -0
- package/dist/{esm/parsers → parsers}/parseString.js +29 -18
- package/dist/types/Types.d.ts +32 -4
- package/dist/types/core/analyzeSchema.d.ts +3 -2
- package/dist/types/generators/generateBundle.d.ts +0 -2
- package/dist/types/index.d.ts +6 -0
- package/dist/types/parsers/parseAllOf.d.ts +2 -2
- package/dist/types/parsers/parseAnyOf.d.ts +2 -2
- package/dist/types/parsers/parseArray.d.ts +2 -2
- package/dist/types/parsers/parseBoolean.d.ts +2 -1
- package/dist/types/parsers/parseConst.d.ts +2 -2
- package/dist/types/parsers/parseDefault.d.ts +2 -2
- package/dist/types/parsers/parseEnum.d.ts +2 -2
- package/dist/types/parsers/parseIfThenElse.d.ts +2 -2
- package/dist/types/parsers/parseMultipleType.d.ts +2 -2
- package/dist/types/parsers/parseNot.d.ts +2 -2
- package/dist/types/parsers/parseNull.d.ts +2 -1
- package/dist/types/parsers/parseNullable.d.ts +2 -2
- package/dist/types/parsers/parseNumber.d.ts +2 -2
- package/dist/types/parsers/parseObject.d.ts +2 -2
- package/dist/types/parsers/parseOneOf.d.ts +2 -2
- package/dist/types/parsers/parseSchema.d.ts +2 -2
- package/dist/types/parsers/parseSimpleDiscriminatedOneOf.d.ts +2 -2
- package/dist/types/parsers/parseString.d.ts +2 -2
- package/dist/types/utils/anyOrUnknown.d.ts +5 -4
- package/dist/types/utils/esmEmitter.d.ts +29 -0
- package/dist/types/utils/extractInlineObject.d.ts +15 -0
- package/dist/types/utils/liftInlineObjects.d.ts +21 -0
- package/dist/types/utils/namingService.d.ts +21 -0
- package/dist/types/utils/resolveRef.d.ts +7 -0
- package/dist/types/utils/schemaRepresentation.d.ts +71 -0
- package/dist/utils/anyOrUnknown.js +13 -0
- package/dist/{esm/utils → utils}/buildRefRegistry.js +4 -0
- package/dist/utils/esmEmitter.js +87 -0
- package/dist/utils/extractInlineObject.js +119 -0
- package/dist/utils/liftInlineObjects.js +476 -0
- package/dist/utils/namingService.js +58 -0
- package/dist/utils/resolveRef.js +92 -0
- package/dist/utils/schemaRepresentation.js +569 -0
- package/docs/IMPROVEMENT-PLAN.md +243 -0
- package/docs/ZOD-V4-RECURSIVE-TYPE-LIMITATIONS.md +292 -0
- package/docs/proposals/bundle-refactor.md +1 -1
- package/docs/proposals/discriminated-union-with-default.md +248 -0
- package/docs/proposals/inline-object-lifting.md +77 -0
- package/eslint.config.js +4 -2
- package/jest.config.mjs +19 -0
- package/package.json +17 -20
- package/scripts/generateWorkflowSchema.ts +0 -1
- package/dist/cjs/Types.js +0 -2
- package/dist/cjs/cli.js +0 -70
- package/dist/cjs/core/analyzeSchema.js +0 -62
- package/dist/cjs/core/emitZod.js +0 -157
- package/dist/cjs/generators/generateBundle.js +0 -510
- package/dist/cjs/index.js +0 -50
- package/dist/cjs/jsonSchemaToZod.js +0 -10
- package/dist/cjs/package.json +0 -1
- package/dist/cjs/parsers/parseAllOf.js +0 -46
- package/dist/cjs/parsers/parseAnyOf.js +0 -18
- package/dist/cjs/parsers/parseArray.js +0 -90
- package/dist/cjs/parsers/parseBoolean.js +0 -5
- package/dist/cjs/parsers/parseConst.js +0 -7
- package/dist/cjs/parsers/parseDefault.js +0 -8
- package/dist/cjs/parsers/parseEnum.js +0 -21
- package/dist/cjs/parsers/parseIfThenElse.js +0 -35
- package/dist/cjs/parsers/parseMultipleType.js +0 -10
- package/dist/cjs/parsers/parseNot.js +0 -12
- package/dist/cjs/parsers/parseNull.js +0 -5
- package/dist/cjs/parsers/parseNullable.js +0 -12
- package/dist/cjs/parsers/parseNumber.js +0 -116
- package/dist/cjs/parsers/parseObject.js +0 -318
- package/dist/cjs/parsers/parseOneOf.js +0 -53
- package/dist/cjs/parsers/parseSchema.js +0 -419
- package/dist/cjs/parsers/parseSimpleDiscriminatedOneOf.js +0 -21
- package/dist/cjs/parsers/parseString.js +0 -317
- package/dist/cjs/utils/anyOrUnknown.js +0 -14
- package/dist/cjs/utils/buildRefRegistry.js +0 -56
- package/dist/cjs/utils/cliTools.js +0 -108
- package/dist/cjs/utils/cycles.js +0 -113
- package/dist/cjs/utils/half.js +0 -7
- package/dist/cjs/utils/jsdocs.js +0 -20
- package/dist/cjs/utils/omit.js +0 -11
- package/dist/cjs/utils/resolveUri.js +0 -16
- package/dist/cjs/utils/withMessage.js +0 -21
- package/dist/cjs/zodToJsonSchema.js +0 -89
- package/dist/esm/core/emitZod.js +0 -153
- package/dist/esm/jsonSchemaToZod.js +0 -6
- package/dist/esm/package.json +0 -1
- package/dist/esm/parsers/parseAllOf.js +0 -43
- package/dist/esm/parsers/parseAnyOf.js +0 -14
- package/dist/esm/parsers/parseBoolean.js +0 -1
- package/dist/esm/parsers/parseConst.js +0 -3
- package/dist/esm/parsers/parseEnum.js +0 -17
- package/dist/esm/parsers/parseMultipleType.js +0 -6
- package/dist/esm/parsers/parseNot.js +0 -8
- package/dist/esm/parsers/parseNull.js +0 -1
- package/dist/esm/parsers/parseNullable.js +0 -8
- package/dist/esm/parsers/parseOneOf.js +0 -49
- package/dist/esm/parsers/parseSimpleDiscriminatedOneOf.js +0 -17
- package/dist/esm/utils/anyOrUnknown.js +0 -10
- package/jest.config.cjs +0 -4
- package/postcjs.cjs +0 -1
- package/postesm.cjs +0 -1
- /package/dist/{esm/Types.js → Types.js} +0 -0
- /package/dist/{esm/parsers → parsers}/parseDefault.js +0 -0
- /package/dist/{esm/utils → utils}/cliTools.js +0 -0
- /package/dist/{esm/utils → utils}/cycles.js +0 -0
- /package/dist/{esm/utils → utils}/half.js +0 -0
- /package/dist/{esm/utils → utils}/jsdocs.js +0 -0
- /package/dist/{esm/utils → utils}/omit.js +0 -0
- /package/dist/{esm/utils → utils}/resolveUri.js +0 -0
- /package/dist/{esm/utils → utils}/withMessage.js +0 -0
- /package/dist/{esm/zodToJsonSchema.js → zodToJsonSchema.js} +0 -0
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
import { parseSchema } from "./parseSchema.js";
|
|
2
|
+
import { anyOrUnknown } from "../utils/anyOrUnknown.js";
|
|
3
|
+
import { extractInlineObject } from "../utils/extractInlineObject.js";
|
|
4
|
+
import { resolveRef } from "../utils/resolveRef.js";
|
|
5
|
+
/**
|
|
6
|
+
* Check if a schema is a "required-only" validation constraint.
|
|
7
|
+
* These are schemas that only specify `required` without defining types.
|
|
8
|
+
*/
|
|
9
|
+
const isRequiredOnlySchema = (schema) => {
|
|
10
|
+
if (typeof schema !== "object" || schema === null) {
|
|
11
|
+
return false;
|
|
12
|
+
}
|
|
13
|
+
const obj = schema;
|
|
14
|
+
// Must have required array
|
|
15
|
+
if (!Array.isArray(obj.required) || obj.required.length === 0) {
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
// Must NOT have type-defining keywords
|
|
19
|
+
if (obj.type || obj.properties || obj.additionalProperties || obj.patternProperties) {
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
// Must NOT have composition keywords
|
|
23
|
+
if (obj.allOf || obj.anyOf || obj.oneOf) {
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
// Must NOT be a reference
|
|
27
|
+
if (obj.$ref || obj.$dynamicRef) {
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
return true;
|
|
31
|
+
};
|
|
32
|
+
/**
|
|
33
|
+
* Generate a superRefine expression that validates required field combinations.
|
|
34
|
+
* This handles the JSON Schema pattern where oneOf is used purely for validation.
|
|
35
|
+
*
|
|
36
|
+
* When isRefinementOnly is true, the expression is just the refinement function body
|
|
37
|
+
* that should be appended with .superRefine() directly to the parent schema.
|
|
38
|
+
*/
|
|
39
|
+
const generateRequiredFieldsRefinement = (requiredCombinations) => {
|
|
40
|
+
const conditions = requiredCombinations.map((fields) => {
|
|
41
|
+
const checks = fields.map((f) => `obj[${JSON.stringify(f)}] !== undefined`).join(" && ");
|
|
42
|
+
return `(${checks})`;
|
|
43
|
+
});
|
|
44
|
+
const message = `Must have one of the following required field combinations: ${requiredCombinations.map((r) => r.join(", ")).join(" | ")}`;
|
|
45
|
+
// The refinement function body (without the surrounding .superRefine())
|
|
46
|
+
const refinementBody = `(obj, ctx) => { if (!(${conditions.join(" || ")})) { ctx.addIssue({ code: "custom", message: ${JSON.stringify(message)} }); } }`;
|
|
47
|
+
// For standalone use, return z.any() with the refinement
|
|
48
|
+
const expression = `z.any().superRefine(${refinementBody})`;
|
|
49
|
+
return {
|
|
50
|
+
expression,
|
|
51
|
+
type: "z.ZodAny",
|
|
52
|
+
isRefinementOnly: true,
|
|
53
|
+
refinementBody,
|
|
54
|
+
};
|
|
55
|
+
};
|
|
56
|
+
/**
|
|
57
|
+
* Collects all properties from a schema, including properties defined in allOf members.
|
|
58
|
+
* Returns merged properties object and combined required array.
|
|
59
|
+
*/
|
|
60
|
+
const collectSchemaProperties = (schema, refs) => {
|
|
61
|
+
let properties = {};
|
|
62
|
+
let required = [];
|
|
63
|
+
// Collect direct properties
|
|
64
|
+
if (schema.properties) {
|
|
65
|
+
properties = { ...properties, ...schema.properties };
|
|
66
|
+
}
|
|
67
|
+
// Collect direct required
|
|
68
|
+
if (Array.isArray(schema.required)) {
|
|
69
|
+
required = [...required, ...schema.required];
|
|
70
|
+
}
|
|
71
|
+
// Collect from allOf members
|
|
72
|
+
if (Array.isArray(schema.allOf)) {
|
|
73
|
+
for (const member of schema.allOf) {
|
|
74
|
+
if (typeof member !== 'object' || member === null)
|
|
75
|
+
continue;
|
|
76
|
+
let resolvedMember = member;
|
|
77
|
+
// Resolve $ref if needed
|
|
78
|
+
if (resolvedMember.$ref || resolvedMember.$dynamicRef) {
|
|
79
|
+
const resolved = resolveRef(resolvedMember, (resolvedMember.$ref || resolvedMember.$dynamicRef), refs);
|
|
80
|
+
if (resolved && typeof resolved.schema === 'object' && resolved.schema !== null) {
|
|
81
|
+
resolvedMember = resolved.schema;
|
|
82
|
+
}
|
|
83
|
+
else {
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
// Merge properties from this allOf member
|
|
88
|
+
if (resolvedMember.properties) {
|
|
89
|
+
properties = { ...properties, ...resolvedMember.properties };
|
|
90
|
+
}
|
|
91
|
+
// Merge required from this allOf member
|
|
92
|
+
if (Array.isArray(resolvedMember.required)) {
|
|
93
|
+
required = [...required, ...resolvedMember.required];
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
// Return undefined if no properties found
|
|
98
|
+
if (Object.keys(properties).length === 0) {
|
|
99
|
+
return undefined;
|
|
100
|
+
}
|
|
101
|
+
return { properties, required: [...new Set(required)] };
|
|
102
|
+
};
|
|
103
|
+
/**
|
|
104
|
+
* Check if two sets contain the same elements.
|
|
105
|
+
*/
|
|
106
|
+
const setsEqual = (a, b) => {
|
|
107
|
+
if (a.size !== b.size)
|
|
108
|
+
return false;
|
|
109
|
+
for (const item of a) {
|
|
110
|
+
if (!b.has(item))
|
|
111
|
+
return false;
|
|
112
|
+
}
|
|
113
|
+
return true;
|
|
114
|
+
};
|
|
115
|
+
/**
|
|
116
|
+
* Extract the constant value from a property schema.
|
|
117
|
+
* Returns the string value if it's a const or single-element enum, undefined otherwise.
|
|
118
|
+
*/
|
|
119
|
+
const getConstValue = (prop) => {
|
|
120
|
+
if (prop.const !== undefined && typeof prop.const === 'string') {
|
|
121
|
+
return prop.const;
|
|
122
|
+
}
|
|
123
|
+
if (prop.enum &&
|
|
124
|
+
Array.isArray(prop.enum) &&
|
|
125
|
+
prop.enum.length === 1 &&
|
|
126
|
+
typeof prop.enum[0] === 'string') {
|
|
127
|
+
return prop.enum[0];
|
|
128
|
+
}
|
|
129
|
+
return undefined;
|
|
130
|
+
};
|
|
131
|
+
/**
|
|
132
|
+
* Extract the negated enum values from a property schema.
|
|
133
|
+
* Returns the enum values if the property has { not: { enum: [...] } }, undefined otherwise.
|
|
134
|
+
*/
|
|
135
|
+
const getNegatedEnumValues = (prop) => {
|
|
136
|
+
if (prop.not &&
|
|
137
|
+
typeof prop.not === 'object' &&
|
|
138
|
+
prop.not !== null &&
|
|
139
|
+
Array.isArray(prop.not.enum) &&
|
|
140
|
+
prop.not.enum.every((v) => typeof v === 'string')) {
|
|
141
|
+
return prop.not.enum;
|
|
142
|
+
}
|
|
143
|
+
return undefined;
|
|
144
|
+
};
|
|
145
|
+
/**
|
|
146
|
+
* Attempts to find a discriminator property common to all options.
|
|
147
|
+
* A discriminator must:
|
|
148
|
+
* 1. Be present in 'properties' of all options (resolving $refs and allOf if needed)
|
|
149
|
+
* 2. Be required in all options (checking both direct required and allOf required)
|
|
150
|
+
* 3. Have a constant string value (const or enum: [val]) in all options, OR
|
|
151
|
+
* have constant values in all but one option which has not:{enum:[those values]}
|
|
152
|
+
* 4. Have unique values across all options (for const values)
|
|
153
|
+
*/
|
|
154
|
+
const findImplicitDiscriminator = (options, refs) => {
|
|
155
|
+
if (options.length < 2)
|
|
156
|
+
return undefined;
|
|
157
|
+
// Fully resolve schemas and collect their properties (including from allOf)
|
|
158
|
+
const resolvedOptions = [];
|
|
159
|
+
for (const opt of options) {
|
|
160
|
+
if (typeof opt !== 'object' || opt === null)
|
|
161
|
+
return undefined;
|
|
162
|
+
let schemaObj = opt;
|
|
163
|
+
// Resolve ref if needed
|
|
164
|
+
if (schemaObj.$ref || schemaObj.$dynamicRef) {
|
|
165
|
+
const resolved = resolveRef(schemaObj, (schemaObj.$ref || schemaObj.$dynamicRef), refs);
|
|
166
|
+
if (resolved && typeof resolved.schema === 'object' && resolved.schema !== null) {
|
|
167
|
+
schemaObj = resolved.schema;
|
|
168
|
+
}
|
|
169
|
+
else {
|
|
170
|
+
return undefined;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
// Must be an object type
|
|
174
|
+
if (schemaObj.type !== 'object') {
|
|
175
|
+
return undefined;
|
|
176
|
+
}
|
|
177
|
+
// Collect all properties including from allOf
|
|
178
|
+
const collected = collectSchemaProperties(schemaObj, refs);
|
|
179
|
+
if (!collected) {
|
|
180
|
+
return undefined;
|
|
181
|
+
}
|
|
182
|
+
resolvedOptions.push(collected);
|
|
183
|
+
}
|
|
184
|
+
// Get all possible keys from the first option
|
|
185
|
+
const firstProps = resolvedOptions[0].properties;
|
|
186
|
+
const candidateKeys = Object.keys(firstProps);
|
|
187
|
+
for (const key of candidateKeys) {
|
|
188
|
+
const constValues = [];
|
|
189
|
+
const constValuesSet = new Set();
|
|
190
|
+
let defaultIndex;
|
|
191
|
+
let defaultEnumValues;
|
|
192
|
+
let isValidDiscriminator = true;
|
|
193
|
+
for (let i = 0; i < resolvedOptions.length; i++) {
|
|
194
|
+
const opt = resolvedOptions[i];
|
|
195
|
+
// Must be required
|
|
196
|
+
if (!opt.required.includes(key)) {
|
|
197
|
+
isValidDiscriminator = false;
|
|
198
|
+
break;
|
|
199
|
+
}
|
|
200
|
+
const propBeforeResolve = opt.properties[key];
|
|
201
|
+
if (!propBeforeResolve) {
|
|
202
|
+
isValidDiscriminator = false;
|
|
203
|
+
break;
|
|
204
|
+
}
|
|
205
|
+
// Resolve property schema ref if needed (e.g. definitions/kind -> const)
|
|
206
|
+
let prop = propBeforeResolve;
|
|
207
|
+
if (typeof prop === 'object' && prop !== null && (prop.$ref || prop.$dynamicRef)) {
|
|
208
|
+
const resolvedProp = resolveRef(prop, (prop.$ref || prop.$dynamicRef), refs);
|
|
209
|
+
if (resolvedProp) {
|
|
210
|
+
prop = resolvedProp.schema;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
if (typeof prop !== 'object' || prop === null) {
|
|
214
|
+
isValidDiscriminator = false;
|
|
215
|
+
break;
|
|
216
|
+
}
|
|
217
|
+
// Check for constant value
|
|
218
|
+
const constValue = getConstValue(prop);
|
|
219
|
+
if (constValue !== undefined) {
|
|
220
|
+
if (constValuesSet.has(constValue)) {
|
|
221
|
+
isValidDiscriminator = false; // Duplicate value found
|
|
222
|
+
break;
|
|
223
|
+
}
|
|
224
|
+
constValuesSet.add(constValue);
|
|
225
|
+
constValues.push(constValue);
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
// Check for negated enum (default case pattern)
|
|
229
|
+
const negatedEnum = getNegatedEnumValues(prop);
|
|
230
|
+
if (negatedEnum !== undefined) {
|
|
231
|
+
if (defaultIndex !== undefined) {
|
|
232
|
+
// Multiple defaults - can't optimize
|
|
233
|
+
isValidDiscriminator = false;
|
|
234
|
+
break;
|
|
235
|
+
}
|
|
236
|
+
defaultIndex = i;
|
|
237
|
+
defaultEnumValues = negatedEnum;
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
// Neither const nor not.enum - can't use discriminated union
|
|
241
|
+
isValidDiscriminator = false;
|
|
242
|
+
break;
|
|
243
|
+
}
|
|
244
|
+
if (!isValidDiscriminator) {
|
|
245
|
+
continue;
|
|
246
|
+
}
|
|
247
|
+
// Check if all options have const values (full discriminated union)
|
|
248
|
+
if (constValues.length === resolvedOptions.length) {
|
|
249
|
+
return { type: 'full', key };
|
|
250
|
+
}
|
|
251
|
+
// Check if we have a valid default case pattern
|
|
252
|
+
if (defaultIndex !== undefined &&
|
|
253
|
+
defaultEnumValues !== undefined &&
|
|
254
|
+
constValues.length === resolvedOptions.length - 1) {
|
|
255
|
+
// Verify the negated enum exactly matches the const values
|
|
256
|
+
const enumSet = new Set(defaultEnumValues);
|
|
257
|
+
if (setsEqual(constValuesSet, enumSet)) {
|
|
258
|
+
return { type: 'withDefault', key, defaultIndex, constValues };
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
return undefined;
|
|
263
|
+
};
|
|
264
|
+
export const parseOneOf = (schema, refs) => {
|
|
265
|
+
if (!schema.oneOf.length) {
|
|
266
|
+
return anyOrUnknown(refs);
|
|
267
|
+
}
|
|
268
|
+
if (schema.oneOf.length === 1) {
|
|
269
|
+
return parseSchema(schema.oneOf[0], {
|
|
270
|
+
...refs,
|
|
271
|
+
path: [...refs.path, "oneOf", 0],
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
// Check if ALL oneOf members are "required-only" schemas
|
|
275
|
+
const requiredOnlyMembers = schema.oneOf.filter(isRequiredOnlySchema);
|
|
276
|
+
if (requiredOnlyMembers.length === schema.oneOf.length) {
|
|
277
|
+
const requiredCombinations = requiredOnlyMembers.map((m) => m.required);
|
|
278
|
+
return generateRequiredFieldsRefinement(requiredCombinations);
|
|
279
|
+
}
|
|
280
|
+
// Optimize: Check for implicit discriminated union
|
|
281
|
+
const discriminator = findImplicitDiscriminator(schema.oneOf, refs);
|
|
282
|
+
if (discriminator?.type === 'full') {
|
|
283
|
+
// All options have constant discriminator values
|
|
284
|
+
const options = schema.oneOf.map((s, i) => parseSchema(s, {
|
|
285
|
+
...refs,
|
|
286
|
+
path: [...refs.path, "oneOf", i],
|
|
287
|
+
}));
|
|
288
|
+
const expressions = options.map(o => o.expression).join(", ");
|
|
289
|
+
const types = options.map(o => o.type).join(", ");
|
|
290
|
+
return {
|
|
291
|
+
expression: `z.discriminatedUnion("${discriminator.key}", [${expressions}])`,
|
|
292
|
+
// Use readonly tuple for union type annotations (required for recursive type inference)
|
|
293
|
+
type: `z.ZodDiscriminatedUnion<"${discriminator.key}", readonly [${types}]>`,
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
// Note: 'withDefault' case (discriminated union with catch-all) cannot be optimized
|
|
297
|
+
// in Zod v4 because ZodDiscriminatedUnion cannot be nested inside ZodUnion at the type level.
|
|
298
|
+
// The runtime would work, but the types wouldn't match, causing compile errors.
|
|
299
|
+
// So we fall through to the regular union handling below.
|
|
300
|
+
// Fallback: Standard z.union
|
|
301
|
+
const parsedSchemas = schema.oneOf.map((s, i) => {
|
|
302
|
+
const extracted = extractInlineObject(s, refs, [...refs.path, "oneOf", i]);
|
|
303
|
+
if (extracted) {
|
|
304
|
+
// extractInlineObject returns a refName string
|
|
305
|
+
return { expression: extracted, type: `typeof ${extracted}` };
|
|
306
|
+
}
|
|
307
|
+
let parsed = parseSchema(s, {
|
|
308
|
+
...refs,
|
|
309
|
+
path: [...refs.path, "oneOf", i],
|
|
310
|
+
});
|
|
311
|
+
// Make regular unions stricter: if it's an object, it shouldn't match emptiness.
|
|
312
|
+
// Ensure we only apply .strict() to actual z.object() calls.
|
|
313
|
+
if (typeof s === "object" &&
|
|
314
|
+
s !== null &&
|
|
315
|
+
(s.type === "object" || s.properties) &&
|
|
316
|
+
!s.$ref &&
|
|
317
|
+
parsed.expression.startsWith("z.object(") && // Critical check: Must be a Zod object
|
|
318
|
+
!parsed.expression.includes(".and(") &&
|
|
319
|
+
!parsed.expression.includes(".intersection(") &&
|
|
320
|
+
!parsed.expression.includes(".strict()") &&
|
|
321
|
+
!parsed.expression.includes(".catchall") &&
|
|
322
|
+
!parsed.expression.includes(".passthrough")) {
|
|
323
|
+
parsed = {
|
|
324
|
+
expression: parsed.expression + ".strict()",
|
|
325
|
+
type: parsed.type, // .strict() doesn't change the Zod type
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
return parsed;
|
|
329
|
+
});
|
|
330
|
+
// Build the union types for the SchemaRepresentation
|
|
331
|
+
const unionTypes = parsedSchemas.map(r => r.type).join(", ");
|
|
332
|
+
const unionExpression = `z.union([${parsedSchemas.map(r => r.expression).join(", ")}])`;
|
|
333
|
+
if (refs.strictOneOf) {
|
|
334
|
+
const schemasExpressions = parsedSchemas.map(r => r.expression).join(", ");
|
|
335
|
+
const expression = `${unionExpression}.superRefine((x, ctx) => {
|
|
336
|
+
const schemas = [${schemasExpressions}];
|
|
337
|
+
const errors = schemas.reduce<z.ZodError[]>(
|
|
338
|
+
(errors, schema) =>
|
|
339
|
+
((result) =>
|
|
340
|
+
result.error ? [...errors, result.error] : errors)(
|
|
341
|
+
schema.safeParse(x),
|
|
342
|
+
),
|
|
343
|
+
[],
|
|
344
|
+
);
|
|
345
|
+
if (schemas.length - errors.length !== 1) {
|
|
346
|
+
ctx.addIssue({
|
|
347
|
+
path: [],
|
|
348
|
+
code: "invalid_union",
|
|
349
|
+
errors: errors.map(e => e.issues),
|
|
350
|
+
message: "Invalid input: Should pass single schema",
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
})`;
|
|
354
|
+
return {
|
|
355
|
+
expression,
|
|
356
|
+
// In Zod v4, .superRefine() doesn't change the type
|
|
357
|
+
type: `z.ZodUnion<readonly [${unionTypes}]>`,
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
return {
|
|
361
|
+
expression: unionExpression,
|
|
362
|
+
// Use readonly tuple for union type annotations (required for recursive type inference)
|
|
363
|
+
type: `z.ZodUnion<readonly [${unionTypes}]>`,
|
|
364
|
+
};
|
|
365
|
+
};
|
|
@@ -17,7 +17,7 @@ import { parseSimpleDiscriminatedOneOf } from "./parseSimpleDiscriminatedOneOf.j
|
|
|
17
17
|
import { parseNullable } from "./parseNullable.js";
|
|
18
18
|
import { anyOrUnknown } from "../utils/anyOrUnknown.js";
|
|
19
19
|
import { resolveUri } from "../utils/resolveUri.js";
|
|
20
|
-
import {
|
|
20
|
+
import { resolveRef } from "../utils/resolveRef.js";
|
|
21
21
|
export const parseSchema = (schema, refs = { seen: new Map(), path: [] }, blockMeta) => {
|
|
22
22
|
// Ensure ref bookkeeping exists so $ref declarations and getter-based recursion work
|
|
23
23
|
refs.root = refs.root ?? schema;
|
|
@@ -27,8 +27,11 @@ export const parseSchema = (schema, refs = { seen: new Map(), path: [] }, blockM
|
|
|
27
27
|
refs.inProgress = refs.inProgress ?? new Set();
|
|
28
28
|
refs.refNameByPointer = refs.refNameByPointer ?? new Map();
|
|
29
29
|
refs.usedNames = refs.usedNames ?? new Set();
|
|
30
|
-
if (typeof schema !== "object")
|
|
31
|
-
return schema
|
|
30
|
+
if (typeof schema !== "object") {
|
|
31
|
+
return schema
|
|
32
|
+
? anyOrUnknown(refs)
|
|
33
|
+
: { expression: "z.never()", type: "z.ZodNever" };
|
|
34
|
+
}
|
|
32
35
|
const parentBase = refs.currentBaseUri ?? refs.rootBaseUri ?? "root:///";
|
|
33
36
|
const baseUri = typeof schema.$id === "string" ? resolveUri(parentBase, schema.$id) : parentBase;
|
|
34
37
|
const dynamicAnchors = Array.isArray(refs.dynamicAnchors) ? [...refs.dynamicAnchors] : [];
|
|
@@ -42,7 +45,8 @@ export const parseSchema = (schema, refs = { seen: new Map(), path: [] }, blockM
|
|
|
42
45
|
if (refs.parserOverride) {
|
|
43
46
|
const custom = refs.parserOverride(schema, { ...refs, currentBaseUri: baseUri, dynamicAnchors });
|
|
44
47
|
if (typeof custom === "string") {
|
|
45
|
-
|
|
48
|
+
// ParserOverride returns string for backward compatibility
|
|
49
|
+
return { expression: custom, type: "z.ZodTypeAny" };
|
|
46
50
|
}
|
|
47
51
|
}
|
|
48
52
|
let seen = refs.seen.get(schema);
|
|
@@ -91,7 +95,7 @@ const parseRef = (schema, refs) => {
|
|
|
91
95
|
const refName = getOrCreateRefName(pointerKey, path, refs);
|
|
92
96
|
if (!refs.declarations.has(refName) && !refs.inProgress.has(refName)) {
|
|
93
97
|
refs.inProgress.add(refName);
|
|
94
|
-
const
|
|
98
|
+
const result = parseSchema(target, {
|
|
95
99
|
...refs,
|
|
96
100
|
path,
|
|
97
101
|
currentBaseUri: resolved.baseUri,
|
|
@@ -99,7 +103,7 @@ const parseRef = (schema, refs) => {
|
|
|
99
103
|
root: refs.root,
|
|
100
104
|
});
|
|
101
105
|
refs.inProgress.delete(refName);
|
|
102
|
-
refs.declarations.set(refName,
|
|
106
|
+
refs.declarations.set(refName, result);
|
|
103
107
|
}
|
|
104
108
|
const current = refs.currentSchemaName;
|
|
105
109
|
if (current) {
|
|
@@ -116,23 +120,43 @@ const parseRef = (schema, refs) => {
|
|
|
116
120
|
targetComponent !== undefined &&
|
|
117
121
|
currentComponent === targetComponent &&
|
|
118
122
|
refs.cycleRefNames?.has(refName);
|
|
119
|
-
//
|
|
120
|
-
//
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
123
|
+
// Check if this is a true forward reference (target not yet declared)
|
|
124
|
+
// We only need z.lazy() for forward refs, not for back-refs to already-declared schemas
|
|
125
|
+
const isForwardRef = refs.inProgress.has(refName);
|
|
126
|
+
const refType = `typeof ${refName}`;
|
|
127
|
+
// For same-cycle refs, check if we need special handling
|
|
128
|
+
if (isSameCycle || isForwardRef) {
|
|
129
|
+
// Check context: are we inside an object property where getters work?
|
|
130
|
+
// IMPORTANT: additionalProperties becomes z.record() which does NOT support getters
|
|
131
|
+
// Only named properties (properties, patternProperties) can use getters
|
|
132
|
+
const inNamedProperty = refs.path.includes("properties") ||
|
|
133
|
+
refs.path.includes("patternProperties");
|
|
134
|
+
// additionalProperties becomes z.record() value - getters don't work there
|
|
135
|
+
// Per Zod issue #4881: z.record() with recursive values REQUIRES z.lazy()
|
|
136
|
+
const inRecordContext = refs.path.includes("additionalProperties");
|
|
137
|
+
// Self-recursion in named object properties: use direct ref (getter handles deferred eval)
|
|
138
|
+
const isSelfRecursion = refName === refs.currentSchemaName;
|
|
139
|
+
if (inNamedProperty && isSelfRecursion) {
|
|
140
|
+
return { expression: refName, type: refType };
|
|
141
|
+
}
|
|
142
|
+
// Cross-schema refs in named object properties within same cycle: use direct ref
|
|
143
|
+
// The getter in parseObject.ts will handle deferred evaluation
|
|
144
|
+
if (inNamedProperty && isSameCycle && !isForwardRef) {
|
|
145
|
+
return { expression: refName, type: refType };
|
|
146
|
+
}
|
|
147
|
+
// z.record() values with recursive refs MUST use z.lazy() (Colin confirmed in #4881)
|
|
148
|
+
// Also arrays, unions, and other non-object contexts with forward refs need z.lazy()
|
|
149
|
+
if (isForwardRef || inRecordContext) {
|
|
150
|
+
return {
|
|
151
|
+
expression: `z.lazy(() => ${refName})`,
|
|
152
|
+
type: `z.ZodLazy<${refType}>`
|
|
153
|
+
};
|
|
130
154
|
}
|
|
131
|
-
return `z.lazy(() => ${refName})`;
|
|
132
155
|
}
|
|
133
|
-
return refName;
|
|
156
|
+
return { expression: refName, type: refType };
|
|
134
157
|
};
|
|
135
158
|
const addDescribes = (schema, parsed, refs) => {
|
|
159
|
+
let { expression, type } = parsed;
|
|
136
160
|
// Use .meta() for richer metadata when withMeta is enabled
|
|
137
161
|
if (refs?.withMeta) {
|
|
138
162
|
const meta = {};
|
|
@@ -147,103 +171,13 @@ const addDescribes = (schema, parsed, refs) => {
|
|
|
147
171
|
if (schema.deprecated)
|
|
148
172
|
meta.deprecated = schema.deprecated;
|
|
149
173
|
if (Object.keys(meta).length > 0) {
|
|
150
|
-
|
|
174
|
+
expression += `.meta(${JSON.stringify(meta)})`;
|
|
151
175
|
}
|
|
152
176
|
}
|
|
153
177
|
else if (schema.description) {
|
|
154
|
-
|
|
178
|
+
expression += `.describe(${JSON.stringify(schema.description)})`;
|
|
155
179
|
}
|
|
156
|
-
return
|
|
157
|
-
};
|
|
158
|
-
const resolveRef = (schemaNode, ref, refs) => {
|
|
159
|
-
const base = refs.currentBaseUri ?? refs.rootBaseUri ?? "root:///";
|
|
160
|
-
// Handle dynamicRef lookup via dynamicAnchors stack
|
|
161
|
-
const isDynamic = typeof schemaNode.$dynamicRef === "string";
|
|
162
|
-
if (isDynamic && refs.dynamicAnchors && ref.startsWith("#")) {
|
|
163
|
-
const name = ref.slice(1);
|
|
164
|
-
for (let i = refs.dynamicAnchors.length - 1; i >= 0; i -= 1) {
|
|
165
|
-
const entry = refs.dynamicAnchors[i];
|
|
166
|
-
if (entry.name === name) {
|
|
167
|
-
const key = `${entry.uri}#${name}`;
|
|
168
|
-
const target = refs.refRegistry?.get(key);
|
|
169
|
-
if (target) {
|
|
170
|
-
return { schema: target.schema, path: target.path, baseUri: target.baseUri, pointerKey: key };
|
|
171
|
-
}
|
|
172
|
-
}
|
|
173
|
-
}
|
|
174
|
-
}
|
|
175
|
-
// Resolve URI against base
|
|
176
|
-
const resolvedUri = resolveUri(base, ref);
|
|
177
|
-
const [uriBase, fragment] = resolvedUri.split("#");
|
|
178
|
-
const key = fragment ? `${uriBase}#${fragment}` : uriBase;
|
|
179
|
-
let regEntry = refs.refRegistry?.get(key);
|
|
180
|
-
if (regEntry) {
|
|
181
|
-
return { schema: regEntry.schema, path: regEntry.path, baseUri: regEntry.baseUri, pointerKey: key };
|
|
182
|
-
}
|
|
183
|
-
// Legacy recursive ref: treat as dynamic to __recursive__
|
|
184
|
-
if (schemaNode.$recursiveRef) {
|
|
185
|
-
const recursiveKey = `${base}#__recursive__`;
|
|
186
|
-
regEntry = refs.refRegistry?.get(recursiveKey);
|
|
187
|
-
if (regEntry) {
|
|
188
|
-
return {
|
|
189
|
-
schema: regEntry.schema,
|
|
190
|
-
path: regEntry.path,
|
|
191
|
-
baseUri: regEntry.baseUri,
|
|
192
|
-
pointerKey: recursiveKey,
|
|
193
|
-
};
|
|
194
|
-
}
|
|
195
|
-
}
|
|
196
|
-
// External resolver hook
|
|
197
|
-
const extBase = uriBaseFromRef(resolvedUri);
|
|
198
|
-
if (refs.resolveExternalRef && extBase && !isLocalBase(extBase, refs.rootBaseUri ?? "")) {
|
|
199
|
-
const loaded = refs.resolveExternalRef(extBase);
|
|
200
|
-
if (loaded) {
|
|
201
|
-
// If async resolver is used synchronously here, it will be ignored; keep simple sync for now
|
|
202
|
-
const maybePromise = loaded;
|
|
203
|
-
const schema = typeof maybePromise.then === "function"
|
|
204
|
-
? undefined
|
|
205
|
-
: loaded;
|
|
206
|
-
if (schema) {
|
|
207
|
-
const { registry } = buildRefRegistry(schema, extBase);
|
|
208
|
-
registry.forEach((entry, k) => refs.refRegistry?.set(k, entry));
|
|
209
|
-
regEntry = refs.refRegistry?.get(key);
|
|
210
|
-
if (regEntry) {
|
|
211
|
-
return {
|
|
212
|
-
schema: regEntry.schema,
|
|
213
|
-
path: regEntry.path,
|
|
214
|
-
baseUri: regEntry.baseUri,
|
|
215
|
-
pointerKey: key,
|
|
216
|
-
};
|
|
217
|
-
}
|
|
218
|
-
}
|
|
219
|
-
}
|
|
220
|
-
}
|
|
221
|
-
// Backward compatibility: JSON Pointer into root
|
|
222
|
-
if (refs.root && ref.startsWith("#/")) {
|
|
223
|
-
const rawSegments = ref
|
|
224
|
-
.slice(2)
|
|
225
|
-
.split("/")
|
|
226
|
-
.filter((segment) => segment.length > 0)
|
|
227
|
-
.map(decodePointerSegment);
|
|
228
|
-
let current = refs.root;
|
|
229
|
-
for (const segment of rawSegments) {
|
|
230
|
-
if (typeof current !== "object" || current === null)
|
|
231
|
-
return undefined;
|
|
232
|
-
current = current[segment];
|
|
233
|
-
}
|
|
234
|
-
return { schema: current, path: rawSegments, baseUri: base, pointerKey: ref };
|
|
235
|
-
}
|
|
236
|
-
return undefined;
|
|
237
|
-
};
|
|
238
|
-
const decodePointerSegment = (segment) => segment.replace(/~1/g, "/").replace(/~0/g, "~");
|
|
239
|
-
const uriBaseFromRef = (resolvedUri) => {
|
|
240
|
-
const hashIdx = resolvedUri.indexOf("#");
|
|
241
|
-
return hashIdx === -1 ? resolvedUri : resolvedUri.slice(0, hashIdx);
|
|
242
|
-
};
|
|
243
|
-
const isLocalBase = (base, rootBase) => {
|
|
244
|
-
if (!rootBase)
|
|
245
|
-
return false;
|
|
246
|
-
return base === rootBase;
|
|
180
|
+
return { expression, type };
|
|
247
181
|
};
|
|
248
182
|
const getOrCreateRefName = (pointer, path, refs) => {
|
|
249
183
|
if (refs.refNameByPointer?.has(pointer)) {
|
|
@@ -297,16 +231,20 @@ const sanitizeIdentifier = (value) => {
|
|
|
297
231
|
};
|
|
298
232
|
const capitalize = (value) => value.length ? value[0].toUpperCase() + value.slice(1) : value;
|
|
299
233
|
const addDefaults = (schema, parsed) => {
|
|
234
|
+
let { expression, type } = parsed;
|
|
300
235
|
if (schema.default !== undefined) {
|
|
301
|
-
|
|
236
|
+
expression += `.default(${JSON.stringify(schema.default)})`;
|
|
237
|
+
type = `z.ZodDefault<${type}>`;
|
|
302
238
|
}
|
|
303
|
-
return
|
|
239
|
+
return { expression, type };
|
|
304
240
|
};
|
|
305
241
|
const addAnnotations = (schema, parsed) => {
|
|
242
|
+
let { expression, type } = parsed;
|
|
306
243
|
if (schema.readOnly) {
|
|
307
|
-
|
|
244
|
+
expression += ".readonly()";
|
|
245
|
+
type = `z.ZodReadonly<${type}>`;
|
|
308
246
|
}
|
|
309
|
-
return
|
|
247
|
+
return { expression, type };
|
|
310
248
|
};
|
|
311
249
|
const selectParser = (schema, refs) => {
|
|
312
250
|
if (its.a.nullable(schema)) {
|
|
@@ -334,7 +272,7 @@ const selectParser = (schema, refs) => {
|
|
|
334
272
|
return parseNot(schema, refs);
|
|
335
273
|
}
|
|
336
274
|
else if (its.an.enum(schema)) {
|
|
337
|
-
return parseEnum(schema);
|
|
275
|
+
return parseEnum(schema);
|
|
338
276
|
}
|
|
339
277
|
else if (its.a.const(schema)) {
|
|
340
278
|
return parseConst(schema);
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { parseSchema } from "./parseSchema.js";
|
|
2
|
+
import { anyOrUnknown } from "../utils/anyOrUnknown.js";
|
|
3
|
+
export const parseSimpleDiscriminatedOneOf = (schema, refs) => {
|
|
4
|
+
const discriminator = schema.discriminator.propertyName;
|
|
5
|
+
const options = schema.oneOf.map((option, i) => parseSchema(option, {
|
|
6
|
+
...refs,
|
|
7
|
+
path: [...refs.path, "oneOf", i],
|
|
8
|
+
}));
|
|
9
|
+
if (!schema.oneOf.length) {
|
|
10
|
+
return anyOrUnknown(refs);
|
|
11
|
+
}
|
|
12
|
+
if (schema.oneOf.length === 1) {
|
|
13
|
+
return parseSchema(schema.oneOf[0], {
|
|
14
|
+
...refs,
|
|
15
|
+
path: [...refs.path, "oneOf", 0],
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
const expressions = options.map(o => o.expression).join(", ");
|
|
19
|
+
const types = options.map(o => o.type).join(", ");
|
|
20
|
+
return {
|
|
21
|
+
expression: `z.discriminatedUnion("${discriminator}", [${expressions}])`,
|
|
22
|
+
type: `z.ZodDiscriminatedUnion<"${discriminator}", [${types}]>`,
|
|
23
|
+
};
|
|
24
|
+
};
|