✅ Pin the duplicate-tag union collapse

This commit is contained in:
tmu committed 2026-09-23 20:30:35 +00:00
1 parent 0339a493a2
commit 7ff371c569
1 file changed
+76
+76
View File
@@ -1244,6 +1244,82 @@ test("property union: a collision is rejected beside legal members", () => {
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
// ============================================================================