zod 4.2.0-canary.20251213T203150 → 4.2.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.
Files changed (50) hide show
  1. package/package.json +1 -1
  2. package/src/v4/classic/external.ts +2 -1
  3. package/src/v4/classic/from-json-schema.ts +527 -0
  4. package/src/v4/classic/schemas.ts +43 -0
  5. package/src/v4/classic/tests/from-json-schema.test.ts +537 -0
  6. package/src/v4/classic/tests/record.test.ts +37 -0
  7. package/src/v4/classic/tests/union.test.ts +38 -0
  8. package/src/v4/core/api.ts +15 -0
  9. package/src/v4/core/errors.ts +12 -1
  10. package/src/v4/core/json-schema-processors.ts +5 -5
  11. package/src/v4/core/schemas.ts +99 -10
  12. package/src/v4/core/versions.ts +2 -2
  13. package/src/v4/mini/external.ts +1 -1
  14. package/src/v4/mini/schemas.ts +39 -0
  15. package/v4/classic/external.cjs +5 -2
  16. package/v4/classic/external.d.cts +3 -1
  17. package/v4/classic/external.d.ts +3 -1
  18. package/v4/classic/external.js +3 -1
  19. package/v4/classic/from-json-schema.cjs +503 -0
  20. package/v4/classic/from-json-schema.d.cts +10 -0
  21. package/v4/classic/from-json-schema.d.ts +10 -0
  22. package/v4/classic/from-json-schema.js +477 -0
  23. package/v4/classic/schemas.cjs +30 -2
  24. package/v4/classic/schemas.d.cts +10 -0
  25. package/v4/classic/schemas.d.ts +10 -0
  26. package/v4/classic/schemas.js +26 -0
  27. package/v4/core/api.cjs +9 -0
  28. package/v4/core/api.d.cts +2 -0
  29. package/v4/core/api.d.ts +2 -0
  30. package/v4/core/api.js +8 -0
  31. package/v4/core/errors.d.cts +10 -1
  32. package/v4/core/errors.d.ts +10 -1
  33. package/v4/core/json-schema-processors.cjs +5 -5
  34. package/v4/core/json-schema-processors.js +5 -5
  35. package/v4/core/schemas.cjs +76 -11
  36. package/v4/core/schemas.d.cts +9 -0
  37. package/v4/core/schemas.d.ts +9 -0
  38. package/v4/core/schemas.js +74 -9
  39. package/v4/core/versions.cjs +2 -2
  40. package/v4/core/versions.d.cts +1 -1
  41. package/v4/core/versions.d.ts +1 -1
  42. package/v4/core/versions.js +2 -2
  43. package/v4/mini/external.cjs +3 -2
  44. package/v4/mini/external.d.cts +2 -1
  45. package/v4/mini/external.d.ts +2 -1
  46. package/v4/mini/external.js +2 -1
  47. package/v4/mini/schemas.cjs +28 -2
  48. package/v4/mini/schemas.d.cts +8 -0
  49. package/v4/mini/schemas.d.ts +8 -0
  50. package/v4/mini/schemas.js +24 -0
