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.
- package/package.json +1 -1
- package/src/v4/classic/external.ts +2 -1
- package/src/v4/classic/from-json-schema.ts +527 -0
- package/src/v4/classic/schemas.ts +43 -0
- package/src/v4/classic/tests/from-json-schema.test.ts +537 -0
- package/src/v4/classic/tests/record.test.ts +37 -0
- package/src/v4/classic/tests/union.test.ts +38 -0
- package/src/v4/core/api.ts +15 -0
- package/src/v4/core/errors.ts +12 -1
- package/src/v4/core/json-schema-processors.ts +5 -5
- package/src/v4/core/schemas.ts +99 -10
- package/src/v4/core/versions.ts +2 -2
- package/src/v4/mini/external.ts +1 -1
- package/src/v4/mini/schemas.ts +39 -0
- package/v4/classic/external.cjs +5 -2
- package/v4/classic/external.d.cts +3 -1
- package/v4/classic/external.d.ts +3 -1
- package/v4/classic/external.js +3 -1
- package/v4/classic/from-json-schema.cjs +503 -0
- package/v4/classic/from-json-schema.d.cts +10 -0
- package/v4/classic/from-json-schema.d.ts +10 -0
- package/v4/classic/from-json-schema.js +477 -0
- package/v4/classic/schemas.cjs +30 -2
- package/v4/classic/schemas.d.cts +10 -0
- package/v4/classic/schemas.d.ts +10 -0
- package/v4/classic/schemas.js +26 -0
- package/v4/core/api.cjs +9 -0
- package/v4/core/api.d.cts +2 -0
- package/v4/core/api.d.ts +2 -0
- package/v4/core/api.js +8 -0
- package/v4/core/errors.d.cts +10 -1
- package/v4/core/errors.d.ts +10 -1
- package/v4/core/json-schema-processors.cjs +5 -5
- package/v4/core/json-schema-processors.js +5 -5
- package/v4/core/schemas.cjs +76 -11
- package/v4/core/schemas.d.cts +9 -0
- package/v4/core/schemas.d.ts +9 -0
- package/v4/core/schemas.js +74 -9
- package/v4/core/versions.cjs +2 -2
- package/v4/core/versions.d.cts +1 -1
- package/v4/core/versions.d.ts +1 -1
- package/v4/core/versions.js +2 -2
- package/v4/mini/external.cjs +3 -2
- package/v4/mini/external.d.cts +2 -1
- package/v4/mini/external.d.ts +2 -1
- package/v4/mini/external.js +2 -1
- package/v4/mini/schemas.cjs +28 -2
- package/v4/mini/schemas.d.cts +8 -0
- package/v4/mini/schemas.d.ts +8 -0
- 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
|
+
});
|
package/src/v4/core/api.ts
CHANGED
|
@@ -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;
|
package/src/v4/core/errors.ts
CHANGED
|
@@ -62,13 +62,24 @@ export interface $ZodIssueUnrecognizedKeys extends $ZodIssueBase {
|
|
|
62
62
|
readonly input?: Record<string, unknown>;
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
-
|
|
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
|
-
//
|
|
337
|
-
//
|
|
338
|
-
const
|
|
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,
|
|
342
|
+
path: [...params.path, isExclusive ? "oneOf" : "anyOf", i],
|
|
343
343
|
})
|
|
344
344
|
);
|
|
345
|
-
if (
|
|
345
|
+
if (isExclusive) {
|
|
346
346
|
json.oneOf = options;
|
|
347
347
|
} else {
|
|
348
348
|
json.anyOf = options;
|