Skip to content

Commit c648586

Browse files
committed
Include an array of sub-issues in "invalid_union" issues
1 parent 7e35c43 commit c648586

3 files changed

Lines changed: 68 additions & 3 deletions

File tree

.changeset/fast-pens-bake.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@badrap/valita": patch
3+
---
4+
5+
Include an array of sub-issues in "invalid_union" issues

src/index.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,13 @@ type Issue = Readonly<
8787
| { code: "missing_value"; path: Key[] }
8888
| { code: "invalid_literal"; path: Key[]; expected: Literal[] }
8989
| { code: "unrecognized_keys"; path: Key[]; keys: Key[] }
90-
| { code: "invalid_union"; path: Key[]; tree: IssueTree }
90+
| {
91+
code: "invalid_union";
92+
path: Key[];
93+
issues: Issue[];
94+
/** @deprecated Instead of `.tree` use `.issues`. */
95+
tree: IssueTree;
96+
}
9197
| {
9298
code: "invalid_length";
9399
path: Key[];
@@ -123,7 +129,7 @@ function cloneIssueWithPath(tree: IssueLeaf, path: Key[]): Issue {
123129
case "unrecognized_keys":
124130
return { code, path, keys: tree.keys };
125131
case "invalid_union":
126-
return { code, path, tree: tree.tree };
132+
return { code, path, tree: tree.tree, issues: collectIssues(tree.tree) };
127133
case "custom_error":
128134
return { code, path, error: tree.error };
129135
}

tests/union.test.ts

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { describe, it, expect, expectTypeOf } from "vitest";
1+
import { describe, it, expect, expectTypeOf, assert } from "vitest";
22
import * as v from "../src";
33

44
describe("union()", () => {
@@ -8,12 +8,14 @@ describe("union()", () => {
88
expect(t.parse(1)).to.equal(1);
99
expect(() => t.parse({})).to.throw(v.ValitaError);
1010
});
11+
1112
it("ignores never()", () => {
1213
const t = v.union(v.string(), v.never());
1314
expect(t.parse("test")).to.equal("test");
1415
expect(() => t.parse(1)).to.throw(v.ValitaError);
1516
expectTypeOf<v.Infer<typeof t>>().toEqualTypeOf<string>();
1617
});
18+
1719
it("picks the first successful parse", () => {
1820
const t = v.union(
1921
v
@@ -24,6 +26,7 @@ describe("union()", () => {
2426
);
2527
expect(t.parse("test")).to.equal(2);
2628
});
29+
2730
it("respects the order of overlapping parsers", () => {
2831
const a = v.literal(1).map(() => "literal");
2932
const b = v.number().map(() => "number");
@@ -35,18 +38,21 @@ describe("union()", () => {
3538
expect(v.union(c, b, a).parse(1)).to.equal("unknown");
3639
expect(v.union(c, a, b).parse(1)).to.equal("unknown");
3740
});
41+
3842
it("deduplicates strictly equal parsers", () => {
3943
const a = v.unknown().assert(() => false, "test");
4044
expect(() => v.union(a, a).parse(1))
4145
.to.throw(v.ValitaError)
4246
.with.property("issues")
4347
.with.lengthOf(1);
4448
});
49+
4550
it("keeps the matching order when deduplicating", () => {
4651
const a = v.unknown().map(() => "a");
4752
const b = v.unknown().map(() => "b");
4853
expect(v.union(a, b, a).parse(1)).to.equal("a");
4954
});
55+
5056
it("accepts more than two subvalidators", () => {
5157
const t = v.union(
5258
v.string(),
@@ -62,6 +68,7 @@ describe("union()", () => {
6268
expect(t.parse(true)).to.equal(true);
6369
expect(() => t.parse({})).to.throw(v.ValitaError);
6470
});
71+
6572
it("accepts optional input if it maps to non-optional output", () => {
6673
const t = v.object({
6774
a: v.union(
@@ -74,6 +81,7 @@ describe("union()", () => {
7481
});
7582
expect(t.parse({})).toEqual({ a: 1 });
7683
});
84+
7785
it("reports the expected type even for literals when the base type doesn't match", () => {
7886
const t = v.union(v.literal(1), v.literal("test"));
7987
expect(() => t.parse(true))
@@ -84,6 +92,7 @@ describe("union()", () => {
8492
expected: [1, "test"],
8593
});
8694
});
95+
8796
it("reports the expected literals when the base type matches", () => {
8897
const t = v.union(v.literal(1), v.literal("test"));
8998
expect(() => t.parse(2))
@@ -94,6 +103,7 @@ describe("union()", () => {
94103
expected: [1, "test"],
95104
});
96105
});
106+
97107
it("reports the errors from a branch that doesn't overlap with any other branch", () => {
98108
const t = v.union(v.literal(1), v.number(), v.object({ a: v.number() }));
99109
expect(() => t.parse({ a: "test" }))
@@ -105,6 +115,7 @@ describe("union()", () => {
105115
expected: ["number"],
106116
});
107117
});
118+
108119
it("reports expected types in the order they were first listed", () => {
109120
const t1 = v.union(v.literal(2), v.string(), v.literal(2));
110121
expect(() => t1.parse(true))
@@ -126,6 +137,7 @@ describe("union()", () => {
126137
expected: ["string", "number"],
127138
});
128139
});
140+
129141
it("reports expected literals in the order they were first listed", () => {
130142
const t1 = v.union(v.literal(2), v.literal(1), v.literal(2));
131143
expect(() => t1.parse(3))
@@ -147,6 +159,7 @@ describe("union()", () => {
147159
expected: [1, 2],
148160
});
149161
});
162+
150163
it("matches unknowns if nothing else matches", () => {
151164
const t = v.union(
152165
v.literal(1),
@@ -161,6 +174,7 @@ describe("union()", () => {
161174
error: "test",
162175
});
163176
});
177+
164178
it("considers never() to not overlap with anything", () => {
165179
const t = v.union(
166180
v.never(),
@@ -174,6 +188,7 @@ describe("union()", () => {
174188
error: "unknown",
175189
});
176190
});
191+
177192
it("considers unknown() to overlap with everything except never()", () => {
178193
const t = v.union(
179194
v.literal(1),
@@ -187,6 +202,7 @@ describe("union()", () => {
187202
code: "invalid_union",
188203
});
189204
});
205+
190206
it("considers unknown() to overlap with objects", () => {
191207
const t = v.union(
192208
v.unknown(),
@@ -195,6 +211,7 @@ describe("union()", () => {
195211
);
196212
expect(t.parse({ type: "c" })).to.deep.equal({ type: "c" });
197213
});
214+
198215
it("considers array() and tuple() to overlap", () => {
199216
const t = v.union(v.array(v.number()), v.tuple([v.string()]));
200217
expect(() => t.parse(2))
@@ -205,13 +222,15 @@ describe("union()", () => {
205222
expected: ["array"],
206223
});
207224
});
225+
208226
it("keeps transformed values", () => {
209227
const t = v.union(
210228
v.literal("test1").map(() => 1),
211229
v.literal("test2").map(() => 2),
212230
);
213231
expect(t.parse("test1")).to.deep.equal(1);
214232
});
233+
215234
describe("of objects", () => {
216235
it("discriminates based on base types", () => {
217236
const t = v.union(
@@ -227,6 +246,7 @@ describe("union()", () => {
227246
expected: ["number", "string"],
228247
});
229248
});
249+
230250
it("discriminates based on literal values", () => {
231251
const t = v.union(
232252
v.object({ type: v.literal(1) }),
@@ -241,6 +261,7 @@ describe("union()", () => {
241261
expected: [1, 2],
242262
});
243263
});
264+
244265
it("discriminates based on mixture of base types and literal values", () => {
245266
const t = v.union(
246267
v.object({ type: v.literal(1) }),
@@ -255,6 +276,7 @@ describe("union()", () => {
255276
expected: ["number", "string"],
256277
});
257278
});
279+
258280
it("considers unknown() to overlap with everything except never()", () => {
259281
const t = v.union(
260282
v.object({ type: v.literal(1) }),
@@ -265,6 +287,7 @@ describe("union()", () => {
265287
.with.nested.property("issues[0]")
266288
.that.deep.includes({ code: "invalid_union" });
267289
});
290+
268291
it("considers literals to overlap with their base types", () => {
269292
const t = v.union(
270293
v.object({ type: v.literal(1) }),
@@ -275,6 +298,7 @@ describe("union()", () => {
275298
.with.nested.property("issues[0]")
276299
.that.deep.includes({ code: "invalid_union" });
277300
});
301+
278302
it("considers optional() its own type", () => {
279303
const t = v.union(
280304
v.object({ type: v.literal(1) }),
@@ -288,10 +312,12 @@ describe("union()", () => {
288312
expected: ["number", "undefined"],
289313
});
290314
});
315+
291316
it("matches missing values to optional()", () => {
292317
const t = v.union(v.object({ a: v.unknown().optional() }));
293318
expect(t.parse({})).to.deep.equal({});
294319
});
320+
295321
it("considers equal literals to overlap", () => {
296322
const t = v.union(
297323
v.object({ type: v.literal(1) }),
@@ -302,6 +328,7 @@ describe("union()", () => {
302328
.with.nested.property("issues[0]")
303329
.that.deep.includes({ code: "invalid_union" });
304330
});
331+
305332
it("allows mixing literals and non-literals as long as they don't overlap", () => {
306333
const t = v.union(
307334
v.object({ type: v.literal(1) }),
@@ -312,6 +339,7 @@ describe("union()", () => {
312339
expect(t.parse({ type: 2 })).toEqual({ type: 2 });
313340
expect(t.parse({ type: "test" })).toEqual({ type: "test" });
314341
});
342+
315343
it("folds multiple overlapping types together in same branch", () => {
316344
const t = v.union(
317345
v.object({
@@ -331,6 +359,7 @@ describe("union()", () => {
331359
expected: ["test"],
332360
});
333361
});
362+
334363
it("considers two optionals to overlap", () => {
335364
const t = v.union(
336365
v.object({ type: v.literal(1).optional() }),
@@ -340,6 +369,7 @@ describe("union()", () => {
340369
.to.throw(v.ValitaError)
341370
.with.nested.property("issues[0].code", "invalid_union");
342371
});
372+
343373
it("considers two optionals and undefineds to overlap", () => {
344374
const t = v.union(
345375
v.object({ type: v.undefined() }),
@@ -349,6 +379,7 @@ describe("union()", () => {
349379
.to.throw(v.ValitaError)
350380
.with.nested.property("issues[0].code", "invalid_union");
351381
});
382+
352383
it("considers two unions with partially same types to overlap", () => {
353384
const t = v.union(
354385
v.object({ type: v.union(v.literal(1), v.literal(2)) }),
@@ -358,12 +389,35 @@ describe("union()", () => {
358389
.to.throw(v.ValitaError)
359390
.with.nested.property("issues[0].code", "invalid_union");
360391
});
392+
361393
it("keeps transformed values", () => {
362394
const t = v.union(
363395
v.object({ type: v.literal("test1").map(() => 1) }),
364396
v.object({ type: v.literal("test2").map(() => 2) }),
365397
);
366398
expect(t.parse({ type: "test1" })).to.deep.equal({ type: 1 });
367399
});
400+
401+
it("includes an array of sub-issues in 'invalid_union' issues", () => {
402+
const t = v.union(
403+
v.object({ type: v.literal(1).optional() }),
404+
v.object({ type: v.literal(2).optional() }),
405+
);
406+
const result = t.try({ type: 3 });
407+
assert(!result.ok);
408+
assert(result.issues[0].code === "invalid_union");
409+
expect(result.issues[0].issues).toEqual([
410+
{
411+
code: "invalid_literal",
412+
expected: [1],
413+
path: ["type"],
414+
},
415+
{
416+
code: "invalid_literal",
417+
expected: [2],
418+
path: ["type"],
419+
},
420+
]);
421+
});
368422
});
369423
});

0 commit comments

Comments
 (0)