@@ -0,0 +1,537 @@
1
+ import { expect, test } from "vitest";
2
+ import { fromJSONSchema } from "../from-json-schema.js";
3
+
4
+ test("basic string schema", () => {
5
+ const schema = fromJSONSchema({ type: "string" });
6
+ expect(schema.parse("hello")).toBe("hello");
7
+ expect(() => schema.parse(123)).toThrow();
8
+ });
9
+
10
+ test("string with constraints", () => {
11
+ const schema = fromJSONSchema({
12
+ type: "string",
13
+ minLength: 3,
14
+ maxLength: 10,
15
+ pattern: "^[a-z]+$",
16
+ });
17
+ expect(schema.parse("hello")).toBe("hello");
18
+ expect(schema.parse("helloworld")).toBe("helloworld"); // exactly 10 chars - valid
19
+ expect(() => schema.parse("hi")).toThrow(); // too short
20
+ expect(() => schema.parse("helloworld1")).toThrow(); // too long (11 chars)
21
+ expect(() => schema.parse("Hello")).toThrow(); // pattern mismatch
22
+ });
23
+
24
+ test("pattern is not implicitly anchored", () => {
25
+ // JSON Schema patterns match anywhere in the string, not just the full string
26
+ const schema = fromJSONSchema({
27
+ type: "string",
28
+ pattern: "foo",
29
+ });
30
+ expect(schema.parse("foo")).toBe("foo");
31
+ expect(schema.parse("foobar")).toBe("foobar"); // matches at start
32
+ expect(schema.parse("barfoo")).toBe("barfoo"); // matches at end
33
+ expect(schema.parse("barfoobar")).toBe("barfoobar"); // matches in middle
34
+ expect(() => schema.parse("bar")).toThrow(); // no match
35
+ });
36
+
37
+ test("number schema", () => {
38
+ const schema = fromJSONSchema({ type: "number" });
39
+ expect(schema.parse(42)).toBe(42);
40
+ expect(() => schema.parse("42")).toThrow();
41
+ });
42
+
43
+ test("number with constraints", () => {
44
+ const schema = fromJSONSchema({
45
+ type: "number",
46
+ minimum: 0,
47
+ maximum: 100,
48
+ multipleOf: 5,
49
+ });
50
+ expect(schema.parse(50)).toBe(50);
51
+ expect(() => schema.parse(-1)).toThrow();
52
+ expect(() => schema.parse(101)).toThrow();
53
+ expect(() => schema.parse(47)).toThrow(); // not multiple of 5
54
+ });
55
+
56
+ test("integer schema", () => {
57
+ const schema = fromJSONSchema({ type: "integer" });
58
+ expect(schema.parse(42)).toBe(42);
59
+ expect(() => schema.parse(42.5)).toThrow();
60
+ });
61
+
62
+ test("boolean schema", () => {
63
+ const schema = fromJSONSchema({ type: "boolean" });
64
+ expect(schema.parse(true)).toBe(true);
65
+ expect(schema.parse(false)).toBe(false);
66
+ expect(() => schema.parse("true")).toThrow();
67
+ });
68
+
69
+ test("null schema", () => {
70
+ const schema = fromJSONSchema({ type: "null" });
71
+ expect(schema.parse(null)).toBe(null);
72
+ expect(() => schema.parse(undefined)).toThrow();
73
+ });
74
+
75
+ test("object schema", () => {
76
+ const schema = fromJSONSchema({
77
+ type: "object",
78
+ properties: {
79
+ name: { type: "string" },
80
+ age: { type: "number" },
81
+ },
82
+ required: ["name"],
83
+ });
84
+ expect(schema.parse({ name: "John", age: 30 })).toEqual({ name: "John", age: 30 });
85
+ expect(schema.parse({ name: "John" })).toEqual({ name: "John" });
86
+ expect(() => schema.parse({ age: 30 })).toThrow(); // missing required
87
+ });
88
+
89
+ test("object with additionalProperties false", () => {
90
+ const schema = fromJSONSchema({
91
+ type: "object",
92
+ properties: {
93
+ name: { type: "string" },
94
+ },
95
+ additionalProperties: false,
96
+ });
97
+ expect(schema.parse({ name: "John" })).toEqual({ name: "John" });
98
+ expect(() => schema.parse({ name: "John", extra: "field" })).toThrow();
99
+ });
100
+
101
+ test("array schema", () => {
102
+ const schema = fromJSONSchema({
103
+ type: "array",
104
+ items: { type: "string" },
105
+ });
106
+ expect(schema.parse(["a", "b", "c"])).toEqual(["a", "b", "c"]);
107
+ expect(() => schema.parse([1, 2, 3])).toThrow();
108
+ });
109
+
110
+ test("array with constraints", () => {
111
+ const schema = fromJSONSchema({
112
+ type: "array",
113
+ items: { type: "number" },
114
+ minItems: 2,
115
+ maxItems: 4,
116
+ });
117
+ expect(schema.parse([1, 2])).toEqual([1, 2]);
118
+ expect(schema.parse([1, 2, 3, 4])).toEqual([1, 2, 3, 4]);
119
+ expect(() => schema.parse([1])).toThrow();
120
+ expect(() => schema.parse([1, 2, 3, 4, 5])).toThrow();
121
+ });
122
+
123
+ test("tuple with prefixItems (draft-2020-12)", () => {
124
+ const schema = fromJSONSchema({
125
+ $schema: "https://json-schema.org/draft/2020-12/schema",
126
+ type: "array",
127
+ prefixItems: [{ type: "string" }, { type: "number" }],
128
+ });
129
+ expect(schema.parse(["hello", 42])).toEqual(["hello", 42]);
130
+ expect(() => schema.parse(["hello"])).toThrow();
131
+ expect(() => schema.parse(["hello", "world"])).toThrow();
132
+ });
133
+
134
+ test("tuple with items array (draft-7)", () => {
135
+ const schema = fromJSONSchema({
136
+ $schema: "http://json-schema.org/draft-07/schema#",
137
+ type: "array",
138
+ items: [{ type: "string" }, { type: "number" }],
139
+ additionalItems: false,
140
+ });
141
+ expect(schema.parse(["hello", 42])).toEqual(["hello", 42]);
142
+ expect(() => schema.parse(["hello", 42, "extra"])).toThrow();
143
+ });
144
+
145
+ test("enum schema", () => {
146
+ const schema = fromJSONSchema({
147
+ enum: ["red", "green", "blue"],
148
+ });
149
+ expect(schema.parse("red")).toBe("red");
150
+ expect(() => schema.parse("yellow")).toThrow();
151
+ });
152
+
153
+ test("const schema", () => {
154
+ const schema = fromJSONSchema({
155
+ const: "hello",
156
+ });
157
+ expect(schema.parse("hello")).toBe("hello");
158
+ expect(() => schema.parse("world")).toThrow();
159
+ });
160
+
161
+ test("anyOf schema", () => {
162
+ const schema = fromJSONSchema({
163
+ anyOf: [{ type: "string" }, { type: "number" }],
164
+ });
165
+ expect(schema.parse("hello")).toBe("hello");
166
+ expect(schema.parse(42)).toBe(42);
167
+ expect(() => schema.parse(true)).toThrow();
168
+ });
169
+
170
+ test("allOf schema", () => {
171
+ const schema = fromJSONSchema({
172
+ allOf: [
173
+ { type: "object", properties: { name: { type: "string" } }, required: ["name"] },
174
+ { type: "object", properties: { age: { type: "number" } }, required: ["age"] },
175
+ ],
176
+ });
177
+ const result = schema.parse({ name: "John", age: 30 }) as { name: string; age: number };
178
+ expect(result.name).toBe("John");
179
+ expect(result.age).toBe(30);
180
+ });
181
+
182
+ test("allOf with empty array", () => {
183
+ // Empty allOf without explicit type returns any
184
+ const schema1 = fromJSONSchema({
185
+ allOf: [],
186
+ });
187
+ expect(schema1.parse("hello")).toBe("hello");
188
+ expect(schema1.parse(123)).toBe(123);
189
+ expect(schema1.parse({})).toEqual({});
190
+
191
+ // Empty allOf with explicit type returns base schema
192
+ const schema2 = fromJSONSchema({
193
+ type: "string",
194
+ allOf: [],
195
+ });
196
+ expect(schema2.parse("hello")).toBe("hello");
197
+ expect(() => schema2.parse(123)).toThrow();
198
+ });
199
+
200
+ test("oneOf schema (exclusive union)", () => {
201
+ const schema = fromJSONSchema({
202
+ oneOf: [{ type: "string" }, { type: "number" }],
203
+ });
204
+ expect(schema.parse("hello")).toBe("hello");
205
+ expect(schema.parse(42)).toBe(42);
206
+ expect(() => schema.parse(true)).toThrow();
207
+ });
208
+
209
+ test("type with anyOf creates intersection", () => {
210
+ // type: string AND (type:string,minLength:5 OR type:string,pattern:^a)
211
+ const schema = fromJSONSchema({
212
+ type: "string",
213
+ anyOf: [
214
+ { type: "string", minLength: 5 },
215
+ { type: "string", pattern: "^a" },
216
+ ],
217
+ });
218
+ // Should pass: string AND (minLength:5 OR pattern:^a) - matches minLength
219
+ expect(schema.parse("hello")).toBe("hello");
220
+ // Should pass: string AND (minLength:5 OR pattern:^a) - matches pattern
221
+ expect(schema.parse("abc")).toBe("abc");
222
+ // Should fail: string but neither minLength nor pattern match
223
+ expect(() => schema.parse("hi")).toThrow();
224
+ // Should fail: not a string
225
+ expect(() => schema.parse(123)).toThrow();
226
+ });
227
+
228
+ test("type with oneOf creates intersection", () => {
229
+ // type: string AND (exactly one of: type:string,minLength:5 OR type:string,pattern:^a)
230
+ const schema = fromJSONSchema({
231
+ type: "string",
232
+ oneOf: [
233
+ { type: "string", minLength: 5 },
234
+ { type: "string", pattern: "^a" },
235
+ ],
236
+ });
237
+ // Should pass: string AND minLength:5 (exactly one match - "hello" length 5 >= 5, doesn't start with 'a')
238
+ expect(schema.parse("hello")).toBe("hello");
239
+ // Should pass: string AND pattern:^a (exactly one match - "abc" starts with 'a', length 3 < 5)
240
+ expect(schema.parse("abc")).toBe("abc");
241
+ // Should fail: string but neither match
242
+ expect(() => schema.parse("hi")).toThrow();
243
+ // Should fail: not a string
244
+ expect(() => schema.parse(123)).toThrow();
245
+ // Should fail: matches both (length >= 5 AND starts with 'a') - exclusive union fails
246
+ expect(() => schema.parse("apple")).toThrow();
247
+ });
248
+
249
+ test("unevaluatedItems throws error", () => {
250
+ expect(() => {
251
+ fromJSONSchema({
252
+ type: "array",
253
+ unevaluatedItems: false,
254
+ });
255
+ }).toThrow("unevaluatedItems is not supported");
256
+ });
257
+
258
+ test("unevaluatedProperties throws error", () => {
259
+ expect(() => {
260
+ fromJSONSchema({
261
+ type: "object",
262
+ unevaluatedProperties: false,
263
+ });
264
+ }).toThrow("unevaluatedProperties is not supported");
265
+ });
266
+
267
+ test("if/then/else throws error", () => {
268
+ expect(() => {
269
+ fromJSONSchema({
270
+ if: { type: "string" },
271
+ then: { type: "number" },
272
+ });
273
+ }).toThrow("Conditional schemas");
274
+ });
275
+
276
+ test("external $ref throws error", () => {
277
+ expect(() => {
278
+ fromJSONSchema({
279
+ $ref: "https://example.com/schema#/definitions/User",
280
+ });
281
+ }).toThrow("External $ref is not supported");
282
+ });
283
+
284
+ test("local $ref resolution", () => {
285
+ const schema = fromJSONSchema({
286
+ $defs: {
287
+ User: {
288
+ type: "object",
289
+ properties: {
290
+ name: { type: "string" },
291
+ },
292
+ required: ["name"],
293
+ },
294
+ },
295
+ $ref: "#/$defs/User",
296
+ });
297
+ expect(schema.parse({ name: "John" })).toEqual({ name: "John" });
298
+ expect(() => schema.parse({})).toThrow();
299
+ });
300
+
301
+ test("circular $ref with lazy", () => {
302
+ const schema = fromJSONSchema({
303
+ $defs: {
304
+ Node: {
305
+ type: "object",
306
+ properties: {
307
+ value: { type: "string" },
308
+ children: {
309
+ type: "array",
310
+ items: { $ref: "#/$defs/Node" },
311
+ },
312
+ },
313
+ },
314
+ },
315
+ $ref: "#/$defs/Node",
316
+ });
317
+ type Node = { value: string; children: Node[] };
318
+ const result = schema.parse({
319
+ value: "root",
320
+ children: [{ value: "child", children: [] }],
321
+ }) as Node;
322
+ expect(result.value).toBe("root");
323
+ expect(result.children[0]?.value).toBe("child");
324
+ });
325
+
326
+ test("patternProperties", () => {
327
+ const schema = fromJSONSchema({
328
+ type: "object",
329
+ patternProperties: {
330
+ "^S_": { type: "string" },
331
+ },
332
+ });
333
+ const result = schema.parse({ S_name: "John", S_age: "30" }) as Record<string, string>;
334
+ expect(result.S_name).toBe("John");
335
+ expect(result.S_age).toBe("30");
336
+ });
337
+
338
+ test("patternProperties with regular properties", () => {
339
+ // Note: When patternProperties is combined with properties, the intersection
340
+ // validates all keys against the pattern. This test uses a pattern that
341
+ // matches the regular property name as well.
342
+ const schema = fromJSONSchema({
343
+ type: "object",
344
+ properties: {
345
+ S_name: { type: "string" },
346
+ },
347
+ patternProperties: {
348
+ "^S_": { type: "string" },
349
+ },
350
+ required: ["S_name"],
351
+ });
352
+ const result = schema.parse({ S_name: "John", S_extra: "value" }) as Record<string, string>;
353
+ expect(result.S_name).toBe("John");
354
+ expect(result.S_extra).toBe("value");
355
+ });
356
+
357
+ test("multiple patternProperties", () => {
358
+ const schema = fromJSONSchema({
359
+ type: "object",
360
+ patternProperties: {
361
+ "^S_": { type: "string" },
362
+ "^N_": { type: "number" },
363
+ },
364
+ });
365
+ const result = schema.parse({ S_name: "John", N_count: 123 }) as Record<string, string | number>;
366
+ expect(result.S_name).toBe("John");
367
+ expect(result.N_count).toBe(123);
368
+ // Keys not matching any pattern should pass through
369
+ const result2 = schema.parse({ S_name: "John", N_count: 123, other: "value" }) as Record<string, string | number>;
370
+ expect(result2.other).toBe("value");
371
+ });
372
+
373
+ test("multiple overlapping patternProperties", () => {
374
+ // If a key matches multiple patterns, value must satisfy all schemas
375
+ const schema = fromJSONSchema({
376
+ type: "object",
377
+ patternProperties: {
378
+ "^S_": { type: "string" },
379
+ "^S_N": { type: "string", minLength: 3 },
380
+ },
381
+ });
382
+ // S_name matches ^S_ but not ^S_N
383
+ expect(schema.parse({ S_name: "John" })).toEqual({ S_name: "John" });
384
+ // S_N matches both patterns - must satisfy both (string with minLength 3)
385
+ expect(schema.parse({ S_N: "abc" })).toEqual({ S_N: "abc" });
386
+ expect(() => schema.parse({ S_N: "ab" })).toThrow(); // too short for ^S_N pattern
387
+ });
388
+
389
+ test("default value", () => {
390
+ const schema = fromJSONSchema({
391
+ type: "string",
392
+ default: "hello",
393
+ });
394
+ // Default is applied during parsing if value is missing/undefined
395
+ // This depends on Zod's default behavior
396
+ expect(schema.parse("world")).toBe("world");
397
+ });
398
+
399
+ test("description metadata", () => {
400
+ const schema = fromJSONSchema({
401
+ type: "string",
402
+ description: "A string value",
403
+ });
404
+ expect(schema.parse("hello")).toBe("hello");
405
+ });
406
+
407
+ test("version detection - draft-2020-12", () => {
408
+ const schema = fromJSONSchema({
409
+ $schema: "https://json-schema.org/draft/2020-12/schema",
410
+ type: "array",
411
+ prefixItems: [{ type: "string" }],
412
+ });
413
+ expect(schema.parse(["hello"])).toEqual(["hello"]);
414
+ });
415
+
416
+ test("version detection - draft-7", () => {
417
+ const schema = fromJSONSchema({
418
+ $schema: "http://json-schema.org/draft-07/schema#",
419
+ type: "array",
420
+ items: [{ type: "string" }],
421
+ });
422
+ expect(schema.parse(["hello"])).toEqual(["hello"]);
423
+ });
424
+
425
+ test("version detection - draft-4", () => {
426
+ const schema = fromJSONSchema({
427
+ $schema: "http://json-schema.org/draft-04/schema#",
428
+ type: "array",
429
+ items: [{ type: "string" }],
430
+ });
431
+ expect(schema.parse(["hello"])).toEqual(["hello"]);
432
+ });
433
+
434
+ test("default version (draft-2020-12)", () => {
435
+ const schema = fromJSONSchema({
436
+ type: "array",
437
+ prefixItems: [{ type: "string" }],
438
+ });
439
+ expect(schema.parse(["hello"])).toEqual(["hello"]);
440
+ });
441
+
442
+ test("string format - email", () => {
443
+ const schema = fromJSONSchema({
444
+ type: "string",
445
+ format: "email",
446
+ });
447
+ expect(schema.parse("test@example.com")).toBe("test@example.com");
448
+ });
449
+
450
+ test("string format - uuid", () => {
451
+ const schema = fromJSONSchema({
452
+ type: "string",
453
+ format: "uuid",
454
+ });
455
+ const uuid = "550e8400-e29b-41d4-a716-446655440000";
456
+ expect(schema.parse(uuid)).toBe(uuid);
457
+ });
458
+
459
+ test("exclusiveMinimum and exclusiveMaximum", () => {
460
+ const schema = fromJSONSchema({
461
+ type: "number",
462
+ exclusiveMinimum: 0,
463
+ exclusiveMaximum: 100,
464
+ });
465
+ expect(schema.parse(50)).toBe(50);
466
+ expect(() => schema.parse(0)).toThrow();
467
+ expect(() => schema.parse(100)).toThrow();
468
+ });
469
+
470
+ test("boolean schema (true/false)", () => {
471
+ const trueSchema = fromJSONSchema(true);
472
+ expect(trueSchema.parse("anything")).toBe("anything");
473
+
474
+ const falseSchema = fromJSONSchema(false);
475
+ expect(() => falseSchema.parse("anything")).toThrow();
476
+ });
477
+
478
+ test("empty object schema", () => {
479
+ const schema = fromJSONSchema({
480
+ type: "object",
481
+ });
482
+ expect(schema.parse({})).toEqual({});
483
+ expect(schema.parse({ extra: "field" })).toEqual({ extra: "field" });
484
+ });
485
+
486
+ test("array without items", () => {
487
+ const schema = fromJSONSchema({
488
+ type: "array",
489
+ });
490
+ expect(schema.parse([1, "string", true])).toEqual([1, "string", true]);
491
+ });
492
+
493
+ test("mixed enum types", () => {
494
+ const schema = fromJSONSchema({
495
+ enum: ["string", 42, true, null],
496
+ });
497
+ expect(schema.parse("string")).toBe("string");
498
+ expect(schema.parse(42)).toBe(42);
499
+ expect(schema.parse(true)).toBe(true);
500
+ expect(schema.parse(null)).toBe(null);
501
+ });
502
+
503
+ test("nullable in OpenAPI 3.0", () => {
504
+ // General nullable case (not just enum: [null])
505
+ const stringSchema = fromJSONSchema(
506
+ {
507
+ type: "string",
508
+ nullable: true,
509
+ },
510
+ { defaultTarget: "openapi-3.0" }
511
+ );
512
+ expect(stringSchema.parse("hello")).toBe("hello");
513
+ expect(stringSchema.parse(null)).toBe(null);
514
+ expect(() => stringSchema.parse(123)).toThrow();
515
+
516
+ const numberSchema = fromJSONSchema(
517
+ {
518
+ type: "number",
519
+ nullable: true,
520
+ },
521
+ { defaultTarget: "openapi-3.0" }
522
+ );
523
+ expect(numberSchema.parse(42)).toBe(42);
524
+ expect(numberSchema.parse(null)).toBe(null);
525
+ expect(() => numberSchema.parse("string")).toThrow();
526
+
527
+ const objectSchema = fromJSONSchema(
528
+ {
529
+ type: "object",
530
+ properties: { name: { type: "string" } },
531
+ nullable: true,
532
+ },
533
+ { defaultTarget: "openapi-3.0" }
534
+ );
535
+ expect(objectSchema.parse({ name: "John" })).toEqual({ name: "John" });
536
+ expect(objectSchema.parse(null)).toBe(null);
537
+ });
@@ -486,3 +486,40 @@ test("partialRecord with z.literal([key, ...])", () => {
486
486
  }
487
487
  `);
488
488
  });
489
+
490
+ test("looseRecord passes through non-matching keys", () => {
491
+ const schema = z.looseRecord(z.string().regex(/^S_/), z.string());
492
+
493
+ // Keys matching pattern are validated
494
+ expect(schema.parse({ S_name: "John" })).toEqual({ S_name: "John" });
495
+ expect(() => schema.parse({ S_name: 123 })).toThrow(); // wrong value type
496
+
497
+ // Keys not matching pattern pass through unchanged
498
+ expect(schema.parse({ S_name: "John", other: "value" })).toEqual({ S_name: "John", other: "value" });
499
+ expect(schema.parse({ S_name: "John", count: 123 })).toEqual({ S_name: "John", count: 123 });
500
+ expect(schema.parse({ other: "value" })).toEqual({ other: "value" });
501
+ });
502
+
503
+ test("intersection of loose records", () => {
504
+ const schema = z.intersection(
505
+ z.object({ name: z.string() }).passthrough(),
506
+ z.intersection(
507
+ z.looseRecord(z.string().regex(/^S_/), z.string()),
508
+ z.looseRecord(z.string().regex(/^N_/), z.number())
509
+ )
510
+ );
511
+
512
+ // Each pattern validates its matching keys
513
+ const result = schema.parse({ name: "John", S_foo: "bar", N_count: 123 });
514
+ expect(result.name).toBe("John");
515
+ expect(result.S_foo).toBe("bar");
516
+ expect(result.N_count).toBe(123);
517
+
518
+ // Keys not matching any pattern pass through
519
+ const result2 = schema.parse({ name: "John", S_foo: "bar", N_count: 123, other: "value" });
520
+ expect(result2.other).toBe("value");
521
+
522
+ // Validation errors still occur for matching keys
523
+ expect(() => schema.parse({ name: "John", S_foo: 123 })).toThrow(); // S_foo should be string
524
+ expect(() => schema.parse({ name: "John", N_count: "abc" })).toThrow(); // N_count should be number
525
+ });
@@ -179,3 +179,41 @@ test("surface continuable errors only if they exist", () => {
179
179
  }
180
180
  `);
