@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 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
@@ -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 (type && name) {
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 = properties
235
- ? patternProperties
236
- ? properties + patternProperties
237
- : additionalProperties
238
- ? additionalProperties.expression === "z.never()"
239
- // Don't use .strict() if there are composition keywords that add properties
240
- ? hasCompositionKeywords
241
- ? properties
242
- : properties + ".strict()"
243
- : properties + `.catchall(${additionalProperties.expression})`
244
- : properties
245
- : patternProperties
246
- ? patternProperties
247
- : additionalProperties
248
- ? `z.record(z.string(), ${additionalProperties.expression})`
249
- // If we have composition keywords, start with empty object instead of z.record()
250
- // The composition will provide the actual schema via .and()
251
- : hasCompositionKeywords
252
- ? "z.object({})"
253
- // No constraints = any object. Use z.record() which is cleaner than z.object({}).catchall()
254
- : `z.record(z.string(), ${fallback.expression})`;
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")) {
@@ -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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gabrielbryk/json-schema-to-zod",
3
- "version": "2.11.1",
3
+ "version": "2.12.1",
4
4
  "description": "Converts JSON schema objects or files into Zod schemas",
5
5
  "type": "module",
6
6
  "types": "./dist/types/index.d.ts",