@gabrielbryk/json-schema-to-zod 2.12.0 → 2.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/RELEASE_SETUP.md +120 -0
- package/.github/TOOLING_GUIDE.md +169 -0
- package/.github/dependabot.yml +52 -0
- package/.github/workflows/ci.yml +33 -0
- package/.github/workflows/release.yml +12 -4
- package/.github/workflows/security.yml +40 -0
- package/.husky/commit-msg +1 -0
- package/.husky/pre-commit +1 -0
- package/.lintstagedrc.json +3 -0
- package/.prettierrc +20 -0
- package/AGENTS.md +7 -0
- package/CHANGELOG.md +13 -4
- package/README.md +9 -9
- package/commitlint.config.js +24 -0
- package/createIndex.ts +4 -4
- package/dist/cli.js +3 -4
- package/dist/core/analyzeSchema.js +28 -5
- package/dist/core/emitZod.js +11 -4
- package/dist/generators/generateBundle.js +67 -92
- package/dist/parsers/parseAllOf.js +11 -12
- package/dist/parsers/parseAnyOf.js +2 -2
- package/dist/parsers/parseArray.js +38 -12
- package/dist/parsers/parseMultipleType.js +2 -2
- package/dist/parsers/parseNumber.js +44 -102
- package/dist/parsers/parseObject.js +138 -393
- package/dist/parsers/parseOneOf.js +57 -100
- package/dist/parsers/parseSchema.js +132 -55
- package/dist/parsers/parseSimpleDiscriminatedOneOf.js +2 -2
- package/dist/parsers/parseString.js +113 -253
- package/dist/types/Types.d.ts +22 -1
- package/dist/types/core/analyzeSchema.d.ts +1 -0
- package/dist/types/generators/generateBundle.d.ts +1 -1
- package/dist/utils/cliTools.js +1 -2
- package/dist/utils/esmEmitter.js +6 -2
- package/dist/utils/extractInlineObject.js +1 -3
- package/dist/utils/jsdocs.js +1 -4
- package/dist/utils/liftInlineObjects.js +76 -15
- package/dist/utils/resolveRef.js +35 -10
- package/dist/utils/schemaRepresentation.js +35 -66
- package/dist/zodToJsonSchema.js +1 -2
- package/docs/IMPROVEMENT-PLAN.md +30 -12
- package/docs/ZOD-V4-RECURSIVE-TYPE-LIMITATIONS.md +70 -25
- package/docs/proposals/allof-required-merging.md +10 -4
- package/docs/proposals/bundle-refactor.md +10 -4
- package/docs/proposals/discriminated-union-with-default.md +18 -14
- package/docs/proposals/inline-object-lifting.md +15 -5
- package/docs/proposals/ref-anchor-support.md +11 -0
- package/output.txt +67 -0
- package/package.json +18 -5
- package/scripts/generateWorkflowSchema.ts +5 -14
- package/scripts/regenerate_bundle.ts +25 -0
- package/tsc_output.txt +542 -0
- package/tsc_output_2.txt +489 -0
|
@@ -122,7 +122,12 @@ const deepTransform = (obj, ctx, forceInDefs) => {
|
|
|
122
122
|
if (clone.$defs && typeof clone.$defs === "object" && ctx.allowInDefs) {
|
|
123
123
|
const defsObj = clone.$defs;
|
|
124
124
|
for (const [key, value] of Object.entries(defsObj)) {
|
|
125
|
-
const visited = visit(value, {
|
|
125
|
+
const visited = visit(value, {
|
|
126
|
+
...ctx,
|
|
127
|
+
path: [...ctx.path, "$defs", key],
|
|
128
|
+
inDefs: true,
|
|
129
|
+
context: "root",
|
|
130
|
+
});
|
|
126
131
|
ctx.defs[key] = visited;
|
|
127
132
|
}
|
|
128
133
|
clone.$defs = ctx.defs;
|
|
@@ -131,7 +136,12 @@ const deepTransform = (obj, ctx, forceInDefs) => {
|
|
|
131
136
|
if (clone.patternProperties && typeof clone.patternProperties === "object") {
|
|
132
137
|
const newPatterns = {};
|
|
133
138
|
for (const [key, value] of Object.entries(clone.patternProperties)) {
|
|
134
|
-
newPatterns[key] = visit(value, {
|
|
139
|
+
newPatterns[key] = visit(value, {
|
|
140
|
+
...ctx,
|
|
141
|
+
path: [...ctx.path, key],
|
|
142
|
+
inDefs: nextInDefs,
|
|
143
|
+
context: "patternProperties",
|
|
144
|
+
});
|
|
135
145
|
}
|
|
136
146
|
clone.patternProperties = newPatterns;
|
|
137
147
|
}
|
|
@@ -146,7 +156,12 @@ const deepTransform = (obj, ctx, forceInDefs) => {
|
|
|
146
156
|
}
|
|
147
157
|
// items / additionalItems
|
|
148
158
|
if (clone.items) {
|
|
149
|
-
clone.items = visit(clone.items, {
|
|
159
|
+
clone.items = visit(clone.items, {
|
|
160
|
+
...ctx,
|
|
161
|
+
path: [...ctx.path, "items"],
|
|
162
|
+
inDefs: nextInDefs,
|
|
163
|
+
context: "items",
|
|
164
|
+
});
|
|
150
165
|
}
|
|
151
166
|
if (clone.additionalItems) {
|
|
152
167
|
clone.additionalItems = visit(clone.additionalItems, {
|
|
@@ -159,13 +174,30 @@ const deepTransform = (obj, ctx, forceInDefs) => {
|
|
|
159
174
|
// compositions
|
|
160
175
|
for (const keyword of ["allOf", "anyOf", "oneOf"]) {
|
|
161
176
|
if (Array.isArray(clone[keyword])) {
|
|
162
|
-
clone[keyword] = clone[keyword].map((entry, index) => visit(entry, {
|
|
177
|
+
clone[keyword] = clone[keyword].map((entry, index) => visit(entry, {
|
|
178
|
+
...ctx,
|
|
179
|
+
path: [...ctx.path, keyword, index],
|
|
180
|
+
inDefs: nextInDefs,
|
|
181
|
+
context: keyword,
|
|
182
|
+
}));
|
|
163
183
|
}
|
|
164
184
|
}
|
|
165
185
|
// conditionals
|
|
166
|
-
for (const keyword of [
|
|
186
|
+
for (const keyword of [
|
|
187
|
+
"if",
|
|
188
|
+
"then",
|
|
189
|
+
"else",
|
|
190
|
+
"not",
|
|
191
|
+
"contains",
|
|
192
|
+
"unevaluatedProperties",
|
|
193
|
+
]) {
|
|
167
194
|
if (clone[keyword]) {
|
|
168
|
-
clone[keyword] = visit(clone[keyword], {
|
|
195
|
+
clone[keyword] = visit(clone[keyword], {
|
|
196
|
+
...ctx,
|
|
197
|
+
path: [...ctx.path, keyword],
|
|
198
|
+
inDefs: nextInDefs,
|
|
199
|
+
context: keyword,
|
|
200
|
+
});
|
|
169
201
|
}
|
|
170
202
|
}
|
|
171
203
|
// dependentSchemas
|
|
@@ -184,7 +216,9 @@ const deepTransform = (obj, ctx, forceInDefs) => {
|
|
|
184
216
|
return clone;
|
|
185
217
|
};
|
|
186
218
|
const getDefs = (schema) => {
|
|
187
|
-
if (typeof schema === "object" &&
|
|
219
|
+
if (typeof schema === "object" &&
|
|
220
|
+
schema !== null &&
|
|
221
|
+
typeof schema.$defs === "object") {
|
|
188
222
|
return { ...schema.$defs };
|
|
189
223
|
}
|
|
190
224
|
return {};
|
|
@@ -192,7 +226,11 @@ const getDefs = (schema) => {
|
|
|
192
226
|
const isObjectSchema = (schema) => {
|
|
193
227
|
if (schema.type === "object")
|
|
194
228
|
return true;
|
|
195
|
-
return Boolean(schema.properties ||
|
|
229
|
+
return Boolean(schema.properties ||
|
|
230
|
+
schema.patternProperties ||
|
|
231
|
+
schema.additionalProperties ||
|
|
232
|
+
schema.required ||
|
|
233
|
+
schema.unevaluatedProperties);
|
|
196
234
|
};
|
|
197
235
|
const isMetaOnly = (schema) => {
|
|
198
236
|
const keys = Object.keys(schema);
|
|
@@ -200,7 +238,11 @@ const isMetaOnly = (schema) => {
|
|
|
200
238
|
};
|
|
201
239
|
const isRecursiveRef = (schema, ctx) => {
|
|
202
240
|
// Only guard when refs are present on the schema itself
|
|
203
|
-
const ref = typeof schema.$ref === "string"
|
|
241
|
+
const ref = typeof schema.$ref === "string"
|
|
242
|
+
? schema.$ref
|
|
243
|
+
: typeof schema.$dynamicRef === "string"
|
|
244
|
+
? schema.$dynamicRef
|
|
245
|
+
: null;
|
|
204
246
|
if (!ref)
|
|
205
247
|
return false;
|
|
206
248
|
const resolved = resolveRef(schema, ref, {
|
|
@@ -244,7 +286,9 @@ const extractCallConst = (ctx) => {
|
|
|
244
286
|
return undefined;
|
|
245
287
|
const parentPath = ctx.path.slice(0, -1);
|
|
246
288
|
const parentNode = getAtPath(ctx.rootSchema, parentPath);
|
|
247
|
-
if (parentNode &&
|
|
289
|
+
if (parentNode &&
|
|
290
|
+
typeof parentNode === "object" &&
|
|
291
|
+
parentNode.properties) {
|
|
248
292
|
const props = parentNode.properties;
|
|
249
293
|
const callProp = props["call"];
|
|
250
294
|
if (callProp && typeof callProp === "object" && callProp.const) {
|
|
@@ -301,7 +345,11 @@ const computeCyclicPaths = (schema, refRegistry, rootBaseUri) => {
|
|
|
301
345
|
nodes.add(pathStr);
|
|
302
346
|
nodes.add(ownerStr);
|
|
303
347
|
const nextBase = typeof obj.$id === "string" ? resolveUri(baseUri, obj.$id) : baseUri;
|
|
304
|
-
const ref = typeof obj.$ref === "string"
|
|
348
|
+
const ref = typeof obj.$ref === "string"
|
|
349
|
+
? obj.$ref
|
|
350
|
+
: typeof obj.$dynamicRef === "string"
|
|
351
|
+
? obj.$dynamicRef
|
|
352
|
+
: obj.$recursiveRef;
|
|
305
353
|
if (typeof ref === "string") {
|
|
306
354
|
const resolved = resolveRef(obj, ref, {
|
|
307
355
|
path,
|
|
@@ -432,9 +480,18 @@ const subtreeHasCycle = (node, ctx, pathPrefix) => {
|
|
|
432
480
|
// When a schema is reached through these keywords and participates in a cycle,
|
|
433
481
|
// it will generate z.lazy() which causes type annotation issues when lifted
|
|
434
482
|
const structuralKeywords = new Set([
|
|
435
|
-
"additionalProperties",
|
|
436
|
-
"
|
|
437
|
-
"
|
|
483
|
+
"additionalProperties",
|
|
484
|
+
"items",
|
|
485
|
+
"additionalItems",
|
|
486
|
+
"contains",
|
|
487
|
+
"unevaluatedProperties",
|
|
488
|
+
"not",
|
|
489
|
+
"if",
|
|
490
|
+
"then",
|
|
491
|
+
"else",
|
|
492
|
+
"allOf",
|
|
493
|
+
"anyOf",
|
|
494
|
+
"oneOf",
|
|
438
495
|
]);
|
|
439
496
|
const rootPathStr = normalizePath(pathPrefix);
|
|
440
497
|
const walk = (value, path, parentContext) => {
|
|
@@ -443,7 +500,11 @@ const subtreeHasCycle = (node, ctx, pathPrefix) => {
|
|
|
443
500
|
const obj = value;
|
|
444
501
|
// Check if this node has a $ref that points back to the root or an ancestor
|
|
445
502
|
// This would create self-referential recursion which blocks lifting
|
|
446
|
-
const ref = typeof obj.$ref === "string"
|
|
503
|
+
const ref = typeof obj.$ref === "string"
|
|
504
|
+
? obj.$ref
|
|
505
|
+
: typeof obj.$dynamicRef === "string"
|
|
506
|
+
? obj.$dynamicRef
|
|
507
|
+
: null;
|
|
447
508
|
if (ref) {
|
|
448
509
|
// Resolve the ref to see if it points to root or an ancestor
|
|
449
510
|
if (ref.startsWith("#/")) {
|
package/dist/utils/resolveRef.js
CHANGED
|
@@ -22,7 +22,12 @@ export const resolveRef = (schemaNode, ref, refs) => {
|
|
|
22
22
|
const key = `${entry.uri}#${name}`;
|
|
23
23
|
const target = refs.refRegistry?.get(key);
|
|
24
24
|
if (target) {
|
|
25
|
-
return {
|
|
25
|
+
return {
|
|
26
|
+
schema: target.schema,
|
|
27
|
+
path: target.path,
|
|
28
|
+
baseUri: target.baseUri,
|
|
29
|
+
pointerKey: key,
|
|
30
|
+
};
|
|
26
31
|
}
|
|
27
32
|
}
|
|
28
33
|
}
|
|
@@ -33,7 +38,12 @@ export const resolveRef = (schemaNode, ref, refs) => {
|
|
|
33
38
|
const key = fragment ? `${uriBase}#${fragment}` : uriBase;
|
|
34
39
|
let regEntry = refs.refRegistry?.get(key);
|
|
35
40
|
if (regEntry) {
|
|
36
|
-
return {
|
|
41
|
+
return {
|
|
42
|
+
schema: regEntry.schema,
|
|
43
|
+
path: regEntry.path,
|
|
44
|
+
baseUri: regEntry.baseUri,
|
|
45
|
+
pointerKey: key,
|
|
46
|
+
};
|
|
37
47
|
}
|
|
38
48
|
// Legacy recursive ref: treat as dynamic to __recursive__
|
|
39
49
|
if (schemaNode.$recursiveRef) {
|
|
@@ -51,13 +61,12 @@ export const resolveRef = (schemaNode, ref, refs) => {
|
|
|
51
61
|
// External resolver hook
|
|
52
62
|
const extBase = uriBaseFromRef(resolvedUri);
|
|
53
63
|
if (refs.resolveExternalRef && extBase && !isLocalBase(extBase, refs.rootBaseUri ?? "")) {
|
|
64
|
+
// console.log("ATTEMPTING EXTERNAL RESOLUTION FOR", ref, "AT BASE", extBase);
|
|
54
65
|
const loaded = refs.resolveExternalRef(extBase);
|
|
55
66
|
if (loaded) {
|
|
56
67
|
// If async resolver is used synchronously here, it will be ignored; keep simple sync for now
|
|
57
68
|
const maybePromise = loaded;
|
|
58
|
-
const schema = typeof maybePromise.then === "function"
|
|
59
|
-
? undefined
|
|
60
|
-
: loaded;
|
|
69
|
+
const schema = typeof maybePromise.then === "function" ? undefined : loaded;
|
|
61
70
|
if (schema) {
|
|
62
71
|
const { registry } = buildRefRegistry(schema, extBase);
|
|
63
72
|
registry.forEach((entry, k) => refs.refRegistry?.set(k, entry));
|
|
@@ -80,13 +89,29 @@ export const resolveRef = (schemaNode, ref, refs) => {
|
|
|
80
89
|
.split("/")
|
|
81
90
|
.filter((segment) => segment.length > 0)
|
|
82
91
|
.map(decodePointerSegment);
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
if (typeof current !== "object" || current === null)
|
|
92
|
+
const tryResolve = (rootNode) => {
|
|
93
|
+
if (!rootNode)
|
|
86
94
|
return undefined;
|
|
87
|
-
current =
|
|
95
|
+
let current = rootNode;
|
|
96
|
+
for (const segment of rawSegments) {
|
|
97
|
+
const record = current;
|
|
98
|
+
if (!record || typeof record !== "object") {
|
|
99
|
+
return undefined;
|
|
100
|
+
}
|
|
101
|
+
current = record[segment];
|
|
102
|
+
if (current === undefined)
|
|
103
|
+
return undefined;
|
|
104
|
+
}
|
|
105
|
+
return current;
|
|
106
|
+
};
|
|
107
|
+
let resolved = tryResolve(refs.root);
|
|
108
|
+
if (resolved === undefined && refs.documentRoot && refs.documentRoot !== refs.root) {
|
|
109
|
+
resolved = tryResolve(refs.documentRoot);
|
|
110
|
+
}
|
|
111
|
+
if (resolved !== undefined) {
|
|
112
|
+
return { schema: resolved, path: rawSegments, baseUri: base, pointerKey: ref };
|
|
88
113
|
}
|
|
89
|
-
return
|
|
114
|
+
return undefined;
|
|
90
115
|
}
|
|
91
116
|
return undefined;
|
|
92
117
|
};
|
|
@@ -182,7 +182,7 @@ export const zodObject = (shape) => {
|
|
|
182
182
|
export const zodStrictObject = (shape) => {
|
|
183
183
|
const base = zodObject(shape);
|
|
184
184
|
return {
|
|
185
|
-
expression:
|
|
185
|
+
expression: base.expression.replace(/^z\.object\(/, "z.strictObject("),
|
|
186
186
|
type: base.type, // strict() doesn't change the type signature
|
|
187
187
|
};
|
|
188
188
|
};
|
|
@@ -245,31 +245,31 @@ export const fromExpression = (expression) => ({
|
|
|
245
245
|
* This is used for backward compatibility during migration.
|
|
246
246
|
*/
|
|
247
247
|
export const inferTypeFromExpression = (expr) => {
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
if (methods.includes(".optional()")) {
|
|
248
|
+
const applyOptionality = (type, methods) => {
|
|
249
|
+
if (methods.includes(".exactOptional()")) {
|
|
250
|
+
type = `z.ZodExactOptional<${type}>`;
|
|
251
|
+
}
|
|
252
|
+
else if (methods.includes(".optional()")) {
|
|
254
253
|
type = `z.ZodOptional<${type}>`;
|
|
255
254
|
}
|
|
256
255
|
if (methods.includes(".nullable()")) {
|
|
257
256
|
type = `z.ZodNullable<${type}>`;
|
|
258
257
|
}
|
|
259
258
|
return type;
|
|
259
|
+
};
|
|
260
|
+
// Handle z.lazy with explicit type (possibly with method chains like .optional())
|
|
261
|
+
const lazyTypedMatch = expr.match(/^z\.lazy<([^>]+)>\(\s*\(\)\s*=>\s*([A-Za-z0-9_.$]+)\s*\)(\.[a-z]+\(\))*$/);
|
|
262
|
+
if (lazyTypedMatch) {
|
|
263
|
+
let type = `z.ZodLazy<${lazyTypedMatch[1]}>`;
|
|
264
|
+
const methods = lazyTypedMatch[3] || "";
|
|
265
|
+
return applyOptionality(type, methods);
|
|
260
266
|
}
|
|
261
267
|
// Handle z.lazy without explicit type (possibly with method chains like .optional())
|
|
262
268
|
const lazyMatch = expr.match(/^z\.lazy\(\s*\(\)\s*=>\s*([A-Za-z0-9_.$]+)\s*\)(\.[a-z]+\(\))*$/);
|
|
263
269
|
if (lazyMatch) {
|
|
264
270
|
let type = `z.ZodLazy<typeof ${lazyMatch[1]}>`;
|
|
265
271
|
const methods = lazyMatch[2] || "";
|
|
266
|
-
|
|
267
|
-
type = `z.ZodOptional<${type}>`;
|
|
268
|
-
}
|
|
269
|
-
if (methods.includes(".nullable()")) {
|
|
270
|
-
type = `z.ZodNullable<${type}>`;
|
|
271
|
-
}
|
|
272
|
-
return type;
|
|
272
|
+
return applyOptionality(type, methods);
|
|
273
273
|
}
|
|
274
274
|
// Handle .and() method chains - this creates an intersection type
|
|
275
275
|
// Need to find the .and( that's not inside nested parentheses
|
|
@@ -286,13 +286,7 @@ export const inferTypeFromExpression = (expr) => {
|
|
|
286
286
|
const andType = inferTypeFromExpression(andArg);
|
|
287
287
|
let type = `z.ZodIntersection<${baseType}, ${andType}>`;
|
|
288
288
|
// Handle trailing methods
|
|
289
|
-
|
|
290
|
-
type = `z.ZodOptional<${type}>`;
|
|
291
|
-
}
|
|
292
|
-
if (remainder.includes(".nullable()")) {
|
|
293
|
-
type = `z.ZodNullable<${type}>`;
|
|
294
|
-
}
|
|
295
|
-
return type;
|
|
289
|
+
return applyOptionality(type, remainder);
|
|
296
290
|
}
|
|
297
291
|
}
|
|
298
292
|
// Handle z.intersection(X, Y)
|
|
@@ -312,26 +306,19 @@ export const inferTypeFromExpression = (expr) => {
|
|
|
312
306
|
}
|
|
313
307
|
}
|
|
314
308
|
}
|
|
315
|
-
// Handle z.object({...})
|
|
316
|
-
|
|
309
|
+
// Handle z.object({...})/z.strictObject({...})/z.looseObject({...})
|
|
310
|
+
const objectPrefixes = ["z.object(", "z.strictObject(", "z.looseObject("];
|
|
311
|
+
const objectPrefix = objectPrefixes.find((prefix) => expr.startsWith(prefix));
|
|
312
|
+
if (objectPrefix) {
|
|
317
313
|
// Find the end of z.object({...})
|
|
318
|
-
const argsStart =
|
|
314
|
+
const argsStart = objectPrefix.length; // length of prefix
|
|
319
315
|
const argsEnd = findMatchingParen(expr, argsStart - 1);
|
|
320
316
|
if (argsEnd !== -1) {
|
|
321
317
|
const remainder = expr.substring(argsEnd + 1);
|
|
322
318
|
// Base type for any z.object
|
|
323
319
|
let type = "z.ZodObject<Record<string, z.ZodTypeAny>>";
|
|
324
320
|
// Handle method chains after z.object({...})
|
|
325
|
-
|
|
326
|
-
// .strict() doesn't change the type
|
|
327
|
-
}
|
|
328
|
-
if (remainder.includes(".optional()")) {
|
|
329
|
-
type = `z.ZodOptional<${type}>`;
|
|
330
|
-
}
|
|
331
|
-
if (remainder.includes(".nullable()")) {
|
|
332
|
-
type = `z.ZodNullable<${type}>`;
|
|
333
|
-
}
|
|
334
|
-
return type;
|
|
321
|
+
return applyOptionality(type, remainder);
|
|
335
322
|
}
|
|
336
323
|
}
|
|
337
324
|
// Handle z.record(K, V)
|
|
@@ -377,13 +364,7 @@ export const inferTypeFromExpression = (expr) => {
|
|
|
377
364
|
const baseName = refMatch[1];
|
|
378
365
|
const methods = refMatch[2] || "";
|
|
379
366
|
let type = `typeof ${baseName}`;
|
|
380
|
-
|
|
381
|
-
type = `z.ZodOptional<${type}>`;
|
|
382
|
-
}
|
|
383
|
-
if (methods.includes(".nullable()")) {
|
|
384
|
-
type = `z.ZodNullable<${type}>`;
|
|
385
|
-
}
|
|
386
|
-
return type;
|
|
367
|
+
return applyOptionality(type, methods);
|
|
387
368
|
}
|
|
388
369
|
// Handle z.array(X)
|
|
389
370
|
const arrayMatch = expr.match(/^z\.array\((.+)\)(\.[a-z]+\(\))*$/);
|
|
@@ -391,13 +372,7 @@ export const inferTypeFromExpression = (expr) => {
|
|
|
391
372
|
const innerType = inferTypeFromExpression(arrayMatch[1]);
|
|
392
373
|
let type = `z.ZodArray<${innerType}>`;
|
|
393
374
|
const methods = arrayMatch[2] || "";
|
|
394
|
-
|
|
395
|
-
type = `z.ZodOptional<${type}>`;
|
|
396
|
-
}
|
|
397
|
-
if (methods.includes(".nullable()")) {
|
|
398
|
-
type = `z.ZodNullable<${type}>`;
|
|
399
|
-
}
|
|
400
|
-
return type;
|
|
375
|
+
return applyOptionality(type, methods);
|
|
401
376
|
}
|
|
402
377
|
// Handle z.nullable(X)
|
|
403
378
|
const nullableMatch = expr.match(/^z\.nullable\((.+)\)$/);
|
|
@@ -413,16 +388,10 @@ export const inferTypeFromExpression = (expr) => {
|
|
|
413
388
|
if (bracketEnd !== -1) {
|
|
414
389
|
const arrayContent = expr.substring(bracketStart + 1, bracketEnd); // inside the []
|
|
415
390
|
const memberTypes = parseTopLevelArrayElements(arrayContent);
|
|
416
|
-
const types = memberTypes.map(m => inferTypeFromExpression(m.trim()));
|
|
391
|
+
const types = memberTypes.map((m) => inferTypeFromExpression(m.trim()));
|
|
417
392
|
let baseType = `z.ZodUnion<readonly [${types.join(", ")}]>`;
|
|
418
393
|
const remainder = expr.substring(bracketEnd + 2); // skip ] and )
|
|
419
|
-
|
|
420
|
-
baseType = `z.ZodOptional<${baseType}>`;
|
|
421
|
-
}
|
|
422
|
-
if (remainder.includes(".nullable()")) {
|
|
423
|
-
baseType = `z.ZodNullable<${baseType}>`;
|
|
424
|
-
}
|
|
425
|
-
return baseType;
|
|
394
|
+
return applyOptionality(baseType, remainder);
|
|
426
395
|
}
|
|
427
396
|
}
|
|
428
397
|
// Handle z.discriminatedUnion(...) - Zod v4 uses readonly arrays
|
|
@@ -445,10 +414,10 @@ export const inferTypeFromExpression = (expr) => {
|
|
|
445
414
|
const findTopLevelMethod = (expr, method) => {
|
|
446
415
|
let depth = 0;
|
|
447
416
|
for (let i = 0; i < expr.length - method.length; i++) {
|
|
448
|
-
if (expr[i] ===
|
|
417
|
+
if (expr[i] === "(" || expr[i] === "[" || expr[i] === "{") {
|
|
449
418
|
depth++;
|
|
450
419
|
}
|
|
451
|
-
else if (expr[i] ===
|
|
420
|
+
else if (expr[i] === ")" || expr[i] === "]" || expr[i] === "}") {
|
|
452
421
|
depth--;
|
|
453
422
|
}
|
|
454
423
|
else if (depth === 0 && expr.substring(i, i + method.length) === method) {
|
|
@@ -463,10 +432,10 @@ const findTopLevelMethod = (expr, method) => {
|
|
|
463
432
|
const findMatchingParen = (expr, openIndex) => {
|
|
464
433
|
let depth = 0;
|
|
465
434
|
for (let i = openIndex; i < expr.length; i++) {
|
|
466
|
-
if (expr[i] ===
|
|
435
|
+
if (expr[i] === "(" || expr[i] === "[" || expr[i] === "{") {
|
|
467
436
|
depth++;
|
|
468
437
|
}
|
|
469
|
-
else if (expr[i] ===
|
|
438
|
+
else if (expr[i] === ")" || expr[i] === "]" || expr[i] === "}") {
|
|
470
439
|
depth--;
|
|
471
440
|
if (depth === 0) {
|
|
472
441
|
return i;
|
|
@@ -481,13 +450,13 @@ const findMatchingParen = (expr, openIndex) => {
|
|
|
481
450
|
const findTopLevelComma = (expr) => {
|
|
482
451
|
let depth = 0;
|
|
483
452
|
for (let i = 0; i < expr.length; i++) {
|
|
484
|
-
if (expr[i] ===
|
|
453
|
+
if (expr[i] === "(" || expr[i] === "[" || expr[i] === "{") {
|
|
485
454
|
depth++;
|
|
486
455
|
}
|
|
487
|
-
else if (expr[i] ===
|
|
456
|
+
else if (expr[i] === ")" || expr[i] === "]" || expr[i] === "}") {
|
|
488
457
|
depth--;
|
|
489
458
|
}
|
|
490
|
-
else if (depth === 0 && expr[i] ===
|
|
459
|
+
else if (depth === 0 && expr[i] === ",") {
|
|
491
460
|
return i;
|
|
492
461
|
}
|
|
493
462
|
}
|
|
@@ -502,15 +471,15 @@ const parseTopLevelArrayElements = (content) => {
|
|
|
502
471
|
let current = "";
|
|
503
472
|
for (let i = 0; i < content.length; i++) {
|
|
504
473
|
const char = content[i];
|
|
505
|
-
if (char ===
|
|
474
|
+
if (char === "(" || char === "[" || char === "{") {
|
|
506
475
|
depth++;
|
|
507
476
|
current += char;
|
|
508
477
|
}
|
|
509
|
-
else if (char ===
|
|
478
|
+
else if (char === ")" || char === "]" || char === "}") {
|
|
510
479
|
depth--;
|
|
511
480
|
current += char;
|
|
512
481
|
}
|
|
513
|
-
else if (char ===
|
|
482
|
+
else if (char === "," && depth === 0) {
|
|
514
483
|
if (current.trim()) {
|
|
515
484
|
elements.push(current.trim());
|
|
516
485
|
}
|
package/dist/zodToJsonSchema.js
CHANGED
|
@@ -39,8 +39,7 @@ export function reconstructJsonSchema(schema) {
|
|
|
39
39
|
secondElement.__jsonSchema.conditional) {
|
|
40
40
|
// Extract the main schema and conditional
|
|
41
41
|
const mainSchema = reconstructJsonSchema(result.allOf[0]);
|
|
42
|
-
const conditionalMeta = secondElement.__jsonSchema
|
|
43
|
-
.conditional;
|
|
42
|
+
const conditionalMeta = secondElement.__jsonSchema.conditional;
|
|
44
43
|
// Merge: main schema + if/then/else at top level
|
|
45
44
|
const merged = {
|
|
46
45
|
...mainSchema,
|
package/docs/IMPROVEMENT-PLAN.md
CHANGED
|
@@ -9,17 +9,20 @@ Based on analysis of our generated output and Zod v4 limitations research.
|
|
|
9
9
|
### Problem 1: `z.record()` with recursive values lacks `z.lazy()`
|
|
10
10
|
|
|
11
11
|
**Current output:**
|
|
12
|
+
|
|
12
13
|
```typescript
|
|
13
14
|
export const TaskList: z.ZodArray<z.ZodRecord<typeof z, typeof Task>> =
|
|
14
15
|
z.array(z.record(z.string(), Task).meta({...}))
|
|
15
16
|
```
|
|
16
17
|
|
|
17
18
|
**Issues:**
|
|
19
|
+
|
|
18
20
|
1. `Task` referenced directly in `z.record()` - Colin confirmed in #4881 this REQUIRES `z.lazy()`
|
|
19
21
|
2. Type annotation is completely wrong - `typeof z` as key type is nonsense
|
|
20
22
|
3. This will cause runtime TDZ errors if Task isn't declared yet
|
|
21
23
|
|
|
22
24
|
**Should be:**
|
|
25
|
+
|
|
23
26
|
```typescript
|
|
24
27
|
export const TaskList = z.array(
|
|
25
28
|
z.record(z.string(), z.lazy(() => Task)).meta({...})
|
|
@@ -31,16 +34,19 @@ export const TaskList = z.array(
|
|
|
31
34
|
### Problem 2: Union type annotations use `z.ZodTypeAny`
|
|
32
35
|
|
|
33
36
|
**Current output:**
|
|
37
|
+
|
|
34
38
|
```typescript
|
|
35
39
|
export const CallTask: z.ZodUnion<readonly z.ZodTypeAny[]> = z.union([...])
|
|
36
40
|
```
|
|
37
41
|
|
|
38
42
|
**Issues:**
|
|
43
|
+
|
|
39
44
|
1. `z.ZodTypeAny[]` defeats the entire purpose of type safety
|
|
40
45
|
2. Loses all type information about what's actually in the union
|
|
41
46
|
|
|
42
47
|
**Should be:**
|
|
43
48
|
Either remove the type annotation entirely:
|
|
49
|
+
|
|
44
50
|
```typescript
|
|
45
51
|
export const CallTask = z.union([...])
|
|
46
52
|
```
|
|
@@ -52,6 +58,7 @@ Or if we must have one (for circular reference reasons), at least don't use `Zod
|
|
|
52
58
|
### Problem 3: Object type annotations use `Record<string, z.ZodTypeAny>`
|
|
53
59
|
|
|
54
60
|
**Current output:**
|
|
61
|
+
|
|
55
62
|
```typescript
|
|
56
63
|
export const DoTask: z.ZodIntersection<
|
|
57
64
|
z.ZodObject<Record<string, z.ZodTypeAny>>,
|
|
@@ -60,11 +67,13 @@ export const DoTask: z.ZodIntersection<
|
|
|
60
67
|
```
|
|
61
68
|
|
|
62
69
|
**Issues:**
|
|
70
|
+
|
|
63
71
|
1. `Record<string, z.ZodTypeAny>` loses all property type information
|
|
64
72
|
2. The intersection type is overly complex and still loses info
|
|
65
73
|
|
|
66
74
|
**Should be:**
|
|
67
75
|
Remove the type annotation and let TypeScript infer:
|
|
76
|
+
|
|
68
77
|
```typescript
|
|
69
78
|
export const DoTask = z.object({}).and(z.intersection(TaskBase, z.object({...})))
|
|
70
79
|
```
|
|
@@ -74,6 +83,7 @@ export const DoTask = z.object({}).and(z.intersection(TaskBase, z.object({...}))
|
|
|
74
83
|
### Problem 4: Getters ARE being used correctly ✅
|
|
75
84
|
|
|
76
85
|
**Current output (GOOD):**
|
|
86
|
+
|
|
77
87
|
```typescript
|
|
78
88
|
get "do"(): z.ZodOptional<typeof TaskList>{ return TaskList.optional() }
|
|
79
89
|
```
|
|
@@ -85,6 +95,7 @@ This follows the Zod v4 pattern correctly! The getter with explicit return type
|
|
|
85
95
|
### Problem 5: Empty object base with `.and()` is wasteful
|
|
86
96
|
|
|
87
97
|
**Current output:**
|
|
98
|
+
|
|
88
99
|
```typescript
|
|
89
100
|
z.object({}).and(z.intersection(TaskBase, z.object({...})))
|
|
90
101
|
```
|
|
@@ -93,6 +104,7 @@ z.object({}).and(z.intersection(TaskBase, z.object({...})))
|
|
|
93
104
|
Starting with `z.object({})` then using `.and()` is unnecessary when there are no direct properties.
|
|
94
105
|
|
|
95
106
|
**Should be:**
|
|
107
|
+
|
|
96
108
|
```typescript
|
|
97
109
|
z.intersection(TaskBase, z.object({...}))
|
|
98
110
|
// OR
|
|
@@ -111,9 +123,8 @@ When reference is inside a `z.record()` context AND the target is recursive, use
|
|
|
111
123
|
|
|
112
124
|
```typescript
|
|
113
125
|
// Check if we're inside a record value context
|
|
114
|
-
const inRecordValue = refs.path.some(
|
|
115
|
-
p === "additionalProperties" ||
|
|
116
|
-
(refs.path[i-1] === "record" && p === "1") // second arg to z.record
|
|
126
|
+
const inRecordValue = refs.path.some(
|
|
127
|
+
(p, i) => p === "additionalProperties" || (refs.path[i - 1] === "record" && p === "1") // second arg to z.record
|
|
117
128
|
);
|
|
118
129
|
|
|
119
130
|
if (inRecordValue && (isSameCycle || isForwardRef)) {
|
|
@@ -128,6 +139,7 @@ if (inRecordValue && (isSameCycle || isForwardRef)) {
|
|
|
128
139
|
**File:** `src/core/emitZod.ts`
|
|
129
140
|
|
|
130
141
|
Current logic adds type annotations when there's a cycle/lazy/getter. Change to:
|
|
142
|
+
|
|
131
143
|
1. Only add type annotation if we can infer a GOOD type
|
|
132
144
|
2. Never use `z.ZodTypeAny` - either infer correctly or don't annotate
|
|
133
145
|
|
|
@@ -135,9 +147,11 @@ Current logic adds type annotations when there's a cycle/lazy/getter. Change to:
|
|
|
135
147
|
if (isCycle || hasLazy || hasGetter) {
|
|
136
148
|
const inferredType = inferTypeFromExpression(value);
|
|
137
149
|
// Skip annotation if it's useless or wrong
|
|
138
|
-
if (
|
|
139
|
-
|
|
140
|
-
|
|
150
|
+
if (
|
|
151
|
+
inferredType !== "z.ZodTypeAny" &&
|
|
152
|
+
!inferredType.includes("Record<string, z.ZodTypeAny>") &&
|
|
153
|
+
!inferredType.includes("typeof z,")
|
|
154
|
+
) {
|
|
141
155
|
return `${shouldExport ? "export " : ""}const ${refName}: ${inferredType} = ${value}`;
|
|
142
156
|
}
|
|
143
157
|
}
|
|
@@ -196,6 +210,7 @@ When there are no direct properties but there IS composition (allOf), don't crea
|
|
|
196
210
|
```
|
|
197
211
|
|
|
198
212
|
Then when building output with `.and()`:
|
|
213
|
+
|
|
199
214
|
```typescript
|
|
200
215
|
if (output === null && its.an.allOf(objectSchema)) {
|
|
201
216
|
// No base object, just use the composition directly
|
|
@@ -209,18 +224,19 @@ if (output === null && its.an.allOf(objectSchema)) {
|
|
|
209
224
|
|
|
210
225
|
## Summary of Changes
|
|
211
226
|
|
|
212
|
-
| File
|
|
213
|
-
|
|
214
|
-
| `parseSchema.ts`
|
|
215
|
-
| `emitZod.ts`
|
|
216
|
-
| `schemaRepresentation.ts` | Fix `z.record()` type inference
|
|
217
|
-
| `parseObject.ts`
|
|
227
|
+
| File | Change | Priority |
|
|
228
|
+
| ------------------------- | ------------------------------------------- | -------- |
|
|
229
|
+
| `parseSchema.ts` | Add `z.lazy()` for refs inside `z.record()` | HIGH |
|
|
230
|
+
| `emitZod.ts` | Don't add `z.ZodTypeAny` annotations | HIGH |
|
|
231
|
+
| `schemaRepresentation.ts` | Fix `z.record()` type inference | HIGH |
|
|
232
|
+
| `parseObject.ts` | Remove unnecessary `z.object({}).and()` | MEDIUM |
|
|
218
233
|
|
|
219
234
|
---
|
|
220
235
|
|
|
221
236
|
## Expected Outcome
|
|
222
237
|
|
|
223
238
|
**Before:**
|
|
239
|
+
|
|
224
240
|
```typescript
|
|
225
241
|
export const TaskList: z.ZodArray<z.ZodRecord<typeof z, typeof Task>> =
|
|
226
242
|
z.array(z.record(z.string(), Task).meta({...}))
|
|
@@ -229,6 +245,7 @@ export const CallTask: z.ZodUnion<readonly z.ZodTypeAny[]> = z.union([...])
|
|
|
229
245
|
```
|
|
230
246
|
|
|
231
247
|
**After:**
|
|
248
|
+
|
|
232
249
|
```typescript
|
|
233
250
|
export const TaskList = z.array(
|
|
234
251
|
z.record(z.string(), z.lazy(() => Task)).meta({...})
|
|
@@ -238,6 +255,7 @@ export const CallTask = z.union([...]) // Let TS infer
|
|
|
238
255
|
```
|
|
239
256
|
|
|
240
257
|
This aligns with:
|
|
258
|
+
|
|
241
259
|
1. Colin's guidance that `z.record()` REQUIRES `z.lazy()` for recursive values
|
|
242
260
|
2. Best practice of not using `z.ZodTypeAny` which defeats type safety
|
|
243
261
|
3. Zod v4's getter pattern for recursive object properties (which we already do)
|