From 7ff371c569a7b2e41d468f07a16fc1f35fb1c183 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20M=C3=BCller?= Date: Wed, 23 Sep 2026 20:30:35 +0000 Subject: [PATCH] :white_check_mark: Pin the duplicate-tag union collapse --- src/tagged-union.test.ts | 76 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 76 insertions(+) diff --git a/src/tagged-union.test.ts b/src/tagged-union.test.ts index bd6f4b1..ded1146 100644 --- a/src/tagged-union.test.ts +++ b/src/tagged-union.test.ts @@ -1244,6 +1244,82 @@ test("property union: a collision is rejected beside legal members", () => { getTaggedUnionMatcher()("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` 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()("kind"); + + // Act + const pick = factory({ + a: (s) => { + // The shared tag cannot be split, so the handler sees both members. + expectTypeOf(s).toEqualTypeOf(); + 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()("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 // ============================================================================