181
181
  });
182
+
183
+ // z.xor() tests
184
+ test("z.xor() - exactly one match succeeds", () => {
185
+ const schema = z.xor([z.string(), z.number()]);
186
+ expect(schema.parse("hello")).toBe("hello");
187
+ expect(schema.parse(42)).toBe(42);
188
+ });
189
+
190
+ test("z.xor() - zero matches fails", () => {
191
+ const schema = z.xor([z.string(), z.number()]);
192
+ const result = schema.safeParse(true);
193
+ expect(result.success).toBe(false);
194
+ });
195
+
196
+ test("z.xor() - multiple matches fails", () => {
197
+ const schema = z.xor([z.string(), z.any()]);
198
+ const result = schema.safeParse("hello");
199
+ expect(result.success).toBe(false);
200
+ if (!result.success) {
201
+ expect(result.error.issues[0].code).toBe("invalid_union");
202
+ expect((result.error.issues[0] as any).inclusive).toBe(false);
203
+ }
204
+ });
205
+
206
+ test("z.xor() with custom error message", () => {
207
+ const schema = z.xor([z.string(), z.number()], "Expected exactly one of string or number");
208
+ const result = schema.safeParse(true);
209
+ expect(result.success).toBe(false);
210
+ if (!result.success) {
211
+ expect(result.error.issues[0].message).toBe("Expected exactly one of string or number");
212
+ }
213
+ });
214
+
215
+ test("z.xor() type inference", () => {
216
+ const schema = z.xor([z.string(), z.number(), z.boolean()]);
217
+ type Result = z.infer<typeof schema>;
218
+ expectTypeOf<Result>().toEqualTypeOf<string | number | boolean>();
219
+ });
@@ -1093,6 +1093,21 @@ export function _union<const T extends readonly schemas.$ZodObject[]>(
1093
1093
  }) as any;
