diff --git a/CHANGELOG.md b/CHANGELOG.md index 15fd677..8aff1e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +- pin the duplicate-tag union collapse (members sharing a tag dispatch through + one handler) with tests +- track a TypeScript 5.0 baseline in the backlog + ## [0.8.0] - 2026-09-23 - reject a universe that mixes a literal with a broad type (e.g. diff --git a/backlog.tasks b/backlog.tasks index 33d38a4..d272be0 100644 --- a/backlog.tasks +++ b/backlog.tasks @@ -44,3 +44,4 @@ Maintenance: ☐ Add a minimal dir-listing webserver to the gitea docker setup for serving landing page (reuse existing reverse proxy) ☐ CI writes landing page to a shared volume keyed by project + tag (e.g. `/landing/tiny-pattern-ts//`) ☐ Browse to `…/tiny-pattern-ts/index.html` in the browser +☐ Add testing with TypeScript 5.0 baseline in CI diff --git a/development/library.md b/development/library.md index 05a7eb9..fc3a3f6 100644 --- a/development/library.md +++ b/development/library.md @@ -245,10 +245,13 @@ The key is a separate call because `K` is inferred from its literal argument and #### Known issue -- A member's tag must be unique across the union; two members with the same tag - collapse to a union under one handler. A tag colliding with its - stringification (`true | "true"`) is rejected by the universe gate — see - § Supported universes. +- A tag need not be unique across the union. Two members sharing one is not a + soundness hole: they select a single runtime key, so one handler receiving + their union is the only correct behavior — the key is simply not a + discriminant. The gate rejects only the distinct-value collision + (`true | "true"`), where two values share a key and `Member` can no longer + invert it; see § Supported universes. Pinned by the duplicate-tag tests in + `src/tagged-union.test.ts`. ## Primitive universe 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 // ============================================================================