Rewrite development/library.md around the new shape: the fallback is a second argument (so it can receive the remainder), `R` needs an inference site in the handler map, `Exact` restores the excess-property check, and the overload order is load-bearing. Record this branch's rejected alternatives (single-object `_`, currying, `this`/HKT, variance/`const`/`NoInfer`/brands) and the redundant- fallback known issue. Add the changelog note and check off the backlog task.
90 lines
4.3 KiB
Markdown
90 lines
4.3 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 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.
|
|
|
|
## 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<T, keyof Handled>`. Only the return-strictness axis remains, so there
|
|
are two factories:
|
|
|
|
- `getMatcher` — one common `R`; the fallback must fit it;
|
|
- `getMatcherW` — 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>` plus
|
|
`Fallback<T, Handled, R>` — a partial handler map plus the fallback.
|
|
|
|
#### 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.
|
|
- **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)`. `Handled` is fixed before
|
|
the second call, so a redundant-fallback guard would work. Rejected: two calls
|
|
for the common case.
|
|
- **`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
|
|
|
|
- A redundant fallback is accepted: when the handler map already covers `T`, the
|
|
fallback is still allowed. The guard would be
|
|
`Exclude<T, keyof Handled> extends never ? never : unknown`, but the
|
|
conditional is evaluated before `Handled` is inferred; currying is the only
|
|
encoding that fixes it (see Rejected).
|
|
- `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).
|