✨ 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.
This commit is contained in:
tmu committed 2026-09-20 22:05:40 +00:00
1 parent 7e7f716424
commit f27966e803
5 files changed
+65 -14

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]
- reject a fallback when the handler map already covers the universe
## [0.4.0] - 2026-09-20
- move the `_` fallback out of handlers
+2 -2
View File
@@ -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<T, keyof handlers>`
☐ 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
+14 -10
View File
@@ -31,8 +31,9 @@ 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.
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
@@ -41,6 +42,14 @@ Each factory is two overloads whose order is load-bearing:
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.
@@ -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<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.
+22
View File
@@ -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<string>();
// 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<string>();
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);
});
// ============================================================================
+25 -2
View File
@@ -21,6 +21,21 @@ type Fallback<T extends string | number, Handled, R> = 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 string | number, Handled> = 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<T extends string | number, Handled, R> = UnaryFn<
// #2 Fallback (last) -> accepts a partial handler map plus a fallback
interface MatcherStrict<T extends string | number> {
<R>(handlers: Handlers<T, R>): UnaryFn<T, R>;
<R, Handled extends Exact<Partial<Handlers<T, R>>, Handled>>(
<
R,
Handled extends Exact<Partial<Handlers<T, R>>, Handled> &
MustBePartial<T, Handled>,
>(
handlers: Handled & Partial<Handlers<T, R>>,
fallback: Fallback<T, Handled, R>,
): UnaryFn<T, R>;
@@ -46,7 +65,11 @@ interface MatcherWidening<T extends string | number> {
<P extends Exact<Handlers<T, unknown>, P>>(
handlers: P,
): UnaryFn<T, PatternReturns<P>>;
<R, Handled extends Exact<Partial<Handlers<T, unknown>>, Handled>>(
<
R,
Handled extends Exact<Partial<Handlers<T, unknown>>, Handled> &
MustBePartial<T, Handled>,
>(
handlers: Handled,
fallback: Fallback<T, Handled, R>,
): UnaryFn<T, PatternReturns<Handled> | R>;