@gabrielbryk/json-schema-to-zod 2.12.0 → 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,11 @@
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
+
3
9
  ## 2.12.0
4
10
 
5
11
  ### Minor Changes
@@ -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")) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gabrielbryk/json-schema-to-zod",
3
- "version": "2.12.0",
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",