Add a README Caveats subsection with user-facing guidance for the two open-universe shapes: parse external input at the boundary down to a finite union, or use a runtime Map registry for extensible domains. Point the finite-universe bullet at it, and point the rejected fix/open-universe-* entry in development/library.md back at the new guidance, so rule and decision cross-reference without duplicating each other.
278 lines
14 KiB
Markdown
278 lines
14 KiB
Markdown
# 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<T, keyof Handled>`. Only the return-strictness axis remains, so there
|
||
are two factories:
|
||
|
||
- `getPrimitiveUnionMatcher` — one common `R`; the fallback must fit it;
|
||
- `getPrimitiveUnionMatcherW` — the union `PatternReturns<Handled> | R`.
|
||
|
||
Each factory is two overloads whose order is load-bearing:
|
||
|
||
1. `Handlers<T, R>` — the exhaustive form, and the contextual type of the
|
||
handler-map popup;
|
||
2. `Handled extends Exact<Partial<Handlers<T, R>>, Handled>` intersected with
|
||
`MustBePartial<T, Handled>`, plus `Fallback<T, Handled, R>` — 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<T, keyof Handled>`. 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<T, Handled>` 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<T, keyof Handled> 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<Handlers<T, R>>` 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<Extract<ValueOf<P>, (...args: never[]) => unknown>>` so it survives
|
||
the closed, partly-optional `P` constraints.
|
||
- `Parameters<typeof factory>[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<Universe>` 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<T>` (the `UnsupportedUniverse<Reason>` diagnostic) into the
|
||
handler and fallback parameters. `Member<T, K>` replaces the `PatternParam<K>`
|
||
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<K>` cannot recover the member.
|
||
`Member<T, K>` 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<PatternKey<T>> 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<T, K>` 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<T> =
|
||
Extract<T, Stringified<T>>` 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<T, K>` without the gate:** sound, but a colliding handler gets a
|
||
union and `1 | "1"` stays one runtime key.
|
||
- **A round-trip injectivity gate** (`IsEqual<T, PatternParam<PatternKey<T>>>`):
|
||
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<Shape>()("kind")({
|
||
circle: (s) => Math.PI * s.radius ** 2,
|
||
square: (s) => s.side ** 2,
|
||
});
|
||
const fallback = getTaggedUnionMatcher<Shape>()("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<T>` 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<Tags, keyof Handled>`) and
|
||
`Narrowed<T, K, Exclude<Tags, …>>` 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<PropertyKey, unknown>`.** 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 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.
|
||
|
||
## 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).
|