# Library design The type-level design of the public API and the limitations it carries. The user-facing reference is [README § API](../README.md#api). The matchers are implemented in `src/primitive-union.ts` (`getPrimitiveUnionMatcher` / `getPrimitiveUnionMatcherW`) and `src/tagged-union.ts` (`getTaggedUnionMatcher` / `getTaggedUnionMatcherW`), re-exported from `src/index.ts`; the rest of the library is placeholder code. ## Matcher shape #### Decision (2026-09) A factory takes the universe and returns a builder; the builder takes a handler map and an optional fallback: ```ts const matcher = getPrimitiveUnionMatcher<"a" | "b">()({ a: (s) => …, b: (s) => … }); const fallback = getPrimitiveUnionMatcher<"a" | "b" | "c">()({ a: (s) => … }, (s) => …); ``` Exhaustive or fallback is decided **at the call site**, by whether the second argument is present. The fallback's parameter is the remainder `Exclude`. Only the return-strictness axis remains, so there are two factories: - `getPrimitiveUnionMatcher` — one common `R`; the fallback must fit it; - `getPrimitiveUnionMatcherW` — the union `PatternReturns | R`. Each factory is two overloads whose order is load-bearing: 1. `Handlers` — the exhaustive form, and the contextual type of the handler-map popup; 2. `Handled extends Exact>, Handled>` intersected with `MustBePartial`, plus `Fallback` — a partial handler map plus the fallback, rejected when the map already covers `T`. #### Why - **The fallback is an argument, not a property.** TypeScript fixes a property's contextual type before it infers its sibling keys, so `_: (s) => …` in the handler map can only see all of `T`, never `Exclude`. A later argument is contextually typed from inference on an earlier one, so the split is what makes the remainder expressible. - **The redundant-fallback guard is an F-bounded constraint.** A map that already covers `T` plus a fallback is rejected by folding `MustBePartial` into `Handled`'s own constraint. The guard is checked _after_ `Handled` is inferred, so the contextual pass that types the handler callbacks survives. The obvious conditional `Exclude extends never ? …` in the fallback's parameter type is evaluated while `Handled` is still its constraint and rejects every partial map whose callbacks are context-sensitive. - **Overload order keeps both messages.** #1 supplies the contextual type (`a, b, c`); #2 accepts a partial map once a fallback is present, so its popup is optional (`a?, b?, c?`). A gap without a fallback is reported against #1. - **`R` needs an inference site.** `R` inside the `Exact<…>` constraint is not one, so `handlers: Handled & Partial>` re-adds it; without that `R` collapses to `unknown` when the handler params are inferred. - **`Exact` restores the excess-property check.** TypeScript skips it for a generic constraint, so without `Exact` the handler map accepts keys outside `T`. - Two factories, not four: the fallback is an argument, not a separate API. #### Rejected - **Single-object `_`** (the former shape). `_` sees only all of `T`; the remainder is not expressible there, and an exhaustive map plus `_` was accepted. - **Curried handlers-first** — `(handlers)(fallback)`. Rejected: two calls for the common case. It is not needed for the redundant-fallback guard, which the F-bounded constraint already provides (see Why). - **`this` / HKT self-reference.** `this` is post-construction (method bodies, return positions); a parameter's contextual type is pre-construction. `keyof this` in an interface method is the interface, not the literal. - **Variance / `const` type parameters / `NoInfer` / `unique symbol` brands / defaulted type-param guards.** None change inference or evaluation order; `in`/`out` on the handler map broke contextual typing outright. `NoInfer` specifically leaks into the emitted `.d.ts`, raising the consumer floor to TypeScript 5.4 (README promises `>= 5.0`). - **Union merge**, **overload merge with only the exhaustive arm last**, **inferred universe**, **conditional `RequireKeys`**, **cases-first curried** — decided against while the API was single-object; their reasons (reported near-miss member, no `_` in the exhaustive popup, `NoInfer`/floor, `keyof P` counts optional keys, not pipe-friendly) hold where they still apply. #### Known issue - `PatternReturns` must be `ReturnType, (...args: never[]) => unknown>>` so it survives the closed, partly-optional `P` constraints. - `Parameters[0]` resolves only the **last** overload, so it is 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). ## Shared internals #### Decision (2026-09) `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 - `RedundantFallback`'s property name is the diagnostic, so one definition keeps the two matchers' message from drifting; the other pieces appear verbatim in both public signatures or are the same projection over each matcher's universe. - **`Matchable` is one definition, not two.** The primitive-union matcher's universe and the tagged-union matcher's allowed `Tag` values are the same set, so aliasing them keeps the two matchers from drifting apart on what they accept (`symbol`/`bigint` rejected once). #### Rejected - **A generic `Matcher` over the interface pair, `Handlers`, `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`, 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`. - **The finite-literal predicate is `IsLiteral> extends true`.** `IsLiteral` is `boolean` for a union that mixes a literal with a broad type (`"a" | \`x-${number}\``), so `extends false`would treat the mix as supported;`extends true` is the check that rejects it. - **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. The user-facing consequence — open universes are a parsing or registry concern, not a dispatch one — is guidance in README § Why open universes are rejected. - **`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 #### Decision (2026-09) `getTaggedUnionMatcher` / `getTaggedUnionMatcherW` mirror the primitive-union 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-union matcher.** `HandledTags` recovers the tag values the map handled (`Member`) and `Narrowed>` 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-union dispatch's `shape as string | number`. - **`Narrowed` distributes over `T`, narrowing `K` to the tag.** A member whose `K` cannot take the tag drops out; a duplicated tag yields a union of members instead of dropping one. `T[K] extends V` returns the exact member (a discriminated union's declared interface) untouched, so the mapped form only handles a property that is itself a union. - **A union-valued or optional discriminant is supported.** A single shape whose property is a union (`{ color: "red" | "green" | "blue" }`) is narrowed per handler instead of being passed `never`. Matching a defined tag on an optional property (`{ type?: "x" }`) proves the key is present, so it becomes required (`{ type: "x" }`); the `undefined` tag narrows it to `{ type?: never }` under `exactOptionalPropertyTypes` (absence) or `{ type?: undefined }` when the property explicitly admits `undefined`. - **A `boolean` / `null` / `undefined` tag goes through the shared `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 `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 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 #### Decision (2026-09) 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 `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 - Runtime dispatch indexes with the raw `shape`; `handlers[true]` coerces to `"true"` at runtime exactly as `String` would. The `shape as string | number` assertion only placates `TS2538` and buys the number fast path (an explicit `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 rejected by the gate: user-facing, stated once in [README § Caveats](../README.md#caveats).