✅ Pin the duplicate-tag union collapse
This commit is contained in:
1 parent
0339a493a2
commit
7ff371c569
1 file changed
+76
@@ -1244,6 +1244,82 @@ test("property union: a collision is rejected beside legal members", () => {
|
|||||||
getTaggedUnionMatcher<Colliding>()("kind")({ true: () => 1, a: () => 2 });
|
getTaggedUnionMatcher<Colliding>()("kind")({ true: () => 1, a: () => 2 });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Duplicate tags
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// A tag is a discriminant only while it is unique across the union. Two members
|
||||||
|
// may still share one: the tag set `Tags<T, K>` dedupes, so a single handler is
|
||||||
|
// exhaustive for both and receives their union. This is the documented known
|
||||||
|
// issue (development/library.md § Tagged-union matcher); the tests below pin the
|
||||||
|
// behavior so it cannot change silently.
|
||||||
|
|
||||||
|
test("duplicate tag: members sharing a tag collapse to one handler", () => {
|
||||||
|
// Arrange — `kind` is not a true discriminant: both members carry `"a"`.
|
||||||
|
interface First {
|
||||||
|
readonly kind: "a";
|
||||||
|
readonly first: number;
|
||||||
|
}
|
||||||
|
interface Second {
|
||||||
|
readonly kind: "a";
|
||||||
|
readonly second: string;
|
||||||
|
}
|
||||||
|
type Clashing = First | Second;
|
||||||
|
const factory = getTaggedUnionMatcher<Clashing>()("kind");
|
||||||
|
|
||||||
|
// Act
|
||||||
|
const pick = factory({
|
||||||
|
a: (s) => {
|
||||||
|
// The shared tag cannot be split, so the handler sees both members.
|
||||||
|
expectTypeOf(s).toEqualTypeOf<First | Second>();
|
||||||
|
assert.equal(s.kind, "a");
|
||||||
|
return "first" in s ? s.first : s.second.length;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Assert — the one `a` key is exhaustive and both members reach it.
|
||||||
|
expectTypeOf(pick).toEqualTypeOf<(shape: Clashing) => number>();
|
||||||
|
assert.equal(pick({ kind: "a", first: 1 }), 1);
|
||||||
|
assert.equal(pick({ kind: "a", second: "abc" }), 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("duplicate tag: handling one tag consumes every member that shares it", () => {
|
||||||
|
// Arrange — `"a"` selects two members; `"c"` selects one.
|
||||||
|
type Mixed =
|
||||||
|
| { readonly kind: "a"; readonly a: number }
|
||||||
|
| { readonly kind: "a"; readonly b: string }
|
||||||
|
| { readonly kind: "c"; readonly c: boolean };
|
||||||
|
const factory = getTaggedUnionMatcher<Mixed>()("kind");
|
||||||
|
|
||||||
|
// Act
|
||||||
|
const describe = factory(
|
||||||
|
{
|
||||||
|
a: (s) => {
|
||||||
|
expectTypeOf(s).toEqualTypeOf<
|
||||||
|
| { readonly kind: "a"; readonly a: number }
|
||||||
|
| { readonly kind: "a"; readonly b: string }
|
||||||
|
>();
|
||||||
|
return 1 as const;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
(s) => {
|
||||||
|
// Handling `"a"` removes both of its members, not just one.
|
||||||
|
expectTypeOf(s).toEqualTypeOf<{
|
||||||
|
readonly kind: "c";
|
||||||
|
readonly c: boolean;
|
||||||
|
}>();
|
||||||
|
assert.equal(s.kind, "c");
|
||||||
|
return 2 as const;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expectTypeOf(describe).toEqualTypeOf<(shape: Mixed) => 1 | 2>();
|
||||||
|
assert.equal(describe({ kind: "a", a: 1 }), 1);
|
||||||
|
assert.equal(describe({ kind: "a", b: "x" }), 1);
|
||||||
|
assert.equal(describe({ kind: "c", c: true }), 2);
|
||||||
|
});
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Dispatch — runtime behavior
|
// Dispatch — runtime behavior
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
Reference in new issue
Block a user