🔀 Merge chore/spike-redundant-fallback into main
This commit is contained in:
commit
c32fe08c73
5 files changed
+65
-14
No files matched your search
@@ -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
@@ -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
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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>;
|
||||||
|
|||||||
Reference in new issue
Block a user