2 Commits
Author SHA1 Message Date
tmu c32fe08c73 🔀 Merge chore/spike-redundant-fallback into main
CI / release-gate (push) Successful in 3s
CI / build (push) Successful in 24s
CI / publish (push) Skipped
CI / maintain (push) Failing after 16s
2026-09-20 22:08:35 +00:00
tmu f27966e803 ✨ 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.
2026-09-20 22:05:40 +00:00
5 changed files with 65 additions and 14 deletions

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]
- reject a fallback when the handler map already covers the universe
## [0.4.0] - 2026-09-20 ## [0.4.0] - 2026-09-20
- move the `_` fallback out of handlers - 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` → 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 ✔ `_` 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>` → 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 ✔ A fallback for an already-exhaustive handler map must be a compile error @medium @done
→ `(handlers, fallback)` with `handlers` covering all of `T` is accepted; the redundant fallback should be rejected (currying would allow the guard) → 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: 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
+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 1. `Handlers<T, R>` — the exhaustive form, and the contextual type of the
handler-map popup; handler-map popup;
2. `Handled extends Exact<Partial<Handlers<T, R>>, Handled>` plus 2. `Handled extends Exact<Partial<Handlers<T, R>>, Handled>` intersected with
`Fallback<T, Handled, R>` — a partial handler map plus the fallback. `MustBePartial<T, Handled>`, plus `Fallback<T, Handled, R>` — a partial
handler map plus the fallback, rejected when the map already covers `T`.
#### Why #### 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 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 argument is contextually typed from inference on an earlier one, so the split
is what makes the remainder expressible. 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 - **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 (`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. 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 - **Single-object `_`** (the former shape). `_` sees only all of `T`; the
remainder is not expressible there, and an exhaustive map plus `_` was remainder is not expressible there, and an exhaustive map plus `_` was
accepted. accepted.
- **Curried handlers-first** — `(handlers)(fallback)`. `Handled` is fixed before - **Curried handlers-first** — `(handlers)(fallback)`. Rejected: two calls for
the second call, so a redundant-fallback guard would work. Rejected: two calls the common case. It is not needed for the redundant-fallback guard, which the
for the common case. F-bounded constraint already provides (see Why).
- **`this` / HKT self-reference.** `this` is post-construction (method bodies, - **`this` / HKT self-reference.** `this` is post-construction (method bodies,
return positions); a parameter's contextual type is pre-construction. return positions); a parameter's contextual type is pre-construction.
`keyof this` in an interface method is the interface, not the literal. `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 #### 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 - `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.
+22
View File
@@ -309,6 +309,26 @@ test("getMatcher factory rejects patterns outside its contract", () => {
factory({ a: () => 1 }); factory({ a: () => 1 });
// @ts-expect-error a `string` fallback does not fit the `number` handler // @ts-expect-error a `string` fallback does not fit the `number` handler
factory({ a: () => 1 }, () => "x"); 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", () => { 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 // @ts-expect-error a gap without a `_` fallback is not exhaustive
factory({ x: () => 1 as const }); 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 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, // 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 // so `Exact` restores it for the generic forms: a handler map can otherwise
// carry keys outside `T`. // 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 // #2 Fallback (last) -> accepts a partial handler map plus a fallback
interface MatcherStrict<T extends string | number> { interface MatcherStrict<T extends string | number> {
<R>(handlers: Handlers<T, R>): UnaryFn<T, R>; <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>>, handlers: Handled & Partial<Handlers<T, R>>,
fallback: Fallback<T, Handled, R>, fallback: Fallback<T, Handled, R>,
): UnaryFn<T, R>; ): UnaryFn<T, R>;
@@ -46,7 +65,11 @@ interface MatcherWidening<T extends string | number> {
<P extends Exact<Handlers<T, unknown>, P>>( <P extends Exact<Handlers<T, unknown>, P>>(
handlers: P, handlers: P,
): UnaryFn<T, PatternReturns<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, handlers: Handled,
fallback: Fallback<T, Handled, R>, fallback: Fallback<T, Handled, R>,
): UnaryFn<T, PatternReturns<Handled> | R>; ): UnaryFn<T, PatternReturns<Handled> | R>;