# 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.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 #### 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 = getMatcher<"a" | "b">()({ a: (s) => …, b: (s) => … }); const fallback = getMatcher<"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: - `getMatcher` — one common `R`; the fallback must fit it; - `getMatcherW` — 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 four universe-agnostic pieces both matchers use: `UnaryFn`, `PatternReturns`, `RedundantFallback`, `HandlerMap`. #### Why - `RedundantFallback`'s property name is the diagnostic, so one definition keeps the two matchers' message from drifting; the other three appear verbatim in both public signatures. #### Rejected - **A generic `Matcher` over the interface pair, `Handlers`, `Fallback` and `MustBePartial`.** Each is built from its own universe (`PatternKey`/`PatternParam` vs `Tags`/`MapTaggedUnion`); abstracting over the F-bounded `Handled` constraint that makes the remainder work risks the contextual typing it exists to preserve. ## 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) 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). #### 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 unguarded: user-facing, stated once in [README § Caveats](../README.md#caveats).