1094
1094
  }
1095
1095
 
1096
+ // ZodXor
1097
+ export type $ZodXorParams = TypeParams<schemas.$ZodXor, "options">;
1098
+ export function _xor<const T extends readonly schemas.$ZodObject[]>(
1099
+ Class: util.SchemaClass<schemas.$ZodXor>,
1100
+ options: T,
1101
+ params?: string | $ZodXorParams
1102
+ ): schemas.$ZodXor<T> {
1103
+ return new Class({
1104
+ type: "union",
1105
+ options,
1106
+ inclusive: false,
1107
+ ...util.normalizeParams(params),
1108
+ }) as any;
1109
+ }
1110
+
1096
1111
  // ZodDiscriminatedUnion
1097
1112
  export interface $ZodTypeDiscriminableInternals extends schemas.$ZodTypeInternals {
1098
1113
  propValues: util.PropValues;
@@ -62,13 +62,24 @@ export interface $ZodIssueUnrecognizedKeys extends $ZodIssueBase {
62
62
  readonly input?: Record<string, unknown>;
63
63
  }
64
64
 
65
- export interface $ZodIssueInvalidUnion extends $ZodIssueBase {
65
+ interface $ZodIssueInvalidUnionNoMatch extends $ZodIssueBase {
66
66
  readonly code: "invalid_union";
67
67
  readonly errors: $ZodIssue[][];
68
68
  readonly input?: unknown;
69
69
  readonly discriminator?: string | undefined;
70
+ readonly inclusive?: true;
70
71
  }
71
72
 
73
+ interface $ZodIssueInvalidUnionMultipleMatch extends $ZodIssueBase {
74
+ readonly code: "invalid_union";
75
+ readonly errors: [];
76
+ readonly input?: unknown;
77
+ readonly discriminator?: string | undefined;
78
+ readonly inclusive: false;
79
+ }
80
+
81
+ export type $ZodIssueInvalidUnion = $ZodIssueInvalidUnionNoMatch | $ZodIssueInvalidUnionMultipleMatch;
82
+
72
83
  export interface $ZodIssueInvalidKey<Input = unknown> extends $ZodIssueBase {
73
84
  readonly code: "invalid_key";
74
85
  readonly origin: "map" | "record";
@@ -333,16 +333,16 @@ export const objectProcessor: Processor<schemas.$ZodObject> = (schema, ctx, _jso
333
333
 
334
334
  export const unionProcessor: Processor<schemas.$ZodUnion> = (schema, ctx, json, params) => {
335
335
  const def = schema._zod.def as schemas.$ZodUnionDef;
336
- // Discriminated unions use oneOf (exactly one match) instead of anyOf (one or more matches)
337
- // because the discriminator field ensures mutual exclusivity between options in JSON Schema
338
- const isDiscriminated = (def as any).discriminator !== undefined;
336
+ // Exclusive unions (inclusive === false) use oneOf (exactly one match) instead of anyOf (one or more matches)
337
+ // This includes both z.xor() and discriminated unions
338
+ const isExclusive = def.inclusive === false;
339
339
  const options = def.options.map((x, i) =>
340
340
  process(x, ctx as any, {
341
341
  ...params,
342
- path: [...params.path, isDiscriminated ? "oneOf" : "anyOf", i],
342
+ path: [...params.path, isExclusive ? "oneOf" : "anyOf", i],
343
343
  })
344
344
  );
345
- if (isDiscriminated) {
345
+ if (isExclusive) {
346
346
  json.oneOf = options;
347
347
  } else {
348
348
  json.anyOf = options;