🔀 Merge chore/duplicate-tag-union-test into main
This commit is contained in:
commit
76327a8c47
4 files changed
+88
-4
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
// ============================================================================
|
||||
|
||||
Reference in new issue
Block a user