From f27966e803d13308a2ed38ffc04998bc6ea81f75 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20M=C3=BCller?= Date: Sun, 20 Sep 2026 21:56:06 +0000 Subject: [PATCH] :sparkles: Reject a fallback for an exhaustive map A fallback paired with a handler map that already covers `T` was still accepted, even though its remainder is empty. Fold the guard into `Handled`'s own (F-bounded) constraint so it is checked after inference; a conditional in the fallback parameter is evaluated while `Handled` is still its constraint and rejects every partial map whose handler callbacks need contextual typing. --- CHANGELOG.md | 2 ++ backlog.tasks | 4 ++-- development/library.md | 24 ++++++++++++++---------- src/primitive.test.ts | 22 ++++++++++++++++++++++ src/primitive.ts | 27 +++++++++++++++++++++++++-- 5 files changed, 65 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cc44c20..fc485b4 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] +- reject a fallback when the handler map already covers the universe + ## [0.4.0] - 2026-09-20 - move the `_` fallback out of handlers diff --git a/backlog.tasks b/backlog.tasks index d162e65..e409cd7 100644 --- a/backlog.tasks +++ b/backlog.tasks @@ -44,8 +44,8 @@ Matcher: → fold into `src/primitive.ts` / the public API; drop `src/prototype*.ts` ✔ `_` 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) +✔ A fallback for an already-exhaustive handler map must be a compile error @medium @done + → rejected by an F-bounded constraint on `Handled` (checked *after* inference); a conditional in the fallback parameter is evaluated too early and breaks contextual typing Bugs: ✔ TS 7 LSP server logs `context canceled` on stderr at shutdown @done diff --git a/development/library.md b/development/library.md index 4f71594..f8650b9 100644 --- a/development/library.md +++ b/development/library.md @@ -31,8 +31,9 @@ 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>` plus - `Fallback` — a partial handler map plus the fallback. +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 @@ -41,6 +42,14 @@ Each factory is two overloads whose order is load-bearing: 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. @@ -57,9 +66,9 @@ Each factory is two overloads whose order is load-bearing: - **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. +- **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. @@ -76,11 +85,6 @@ Each factory is two overloads whose order is load-bearing: #### Known issue -- 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. diff --git a/src/primitive.test.ts b/src/primitive.test.ts index 3c445fe..2953883 100644 --- a/src/primitive.test.ts +++ b/src/primitive.test.ts @@ -309,6 +309,26 @@ test("getMatcher factory rejects patterns outside its contract", () => { factory({ a: () => 1 }); // @ts-expect-error a `string` fallback does not fit the `number` handler factory({ a: () => 1 }, () => "x"); + // @ts-expect-error a fallback is redundant once the map covers the universe + factory({ a: () => 1, b: () => 2 }, () => 0); +}); + +test("getMatcher: an open universe keeps the fallback's remainder open", () => { + // Arrange + const factory = getMatcher(); + + // Act + const matcher = factory({ a: () => 1 }, (s) => { + // The map's literal keys do not close an open universe, so the + // remainder stays `string` and the fallback is not redundant. + expectTypeOf(s).toEqualTypeOf(); + return 2 as const; + }); + + // Assert + expectTypeOf(matcher).toEqualTypeOf<(shape: string) => number>(); + assert.equal(matcher("a"), 1); + assert.equal(matcher("b"), 2); }); test("getMatcherW factory rejects patterns outside its contract", () => { @@ -324,6 +344,8 @@ test("getMatcherW factory rejects patterns outside its contract", () => { }); // @ts-expect-error a gap without a `_` fallback is not exhaustive factory({ x: () => 1 as const }); + // @ts-expect-error a fallback is redundant once the map covers the universe + factory({ x: () => 1 as const, y: () => 2 as const }, () => 0); }); // ============================================================================ diff --git a/src/primitive.ts b/src/primitive.ts index 74f6c34..b23c5c3 100644 --- a/src/primitive.ts +++ b/src/primitive.ts @@ -21,6 +21,21 @@ type Fallback = UnaryFn< R >; +// A fallback is redundant once the handler map covers `T`. The guard is folded +// into `Handled`'s own (self-referential) constraint so it is checked *after* +// inference; a conditional in the fallback's parameter type is evaluated while +// `Handled` is still its constraint and would reject context-sensitive partial +// maps. That placement also fixes where the diagnostic lands: the constraint +// failure is reported on the argument that inferred `Handled` (the handler +// map), so the required property is spelled as the message instead of relying +// on its position. See development/library.md. +interface RedundantFallback { + readonly "every case is already handled, so the fallback is redundant": never; +} +type MustBePartial = T extends keyof Handled + ? RedundantFallback + : unknown; + // TypeScript does not apply the excess-property check to a generic constraint, // so `Exact` restores it for the generic forms: a handler map can otherwise // carry keys outside `T`. @@ -31,7 +46,11 @@ type Fallback = UnaryFn< // #2 Fallback (last) -> accepts a partial handler map plus a fallback interface MatcherStrict { (handlers: Handlers): UnaryFn; - >, Handled>>( + < + R, + Handled extends Exact>, Handled> & + MustBePartial, + >( handlers: Handled & Partial>, fallback: Fallback, ): UnaryFn; @@ -46,7 +65,11 @@ interface MatcherWidening {

, P>>( handlers: P, ): UnaryFn>; - >, Handled>>( + < + R, + Handled extends Exact>, Handled> & + MustBePartial, + >( handlers: Handled, fallback: Fallback, ): UnaryFn | R>;