📝 Document the two-argument fallback

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.
This commit is contained in:
tmu committed 2026-09-18 23:52:47 +00:00
1 parent 0d6a3f1b9b
commit ae7ffb6fa2
3 files changed
+61 -63

No files matched your search

+2
View File
@@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
- move the `_` fallback to a second argument typed `Exclude<T, keyof handlers>`, so it receives only the unhandled keys, and infer handler returns from short, unannotated handlers
## [0.3.0] - 2026-09-18 ## [0.3.0] - 2026-09-18
- condensed primitive matcher factories down to 2 from formerly 4 - condensed primitive matcher factories down to 2 from formerly 4
+4 -4
View File
@@ -42,10 +42,10 @@ Matcher:
✔ Clean up: adopt the 3-overload matcher (`src/prototype-ac2.ts`) and delete the prototypes @high @done ✔ Clean up: adopt the 3-overload matcher (`src/prototype-ac2.ts`) and delete the prototypes @high @done
→ `getMatcher` / `getMatcherW`, each with overloads `ExhaustiveLoose` → `Fallback` → `Handlers` (order is load-bearing) → `getMatcher` / `getMatcherW`, each with overloads `ExhaustiveLoose` → `Fallback` → `Handlers` (order is load-bearing)
→ fold into `src/primitive.ts` / the public API; drop `src/prototype*.ts` → fold into `src/primitive.ts` / the public API; drop `src/prototype*.ts`
☐ `_` should receive only the unhandled `T` keys, not all of `T` @medium ✔ `_` should receive only the unhandled `T` keys, not all of `T` @medium @done
→ today `_: (shape: T) => R`; desired `_: (shape: Exclude<T, handledKeys>) => R` → the fallback is now a second argument: `(handlers, (s) => …)`, `s: Exclude<T, keyof handlers>`
☐ An exhaustive pattern that also carries `_` must be a compile error @medium ☐ A fallback for an already-exhaustive handler map must be a compile error @medium
→ `{ a, b, _ }` for `T = "a" | "b"` is accepted today; the redundant `_` should be rejected → `(handlers, fallback)` with `handlers` covering all of `T` is accepted; the redundant fallback should be rejected (currying would allow the guard)
Bugs: Bugs:
✔ TS 7 LSP server logs `context canceled` on stderr at shutdown @done ✔ TS 7 LSP server logs `context canceled` on stderr at shutdown @done
+55 -59
View File
@@ -3,7 +3,7 @@
The type-level design of the public API and the limitations it carries. The The type-level design of the public API and the limitations it carries. The
user-facing reference is [README § API](../README.md#api). user-facing reference is [README § API](../README.md#api).
The matcher below is implemented in `src/primitive.ts` and re-exported from The matcher is implemented in `src/primitive.ts` and re-exported from
`src/index.ts` as `getMatcher` / `getMatcherW`; the rest of the library is `src/index.ts` as `getMatcher` / `getMatcherW`; the rest of the library is
placeholder code. placeholder code.
@@ -11,81 +11,77 @@ placeholder code.
#### Decision (2026-09) #### Decision (2026-09)
A matcher is built by a factory and applied to a pattern: A factory takes the universe and returns a builder; the builder takes a handler
map and an optional fallback:
```ts ```ts
const matcher = getMatcher<"a" | "b">()({ a: (s) => …, b: (s) => … }); const matcher = getMatcher<"a" | "b">()({ a: (s) => …, b: (s) => … });
const fallback = getMatcher<"a" | "b" | "c">()({ a: (s) => … }, (s) => …);
``` ```
Whether the pattern is exhaustive or has a fallback is decided **at the call Exhaustive or fallback is decided **at the call site**, by whether the second
site**, by whether it carries `_` — F#'s `| _ ->`. Only the return-strictness argument is present. The fallback's parameter is the remainder
axis remains, so there are two factories: `Exclude<T, keyof Handled>`. Only the return-strictness axis remains, so there
are two factories:
- `getMatcher` — one common `R`, the best common return type of every handler; - `getMatcher` — one common `R`; the fallback must fit it;
- `getMatcherW` — the union of every handler's return type. - `getMatcherW` — the union `PatternReturns<Handled> | R`.
Both are three overloads whose order is load-bearing: Each factory is two overloads whose order is load-bearing:
1. `ExhaustiveLoose<R, T>` = `{ [K in T]: UnaryFn<K, R> } & { _?: UnaryFn<T, R> }` 1. `Handlers<T, R>` — the exhaustive form, and the contextual type of the
2. `Fallback<R, T>` = `Partial<…> & { _: UnaryFn<T, R> }` handler-map popup;
3. `Handlers<R, T>` — the pure exhaustive shape 2. `Handled extends Exact<Partial<Handlers<T, R>>, Handled>` plus
`Fallback<T, Handled, R>` — a partial handler map plus the fallback.
#### Why #### Why
- **Autocomplete reads the first overload, the error reads the last.** - **The fallback is an argument, not a property.** TypeScript fixes a property's
TypeScript takes the first overload signature as the contextual type for the contextual type before it infers its sibling keys, so `_: (s) => …` in the
object-literal popup, and the last for `No overload matches this call`. So handler map can only see all of `T`, never `Exclude<T, keyof Handled>`. A later
`ExhaustiveLoose` first yields the popup `_?, a, b` (T-keys required, `_` argument is contextually typed from inference on an earlier one, so the split
optional) while `Handlers` last yields `Property 'b' is missing`. The two can be is what makes the remainder expressible.
tuned independently. - **Overload order keeps both messages.** #1 supplies the contextual type
- Two factories, not four: the fallback is a pattern _shape_, not a separate API. (`a, b, c`); #2 accepts a partial map once a fallback is present, so its popup
- The widened return union is derived from the pattern's handler types, so it is optional (`a?, b?, c?`). A gap without a fallback is reported against #1.
needs no fourth signature. - **`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 #### Rejected
- **Four factories** (exhaustive and fallback each split by return handling). - **Single-object `_`** (the former shape). `_` sees only all of `T`; the
The exhaustive/fallback axis is expressible as one pattern type; four remainder is not expressible there, and an exhaustive map plus `_` was
signatures duplicate it. accepted.
- **Union merge** — one type `Exhaustive<R,T> | (Partial<…> & { _: … })`, - **Curried handlers-first** — `(handlers)(fallback)`. `Handled` is fixed before
explicit `<T>()`. Type-safe and completable, but TypeScript reports the the second call, so a redundant-fallback guard would work. Rejected: two calls
near-miss union member, so a missing key reads `Property '_' is missing` for the common case.
instead of naming the key. Arm order does not change the report; the overload - **`this` / HKT self-reference.** `this` is post-construction (method bodies,
split does. return positions); a parameter's contextual type is pre-construction.
- **Overload merge with only the exhaustive arm last.** Fixes the missing-key `keyof this` in an interface method is the interface, not the literal.
message, but a wrong `_` parameter is then reported against the exhaustive - **Variance / `const` type parameters / `NoInfer` / `unique symbol` brands /
arm, and `Parameters<typeof factory>` sees only one arm. defaulted type-param guards.** None change inference or evaluation order;
- **Inferred universe** — `match(pattern)` with `T` taken from the keys `in`/`out` on the handler map broke contextual typing outright. `NoInfer`
(exhaustive) or from `_`'s annotated parameter (fallback), via `NoInfer<T>` and specifically leaks into the emitted `.d.ts`, raising the consumer floor to
`_?: never`, split by overloads (a plain union merges inference; measured TypeScript 5.4 (README promises `>= 5.0`).
`T = "_" | "a"`). No explicit `<T>`, and pipe-friendly. Rejected because: with - **Union merge**, **overload merge with only the exhaustive arm last**,
no declared universe the exhaustive popup offers only `_`; an unannotated `_` **inferred universe**, **conditional `RequireKeys`**, **cases-first curried** —
widens `T` to `string | number`; and `NoInfer` leaks into the emitted `.d.ts`, decided against while the API was single-object; their reasons (reported
raising the consumer floor to TypeScript 5.4 (README promises `>= 5.0`). near-miss member, no `_` in the exhaustive popup, `NoInfer`/floor, `keyof P`
- **Conditional `RequireKeys`** — parameter counts optional keys, not pipe-friendly) hold where they still apply.
`P & ("_" extends keyof P ? unknown : Handlers<R, T>)`. Gives the good
missing-key message, but `keyof P` counts _optional_ keys: a widened value
whose declared type has `_?:` bypasses the completeness check. Demanding a
required `_` instead rejects that case but breaks `P` inference — `P` falls back
to its constraint and partial literals then demand every key. Typos also need a
`NoExtra` guard, whose message degrades to `not assignable to never`.
- **Cases-first curried** — `match(["a", "b"])({ a: …, b: … })`. Completion works
for exhaustive patterns, and the array is a single source of truth for the
runtime list and the union. Rejected as not pipe-friendly; it needs a runtime
array; and the single-call form `match(cases, pattern)` cannot infer `R` (the
mapped key type `K[number]` stays deferred, so `R` widens to `unknown`).
#### Known issue #### Known issue
- The `_` handler receives **all** of `T`, not the unhandled subset - A redundant fallback is accepted: when the handler map already covers `T`, the
(`Exclude<T, handledKeys>`). fallback is still allowed. The guard would be
- An exhaustive pattern that also carries `_` is accepted; the redundant `_` `Exclude<T, keyof Handled> extends never ? never : unknown`, but the
should be a compile error. conditional is evaluated before `Handled` is inferred; currying is the only
- The widened overloads carry a completeness guard encoding that fixes it (see Rejected).
`keyof P extends T | "_" ? unknown : never`, because TypeScript does not apply - `PatternReturns` must be
the excess-property check to a generic constraint: a generic parameter accepts
extra keys, a parameter typed as a concrete object type does not.
`PatternReturns` must be
`ReturnType<Extract<ValueOf<P>, (...args: never[]) => unknown>>` so it survives `ReturnType<Extract<ValueOf<P>, (...args: never[]) => unknown>>` so it survives
the closed, partly-optional `P` constraints. the closed, partly-optional `P` constraints.
- `Parameters<typeof factory>[0]` resolves only the **last** overload, so it is - `Parameters<typeof factory>[0]` resolves only the **last** overload, so it is