🔀 Merge chore/duplicate-tag-union-test into main

This commit is contained in:
tmu committed 2026-09-23 20:55:56 +00:00
commit 76327a8c47
4 files changed
+88 -4

No files matched your search

+4
View File
@@ -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.
+1
View File
@@ -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/<tag>/`)
☐ Browse to `…/tiny-pattern-ts/index.html` in the browser
☐ Add testing with TypeScript 5.0 baseline in CI
+7 -4
View File
@@ -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
+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
// ============================================================================