diff --git a/CHANGELOG.md b/CHANGELOG.md index d342498..b17ef67 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +- move the `_` fallback to a second argument typed `Exclude`, so it receives only the unhandled keys, and infer handler returns from short, unannotated handlers + ## [0.3.0] - 2026-09-18 - condensed primitive matcher factories down to 2 from formerly 4 diff --git a/backlog.tasks b/backlog.tasks index 45ad514..d162e65 100644 --- a/backlog.tasks +++ b/backlog.tasks @@ -42,10 +42,10 @@ Matcher: ✔ 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) → fold into `src/primitive.ts` / the public API; drop `src/prototype*.ts` -☐ `_` should receive only the unhandled `T` keys, not all of `T` @medium - → today `_: (shape: T) => R`; desired `_: (shape: Exclude) => R` -☐ An exhaustive pattern that also carries `_` must be a compile error @medium - → `{ a, b, _ }` for `T = "a" | "b"` is accepted today; the redundant `_` should be rejected +✔ `_` should receive only the unhandled `T` keys, not all of `T` @medium @done + → the fallback is now a second argument: `(handlers, (s) => …)`, `s: Exclude` +☐ A fallback for an already-exhaustive handler map must be a compile error @medium + → `(handlers, fallback)` with `handlers` covering all of `T` is accepted; the redundant fallback should be rejected (currying would allow the guard) Bugs: ✔ TS 7 LSP server logs `context canceled` on stderr at shutdown @done diff --git a/development/library.md b/development/library.md index 5f89e22..4f71594 100644 --- a/development/library.md +++ b/development/library.md @@ -3,7 +3,7 @@ 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 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 placeholder code. @@ -11,81 +11,77 @@ placeholder code. #### 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 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 -site**, by whether it carries `_` — F#'s `| _ ->`. Only the return-strictness -axis remains, so there are two factories: +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 best common return type of every handler; -- `getMatcherW` — the union of every handler's return type. +- `getMatcher` — one common `R`; the fallback must fit it; +- `getMatcherW` — the union `PatternReturns | R`. -Both are three overloads whose order is load-bearing: +Each factory is two overloads whose order is load-bearing: -1. `ExhaustiveLoose` = `{ [K in T]: UnaryFn } & { _?: UnaryFn }` -2. `Fallback` = `Partial<…> & { _: UnaryFn }` -3. `Handlers` — the pure exhaustive shape +1. `Handlers` — the exhaustive form, and the contextual type of the + handler-map popup; +2. `Handled extends Exact>, Handled>` plus + `Fallback` — a partial handler map plus the fallback. #### Why -- **Autocomplete reads the first overload, the error reads the last.** - TypeScript takes the first overload signature as the contextual type for the - object-literal popup, and the last for `No overload matches this call`. So - `ExhaustiveLoose` first yields the popup `_?, a, b` (T-keys required, `_` - optional) while `Handlers` last yields `Property 'b' is missing`. The two can be - tuned independently. -- Two factories, not four: the fallback is a pattern _shape_, not a separate API. -- The widened return union is derived from the pattern's handler types, so it - needs no fourth signature. +- **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. +- **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 -- **Four factories** (exhaustive and fallback each split by return handling). - The exhaustive/fallback axis is expressible as one pattern type; four - signatures duplicate it. -- **Union merge** — one type `Exhaustive | (Partial<…> & { _: … })`, - explicit `()`. Type-safe and completable, but TypeScript reports the - near-miss union member, so a missing key reads `Property '_' is missing` - instead of naming the key. Arm order does not change the report; the overload - split does. -- **Overload merge with only the exhaustive arm last.** Fixes the missing-key - message, but a wrong `_` parameter is then reported against the exhaustive - arm, and `Parameters` sees only one arm. -- **Inferred universe** — `match(pattern)` with `T` taken from the keys - (exhaustive) or from `_`'s annotated parameter (fallback), via `NoInfer` and - `_?: never`, split by overloads (a plain union merges inference; measured - `T = "_" | "a"`). No explicit ``, and pipe-friendly. Rejected because: with - no declared universe the exhaustive popup offers only `_`; an unannotated `_` - widens `T` to `string | number`; and `NoInfer` leaks into the emitted `.d.ts`, - raising the consumer floor to TypeScript 5.4 (README promises `>= 5.0`). -- **Conditional `RequireKeys`** — parameter - `P & ("_" extends keyof P ? unknown : Handlers)`. 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`). +- **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 -- The `_` handler receives **all** of `T`, not the unhandled subset - (`Exclude`). -- An exhaustive pattern that also carries `_` is accepted; the redundant `_` - should be a compile error. -- The widened overloads carry a completeness guard - `keyof P extends T | "_" ? unknown : never`, because TypeScript does not apply - 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 +- A redundant fallback is accepted: when the handler map already covers `T`, the + fallback is still allowed. The guard would be + `Exclude 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, (...args: never[]) => unknown>>` so it survives the closed, partly-optional `P` constraints. - `Parameters[0]` resolves only the **last** overload, so it is