From aaeeef1e11eed2aa090d75ba4eabd316b31eff9c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20M=C3=BCller?= Date: Wed, 23 Sep 2026 13:42:12 +0000 Subject: [PATCH] :memo: Document the finite-only universe decision Record why open universes and value/stringification collisions are rejected, the `Member` inversion, and the findings from the rejected open-universe attempt so they are not re-run. --- README.md | 13 ++++-- development/library.md | 96 ++++++++++++++++++++++++++++++++++-------- 2 files changed, 88 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index f6b20e4..e09f3e8 100644 --- a/README.md +++ b/README.md @@ -25,10 +25,15 @@ Yet to be implemented ## Caveats -- **A value and its stringification collide.** Object keys stringify, so a - universe that mixes a member with the string it stringifies to — `1 | "1"`, - `true | "true"`, `null | "null"` — collapses to a single handler key and both - members are routed to it. Use one form or the other. +- **Only finite universes are supported.** The factory must be given a finite + union of literals; `string`, `number` and template literals are rejected. This + is what lets the exhaustive overload be proven, so the runtime `dispatch` + throw stays unreachable through the typed API. +- **A value and its stringification must not both be present.** Object keys + stringify, so a universe containing both a member and the string it + stringifies to — `1 | "1"`, `true | "true"`, `null | "null"` — is rejected at + the factory. Either form alone is fine, and one value's string form may + coexist with a _different_ value's bare form (`"true" | false`). - **`symbol` and `bigint` are not supported.** A `symbol` brand is a compile-time phantom with nothing to match at runtime, and a `bigint` is not a valid property key; neither satisfies the matcher's universe constraint. diff --git a/development/library.md b/development/library.md index 9196b90..b7b6869 100644 --- a/development/library.md +++ b/development/library.md @@ -97,9 +97,11 @@ Each factory is two overloads whose order is load-bearing: #### Decision (2026-09) -`src/matcher-shared.ts` holds the seven universe-agnostic pieces both matchers -use: `UnaryFn`, `PatternReturns`, `RedundantFallback`, `HandlerMap`, the shared -`Matchable` universe, and the `PatternKey` / `PatternParam` key projection. +`src/matcher-shared.ts` holds the universe-agnostic pieces both matchers use: +`UnaryFn`, `PatternReturns`, `RedundantFallback`, `HandlerMap`, the shared +`Matchable` universe, the `PatternKey` key projection and its `Member` inverse, +and the `Stringified` / `Collisions` / `UnsupportedReason` / `UnsupportedUniverse` +/ `UniverseGate` universe gate. #### Why @@ -118,8 +120,65 @@ use: `UnaryFn`, `PatternReturns`, `RedundantFallback`, `HandlerMap`, the shared `Fallback` and `MustBePartial`.** Each is built from its own universe (`Tags`/`MapTaggedUnion` vs the primitive values); abstracting over the F-bounded `Handled` constraint that makes the remainder work risks the - contextual typing it exists to preserve. `Matchable` and the `PatternKey` / - `PatternParam` projection are the pieces both universes genuinely share. + contextual typing it exists to preserve. `Matchable`, the `PatternKey` / + `Member` projection and the `UniverseGate` are the pieces both universes + genuinely share. + +## Supported universes + +#### Decision (2026-09) + +A universe must be a **finite union of literals** with **no +value/stringification collision**. Broad types (`string`, `number`, a template +literal) and `"true" | true` / `1 | "1"` are rejected; the factory intersects +`UniverseGate` (the `UnsupportedUniverse` diagnostic) into the +handler and fallback parameters. `Member` replaces the `PatternParam` +inversion: the handler parameter is the member(s) of `T` whose `PatternKey` is +`K`, so a standalone `"true"` is `"true"`, not `true`. + +#### Why + +- **`PatternKey` is not injective.** `"true"` and `true` (and `1` / `"1"`) + share a runtime key, so `PatternParam` cannot recover the member. + `Member` inverts against `T`, which is exact. +- **Broad types cannot be proven exhaustive.** An index-like map lets a partial + object satisfy the exhaustive overload and reaches the `dispatch` throw. + Rejecting at the boundary avoids threading an open/closed branch through + `Handlers`, `Fallback` and `MustBePartial`. +- **Collisions are rejected, not merged.** `Member` would be sound (the + handler gets the union), but the API is one handler per member; rejecting + keeps `Member` a singleton and the remainder exact. +- **The collision predicate is type-checkable.** `Collisions = +Extract>` catches numeric collisions too. +- **The gate is an intersection, not a branch,** so `R` inference and the popup + survive; a conditional parameter type would not. + +#### Rejected + +- **Open universes with a required fallback** (`fix/open-universe-*`): sound, + but left the collision hole and added an `IsLiteral` / + `OpenUniverseNeedsFallback` branch through every handler type. Findings, kept + so they are not re-run: `{}` satisfies an index signature (and `Exact` misses + it); an index signature dominates contextual typing; `R` infers only from a + non-self-referential parameter type (`{ [K in keyof H]: … R … }` gives + `unknown`); an F-bounded guard referencing `keyof Handled` in `Handled`'s own + constraint sees the constraint, not the map; all handlers share one `R` (only + the fallback widens it); `IsLiteral` is the finite-literal predicate. +- **`Member` without the gate:** sound, but a colliding handler gets a + union and `1 | "1"` stays one runtime key. +- **A round-trip injectivity gate** (`IsEqual>>`): + over-rejects standalone `"true"` / `"false"` / `"null"` / `"undefined"`. +- **A case-list / ts-pattern builder:** removes the collision class but drops + the object map (footprint, popup) and reimplements an existing library. +- **Normalize numeric keys to strings:** makes `PatternKey` injective but + changes "numeric keys stay numbers" and defeats the numeric dispatch fast + path. + +#### Known issue + +- A multi-collision universe lists every collision in the diagnostic. +- The `dispatch` throw is unreachable through the typed API; the throw tests + widen the factory to `Function` to reach it. ## Tagged-union matcher @@ -160,17 +219,19 @@ The key is a separate call because `K` is inferred from its literal argument and - **`MapTaggedUnion` distributes with `Extract`.** A duplicated tag yields a union of members instead of dropping one. - **A `boolean` / `null` / `undefined` tag goes through the shared - `PatternKey` / `PatternParam` projection.** `Discriminated` admits those tags + `PatternKey` / `Member` projection.** `Discriminated` admits those tags (they are in `Tag`), but they cannot key a mapped type, so the handler map is - keyed by the stringified form (`true` → `"true"`) and `PatternParam` inverts - it to recover the member. This is the same projection the primitive-union - matcher uses over its universe, which is why it lives in `matcher-shared.ts`. + keyed by the stringified form (`true` → `"true"`) and `Member` inverts it + against the tag set to recover the member. This is the same projection the + primitive-union matcher uses over its universe, which is why it lives in + `matcher-shared.ts`. #### 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. The same holds for a tag colliding with - its stringification (`true | "true"`) — see [README § Caveats](../README.md#caveats). + collapse to a union under one handler. A tag colliding with its + stringification (`true | "true"`) is rejected by the universe gate — see + § Supported universes. ## Primitive universe @@ -180,11 +241,12 @@ The universe (`Matchable`) is `string | number | boolean | null | undefined`, with `boolean` admitted as `true | false`. `boolean`/`null`/`undefined` are not property keys, so handler-map keys are a -projection (`PatternKey`: each member stringified) and `PatternParam` inverts -it, so callbacks receive the real member (`true`, not `"true"`). The popup -offers `true`, `false`, `null`, `undefined` by name (verified over LSP). The -same projection is shared with the tagged-union matcher; see -§ Tagged-union matcher. +projection (`PatternKey`: each member stringified) and `Member` inverts it +against the universe, so callbacks receive the real member (`true`, not +`"true"`; the standalone string `"true"` stays `"true"`). The popup offers +`true`, `false`, `null`, `undefined` by name (verified over LSP). The same +projection is shared with the tagged-union matcher; see § Tagged-union matcher. +The supported universes are constrained as described in § Supported universes. #### Why @@ -194,5 +256,5 @@ same projection is shared with the tagged-union matcher; see `String()` defeats V8's numeric-key path: measured ~2× on number-keyed dispatch). - `symbol`/`bigint`/`NaN`/`-0` are rejected, and a member colliding with its - stringification is unguarded: user-facing, stated once in + stringification is rejected by the gate: user-facing, stated once in [README § Caveats](../README.md#caveats).