@gabrielbryk/json-schema-to-zod 2.11.1 → 2.12.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/CHANGELOG.md +21 -0
- package/check-types.sh +1 -1
- package/dist/core/emitZod.js +17 -2
- package/dist/parsers/parseObject.js +73 -21
- package/dist/parsers/parseOneOf.js +10 -0
- package/dist/types/Types.d.ts +12 -0
- package/docs/proposals/allof-required-merging.md +53 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,26 @@
|
|
|
1
1
|
# @gabrielbryk/json-schema-to-zod
|
|
2
2
|
|
|
3
|
+
## 2.12.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- e8cbafc: Fix `unevaluatedProperties: false` with `oneOf` by avoiding strict union branches, allowing base properties through, and enforcing unknown-key rejection after composition.
|
|
8
|
+
|
|
9
|
+
## 2.12.0
|
|
10
|
+
|
|
11
|
+
### Minor Changes
|
|
12
|
+
|
|
13
|
+
- 719e761: Add `typeExports` option to export TypeScript types for all generated schemas
|
|
14
|
+
|
|
15
|
+
When `typeExports: true` is set (along with `exportRefs: true`), each generated schema will have a corresponding type export:
|
|
16
|
+
|
|
17
|
+
```typescript
|
|
18
|
+
export const MySchema = z.object({...});
|
|
19
|
+
export type MySchema = z.infer<typeof MySchema>;
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
This makes it easier to use the generated types throughout your codebase without manually creating type aliases.
|
|
23
|
+
|
|
3
24
|
## 2.11.1
|
|
4
25
|
|
|
5
26
|
### Patch Changes
|
package/check-types.sh
CHANGED
|
@@ -8,7 +8,7 @@ import { readFileSync, writeFileSync } from 'fs';
|
|
|
8
8
|
import { jsonSchemaToZod } from './src/jsonSchemaToZod.js';
|
|
9
9
|
|
|
10
10
|
const schema = yaml.load(readFileSync('test/fixtures/workflow.yaml', 'utf8'));
|
|
11
|
-
const output = jsonSchemaToZod(schema, { name: 'workflowSchema' });
|
|
11
|
+
const output = jsonSchemaToZod(schema, { name: 'workflowSchema', typeExports: true });
|
|
12
12
|
writeFileSync('.tmp-workflow-schema-output.ts', output);
|
|
13
13
|
console.log('Generated .tmp-workflow-schema-output.ts');
|
|
14
14
|
EOF
|
package/dist/core/emitZod.js
CHANGED
|
@@ -149,7 +149,7 @@ const orderDeclarations = (entries, dependencies) => {
|
|
|
149
149
|
};
|
|
150
150
|
export const emitZod = (analysis) => {
|
|
151
151
|
const { schema, options, refNameByPointer, cycleRefNames, cycleComponentByName, } = analysis;
|
|
152
|
-
const { name, type, noImport, exportRefs, withMeta, ...rest } = options;
|
|
152
|
+
const { name, type, noImport, exportRefs, typeExports, withMeta, ...rest } = options;
|
|
153
153
|
const declarations = new Map();
|
|
154
154
|
const dependencies = new Map();
|
|
155
155
|
// Fresh name registry for the emission pass.
|
|
@@ -227,6 +227,13 @@ export const emitZod = (analysis) => {
|
|
|
227
227
|
expression: `${baseName}${methodChain}`,
|
|
228
228
|
exported: exportRefs,
|
|
229
229
|
});
|
|
230
|
+
// Export type for this declaration if typeExports is enabled
|
|
231
|
+
if (typeExports && exportRefs) {
|
|
232
|
+
emitter.addTypeExport({
|
|
233
|
+
name: refName,
|
|
234
|
+
type: `z.infer<typeof ${refName}>`,
|
|
235
|
+
});
|
|
236
|
+
}
|
|
230
237
|
continue;
|
|
231
238
|
}
|
|
232
239
|
}
|
|
@@ -236,6 +243,13 @@ export const emitZod = (analysis) => {
|
|
|
236
243
|
exported: exportRefs,
|
|
237
244
|
typeAnnotation: storedType !== "z.ZodTypeAny" ? storedType : undefined,
|
|
238
245
|
});
|
|
246
|
+
// Export type for this declaration if typeExports is enabled
|
|
247
|
+
if (typeExports && exportRefs) {
|
|
248
|
+
emitter.addTypeExport({
|
|
249
|
+
name: refName,
|
|
250
|
+
type: `z.infer<typeof ${refName}>`,
|
|
251
|
+
});
|
|
252
|
+
}
|
|
239
253
|
}
|
|
240
254
|
}
|
|
241
255
|
if (name) {
|
|
@@ -252,7 +266,8 @@ export const emitZod = (analysis) => {
|
|
|
252
266
|
jsdoc: jsdocs,
|
|
253
267
|
});
|
|
254
268
|
}
|
|
255
|
-
if
|
|
269
|
+
// Export type for root schema if type option is set, or if typeExports is enabled
|
|
270
|
+
if (name && (type || typeExports)) {
|
|
256
271
|
const typeName = typeof type === "string" ? type : `${name[0].toUpperCase()}${name.substring(1)}`;
|
|
257
272
|
emitter.addTypeExport({
|
|
258
273
|
name: typeName,
|
|
@@ -6,6 +6,25 @@ import { parseIfThenElse } from "./parseIfThenElse.js";
|
|
|
6
6
|
import { addJsdocs } from "../utils/jsdocs.js";
|
|
7
7
|
import { anyOrUnknown } from "../utils/anyOrUnknown.js";
|
|
8
8
|
import { containsRecursiveRef, inferTypeFromExpression } from "../utils/schemaRepresentation.js";
|
|
9
|
+
const collectKnownPropertyKeys = (schema) => {
|
|
10
|
+
const keys = new Set();
|
|
11
|
+
const visit = (node) => {
|
|
12
|
+
if (typeof node !== "object" || node === null)
|
|
13
|
+
return;
|
|
14
|
+
const obj = node;
|
|
15
|
+
if (obj.properties && typeof obj.properties === "object") {
|
|
16
|
+
Object.keys(obj.properties).forEach((key) => keys.add(key));
|
|
17
|
+
}
|
|
18
|
+
};
|
|
19
|
+
visit(schema);
|
|
20
|
+
if (Array.isArray(schema.oneOf))
|
|
21
|
+
schema.oneOf.forEach(visit);
|
|
22
|
+
if (Array.isArray(schema.anyOf))
|
|
23
|
+
schema.anyOf.forEach(visit);
|
|
24
|
+
if (Array.isArray(schema.allOf))
|
|
25
|
+
schema.allOf.forEach(visit);
|
|
26
|
+
return Array.from(keys);
|
|
27
|
+
};
|
|
9
28
|
export function parseObject(objectSchema, refs) {
|
|
10
29
|
// Optimization: if we have composition keywords (allOf/anyOf/oneOf) but no direct properties,
|
|
11
30
|
// delegate entirely to the composition parser to avoid generating z.object({}).and(...)
|
|
@@ -230,28 +249,40 @@ export function parseObject(objectSchema, refs) {
|
|
|
230
249
|
// we should NOT default to z.record(z.string(), z.any()) because that would allow any properties.
|
|
231
250
|
// Instead, use z.object({}) and let the .and() call add properties from the composition.
|
|
232
251
|
// This is especially important when unevaluatedProperties: false is set.
|
|
252
|
+
const shouldPassthroughForUnevaluated = unevaluated === false && hasCompositionKeywords;
|
|
253
|
+
const passthroughProperties = shouldPassthroughForUnevaluated && properties && !patternProperties
|
|
254
|
+
? `${properties}.passthrough()`
|
|
255
|
+
: properties;
|
|
233
256
|
const fallback = anyOrUnknown(refs);
|
|
234
|
-
let output
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
257
|
+
let output;
|
|
258
|
+
if (properties) {
|
|
259
|
+
if (patternProperties) {
|
|
260
|
+
output = properties + patternProperties;
|
|
261
|
+
}
|
|
262
|
+
else if (additionalProperties) {
|
|
263
|
+
if (additionalProperties.expression === "z.never()") {
|
|
264
|
+
// Don't use .strict() if there are composition keywords that add properties
|
|
265
|
+
output = hasCompositionKeywords ? passthroughProperties : properties + ".strict()";
|
|
266
|
+
}
|
|
267
|
+
else {
|
|
268
|
+
output = properties + `.catchall(${additionalProperties.expression})`;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
else {
|
|
272
|
+
output = passthroughProperties;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
else if (patternProperties) {
|
|
276
|
+
output = patternProperties;
|
|
277
|
+
}
|
|
278
|
+
else if (additionalProperties) {
|
|
279
|
+
output = `z.record(z.string(), ${additionalProperties.expression})`;
|
|
280
|
+
}
|
|
281
|
+
else {
|
|
282
|
+
// If we have composition keywords, start with empty object instead of z.record()
|
|
283
|
+
// The composition will provide the actual schema via .and()
|
|
284
|
+
output = hasCompositionKeywords ? "z.object({})" : `z.record(z.string(), ${fallback.expression})`;
|
|
285
|
+
}
|
|
255
286
|
if (unevaluated === false && properties && !hasCompositionKeywords) {
|
|
256
287
|
output += ".strict()";
|
|
257
288
|
}
|
|
@@ -328,6 +359,27 @@ export function parseObject(objectSchema, refs) {
|
|
|
328
359
|
output += `.and(${conditionalResult.expression})`;
|
|
329
360
|
intersectionTypes.push(conditionalResult.type);
|
|
330
361
|
}
|
|
362
|
+
if (unevaluated === false && hasCompositionKeywords) {
|
|
363
|
+
const knownKeys = collectKnownPropertyKeys(objectSchema);
|
|
364
|
+
const patternRegexps = objectSchema.patternProperties
|
|
365
|
+
? Object.keys(objectSchema.patternProperties).map((pattern) => new RegExp(pattern))
|
|
366
|
+
: [];
|
|
367
|
+
const patternRegexpsLiteral = patternRegexps.length
|
|
368
|
+
? `[${patternRegexps.map((r) => r.toString()).join(", ")}]`
|
|
369
|
+
: "[new RegExp(\"$^\")]";
|
|
370
|
+
output += `.superRefine((value, ctx) => {
|
|
371
|
+
if (!value || typeof value !== "object") return;
|
|
372
|
+
const knownKeys = ${JSON.stringify(knownKeys)};
|
|
373
|
+
const patternRegexps = ${patternRegexpsLiteral};
|
|
374
|
+
for (const key in value) {
|
|
375
|
+
const isKnown = knownKeys.includes(key);
|
|
376
|
+
const matchesPattern = patternRegexps.length ? patternRegexps.some((r) => r.test(key)) : false;
|
|
377
|
+
if (!isKnown && !matchesPattern) {
|
|
378
|
+
ctx.addIssue({ code: "unrecognized_keys", keys: [key], path: [key], message: "Unknown property" });
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
})`;
|
|
382
|
+
}
|
|
331
383
|
// Only add required validation for missing keys when there are no composition keywords
|
|
332
384
|
// When allOf/anyOf/oneOf exist, they should define the properties and handle required validation
|
|
333
385
|
if (missingRequiredKeys.length > 0 && !hasCompositionKeywords) {
|
|
@@ -297,6 +297,14 @@ export const parseOneOf = (schema, refs) => {
|
|
|
297
297
|
// in Zod v4 because ZodDiscriminatedUnion cannot be nested inside ZodUnion at the type level.
|
|
298
298
|
// The runtime would work, but the types wouldn't match, causing compile errors.
|
|
299
299
|
// So we fall through to the regular union handling below.
|
|
300
|
+
// If the parent object has its own shape (properties/patternProperties/additionalProperties)
|
|
301
|
+
// or explicitly forbids unevaluated properties, we shouldn't make the oneOf branches strict.
|
|
302
|
+
// Otherwise, the intersection with the parent schema would reject the parent's properties
|
|
303
|
+
// before the union gets a chance to validate.
|
|
304
|
+
const parentHasDirectObjectShape = Boolean(schema.properties ||
|
|
305
|
+
schema.patternProperties ||
|
|
306
|
+
schema.additionalProperties);
|
|
307
|
+
const parentForbidsUnevaluated = schema.unevaluatedProperties === false;
|
|
300
308
|
// Fallback: Standard z.union
|
|
301
309
|
const parsedSchemas = schema.oneOf.map((s, i) => {
|
|
302
310
|
const extracted = extractInlineObject(s, refs, [...refs.path, "oneOf", i]);
|
|
@@ -317,6 +325,8 @@ export const parseOneOf = (schema, refs) => {
|
|
|
317
325
|
parsed.expression.startsWith("z.object(") && // Critical check: Must be a Zod object
|
|
318
326
|
!parsed.expression.includes(".and(") &&
|
|
319
327
|
!parsed.expression.includes(".intersection(") &&
|
|
328
|
+
!parentHasDirectObjectShape &&
|
|
329
|
+
!parentForbidsUnevaluated &&
|
|
320
330
|
!parsed.expression.includes(".strict()") &&
|
|
321
331
|
!parsed.expression.includes(".catchall") &&
|
|
322
332
|
!parsed.expression.includes(".passthrough")) {
|
package/dist/types/Types.d.ts
CHANGED
|
@@ -84,6 +84,18 @@ export type Options = {
|
|
|
84
84
|
noImport?: boolean;
|
|
85
85
|
/** Export all generated reference schemas (for $refs) when using ESM */
|
|
86
86
|
exportRefs?: boolean;
|
|
87
|
+
/**
|
|
88
|
+
* Export TypeScript types for all generated schemas using z.infer.
|
|
89
|
+
* When true, exports a type for each schema (including lifted/ref schemas when exportRefs is true).
|
|
90
|
+
* Type names match their corresponding schema const names.
|
|
91
|
+
* @example
|
|
92
|
+
* // With typeExports: true, exportRefs: true, and name: "MySchema"
|
|
93
|
+
* export const SubSchema = z.object({...});
|
|
94
|
+
* export type SubSchema = z.infer<typeof SubSchema>;
|
|
95
|
+
* export const MySchema = z.object({ sub: SubSchema });
|
|
96
|
+
* export type MySchema = z.infer<typeof MySchema>;
|
|
97
|
+
*/
|
|
98
|
+
typeExports?: boolean;
|
|
87
99
|
/**
|
|
88
100
|
* Store original JSON Schema constructs in .meta({ __jsonSchema: {...} })
|
|
89
101
|
* for features that can't be natively represented in Zod (patternProperties,
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# Proposal: Strengthen `allOf` required handling
|
|
2
|
+
|
|
3
|
+
## Problem
|
|
4
|
+
- Required keys are dropped when properties live in a different `allOf` member than the `required` array (e.g., workflow schema `call`/`with`).
|
|
5
|
+
- Spread path only looks at each member’s own `required`, ignoring parent + sibling required-only members.
|
|
6
|
+
- Intersection fallback also skips parent required enforcement because `parseObject` disables missing-key checks when composition keywords exist.
|
|
7
|
+
- `$ref` members with required lists don’t inform optionality of sibling properties.
|
|
8
|
+
- Conflicting property definitions across `allOf` members fail silently (often ending up as permissive intersections).
|
|
9
|
+
|
|
10
|
+
## Goals
|
|
11
|
+
1) Preserve required semantics across `allOf`, even when properties and `required` are split across members.
|
|
12
|
+
2) Keep spread optimization where safe; otherwise, enforce required keys in the intersection path.
|
|
13
|
+
3) Respect `unevaluatedProperties`/`additionalProperties` constraints from stricter members.
|
|
14
|
+
4) Surface conflicts clearly instead of silently widening to `z.any()`.
|
|
15
|
+
|
|
16
|
+
## Proposed changes
|
|
17
|
+
- **Normalize `allOf` members up front (done partially):**
|
|
18
|
+
- Add `type: "object"` when shape hints exist and merge parent+member required into any member that actually declares those properties (already implemented for the spread path; extend to intersection path and `$ref` resolution).
|
|
19
|
+
|
|
20
|
+
- **Intersection required enforcement:**
|
|
21
|
+
- When spread is not possible, compute a combined required set (parent + all member `required` + required-only members) and add a `superRefine` that checks presence of those keys on the final intersection result.
|
|
22
|
+
- Skip keys that none of the members define (avoid false positives).
|
|
23
|
+
|
|
24
|
+
- **Required-only + properties-only pattern:**
|
|
25
|
+
- Detect an `allOf` where one member has only `required` and another has the properties; merge those required keys into the properties member before parsing.
|
|
26
|
+
|
|
27
|
+
- **$ref-aware required merge:**
|
|
28
|
+
- When an `allOf` member is a `$ref`, resolve its schema shape/required (using existing ref resolution) and merge required keys that match properties provided by other members.
|
|
29
|
+
|
|
30
|
+
- **Policy reconciliation:**
|
|
31
|
+
- When merging shapes, intersect `unevaluatedProperties`/`additionalProperties` so a member that disallows extras keeps that restriction after spread/intersection.
|
|
32
|
+
|
|
33
|
+
- **Conflict detection:**
|
|
34
|
+
- If multiple members define the same property with incompatible primitive types (e.g., `string` vs `number`), emit a `superRefine` that fails with a clear message instead of silently widening.
|
|
35
|
+
|
|
36
|
+
## Testing
|
|
37
|
+
- Unit tests in `test/parsers/parseAllOf.test.ts` and `parseObject.test.ts` covering:
|
|
38
|
+
- properties-only + required-only split (current workflow pattern).
|
|
39
|
+
- Overlapping required across multiple members (including parent required).
|
|
40
|
+
- `$ref` member with required + sibling properties.
|
|
41
|
+
- Spread eligible vs. ineligible `allOf` and intersection fallback enforcing required.
|
|
42
|
+
- `unevaluatedProperties` interactions where one member disallows extras.
|
|
43
|
+
- Conflict detection on incompatible property types.
|
|
44
|
+
|
|
45
|
+
## Risks & mitigations
|
|
46
|
+
- **Over-enforcement**: Ensure required keys are only checked when at least one member defines the property. Filter combined required sets accordingly.
|
|
47
|
+
- **Ref resolution cost**: Cache resolved `$ref` shapes when harvesting required sets to avoid repeated work.
|
|
48
|
+
- **False conflicts**: Limit conflict detection to clear primitive mismatches; avoid flagging unions/anyOf/any as conflicts.
|
|
49
|
+
|
|
50
|
+
## Rollout
|
|
51
|
+
- Implement normalization + intersection required enforcement first, add tests for workflow fixture regression.
|
|
52
|
+
- Follow with `$ref`-aware required merge and policy reconciliation tests.
|
|
53
|
+
- Add conflict detection last (guarded behind a clear error message) to avoid unexpected breakage.
|