From 8ab3c6dbc6a693ec07575d10f5bf737f4319a548 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20M=C3=BCller?= Date: Mon, 21 Sep 2026 22:14:22 +0000 Subject: [PATCH] :memo: Document the tagged-union matcher --- CHANGELOG.md | 2 ++ backlog.tasks | 5 ++++ development/library.md | 55 +++++++++++++++++++++++++++++++++++++++--- 3 files changed, 59 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2fbfe07..9c88904 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +- add `getTaggedUnionMatcher` / `getTaggedUnionMatcherW` for discriminated unions + ## [0.5.0] - 2026-09-21 - reject a fallback when the handler map already covers the universe diff --git a/backlog.tasks b/backlog.tasks index e17212f..2f71062 100644 --- a/backlog.tasks +++ b/backlog.tasks @@ -46,6 +46,9 @@ Matcher: → the fallback is now a second argument: `(handlers, (s) => …)`, `s: Exclude` ✔ A fallback for an already-exhaustive handler map must be a compile error @medium @done → rejected by an F-bounded constraint on `Handled` (checked *after* inference); a conditional in the fallback parameter is evaluated too early and breaks contextual typing +✔ Implement matcher with similar API like matcher from primitive.ts @high @done + → `getTaggedUnionMatcher` / `getTaggedUnionMatcherW`, curried on the discriminant key + → fallback is the second argument; `_` removed Bugs: ✔ TS 7 LSP server logs `context canceled` on stderr at shutdown @done @@ -55,6 +58,8 @@ Enhancements: ✔ Allow boolean literals in primitive union patterns (e.g. `true: () => "yes"`) @medium @done ✔ Are there other primitive types that should be supported in union patterns? (e.g. `bigint`, `symbol`) @medium @done → added boolean, null and undefined; rejected `symbol` (compile-time brand, nothing at runtime) and `bigint` (not a property key) +☐ Allow boolean, null and undefined discriminant values in tagged-union patterns @medium + → needs the primitive matcher's `PatternKey` / `PatternParam` projection; see development/library.md § Tagged-union matcher Documentation: ☐ Bring README.md back to its previous form — synopsis and examples restored, in the correct place diff --git a/development/library.md b/development/library.md index 7c6b5fe..f5b2cd8 100644 --- a/development/library.md +++ b/development/library.md @@ -3,9 +3,10 @@ The type-level design of the public API and the limitations it carries. The user-facing reference is [README § API](../README.md#api). -The matcher is implemented in `src/primitive.ts` and re-exported from -`src/index.ts` as `getMatcher` / `getMatcherW`; the rest of the library is -placeholder code. +The matchers are implemented in `src/primitive.ts` (`getMatcher` / +`getMatcherW`) and `src/tagged-union.ts` (`getTaggedUnionMatcher` / +`getTaggedUnionMatcherW`), re-exported from `src/index.ts`; the rest of the +library is placeholder code. ## Matcher shape @@ -92,6 +93,54 @@ Each factory is two overloads whose order is load-bearing: not a sound "rejected" oracle for a factory. Factory-negative tests use `@ts-expect-error` call sites (the test file only — the general ban stands). +## Tagged-union matcher + +#### Decision (2026-09) + +`getTaggedUnionMatcher` / `getTaggedUnionMatcherW` mirror the primitive pair +with one extra curried step for the discriminant key: + +```ts +type Shape = + | { kind: "circle"; radius: number } + | { kind: "square"; side: number }; + +const area = getTaggedUnionMatcher()("kind")({ + circle: (s) => Math.PI * s.radius ** 2, + square: (s) => s.side ** 2, +}); +const fallback = getTaggedUnionMatcher()("kind")( + { circle: (s) => … }, + (s) => …, // s: { kind: "square"; side: number } +); +``` + +The key is a separate call because `K` is inferred from its literal argument and +`T` is fixed by the first factory; one call could not infer both. +`Discriminated` restricts the key to properties whose values are tags. + +#### Why + +- **Same fallback/remainder machinery as the primitive matcher.** `HandledMembers` + maps the handled tags to their members and `Exclude` is the fallback's + parameter; the redundant-fallback guard is the same F-bounded constraint. Only + the "universe" changes — `T`'s members instead of primitive values. +- **`T extends object`, not `Record`.** An `interface` has + no implicit index signature, so the `Record` constraint would reject + interface-based unions. The runtime reads the tag off `object` with one + assertion, the tagged twin of the primitive dispatch's `shape as string | number`. +- **`MapTaggedUnion` distributes with `Extract`.** A duplicated tag yields a + union of members instead of dropping one. + +#### Known issue + +- Tags are `string | number` only. A `boolean` / `null` / `undefined` + discriminant (`{ ok: true } | { ok: false }`) is rejected by `Discriminated`, + because those values are not property keys; supporting them needs the + primitive matcher's `PatternKey` / `PatternParam` projection. +- A member's tag must be unique across the union; two members with the same tag + collapse to a union under one handler. + ## Primitive universe #### Decision (2026-09)