♻️ Name the matcher factories getMatcher / getMatcherW

Drop the strict/widened aliases from src/index.ts so the factories are
exported under their own names, and align the type tests, the autocomplete
probe (which now imports the public surface from ./index.ts) and library.md
with them.

Also correct the widened-overload comment: the excess-property check does not
apply to a generic P, so MatcherWidening's keyof guard — not the closed
constraint — is what rejects keys outside T/_, as library.md already
documented.
This commit is contained in:
tmu committed 2026-09-17 21:48:54 +00:00
1 parent 05fad0fcd6
commit 800c11139d
5 files changed
+69 -84

No files matched your search

+1 -1
View File
@@ -35,7 +35,7 @@ Testing:
Matcher: Matcher:
✔ Clean up: adopt the 3-overload matcher (`src/prototype-ac2.ts`) and delete the prototypes @high @done ✔ Clean up: adopt the 3-overload matcher (`src/prototype-ac2.ts`) and delete the prototypes @high @done
→ `strict` / `widened`, each with overloads `ExhaustiveLoose` → `Fallback` → `Handlers` (order is load-bearing) → `getMatcher` / `getMatcherW`, each with overloads `ExhaustiveLoose` → `Fallback` → `Handlers` (order is load-bearing)
→ 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 ☐ `_` should receive only the unhandled `T` keys, not all of `T` @medium
→ today `_: (shape: T) => R`; desired `_: (shape: Exclude<T, handledKeys>) => R` → today `_: (shape: T) => R`; desired `_: (shape: Exclude<T, handledKeys>) => R`
+8 -7
View File
@@ -4,7 +4,8 @@ The type-level design of the public API and the limitations it carries. The
user-facing reference is [README § API](../README.md#api). user-facing reference is [README § API](../README.md#api).
The matcher below is implemented in `src/primitive.ts` and re-exported from The matcher below is implemented in `src/primitive.ts` and re-exported from
`src/index.ts`; the rest of the library is placeholder code. `src/index.ts` as `getMatcher` / `getMatcherW`; the rest of the library is
placeholder code.
## Matcher shape ## Matcher shape
@@ -13,15 +14,15 @@ The matcher below is implemented in `src/primitive.ts` and re-exported from
A matcher is built by a factory and applied to a pattern: A matcher is built by a factory and applied to a pattern:
```ts ```ts
const matcher = strict<"a" | "b">()({ a: (s) => …, b: (s) => … }); const matcher = getMatcher<"a" | "b">()({ a: (s) => …, b: (s) => … });
``` ```
Whether the pattern is exhaustive or has a fallback is decided **at the call 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 site**, by whether it carries `_` — F#'s `| _ ->`. Only the return-strictness
axis remains, so there are two factories: axis remains, so there are two factories:
- `strict` — one common `R`, the best common return type of every handler; - `getMatcher` — one common `R`, the best common return type of every handler;
- `widened` — the union of every handler's return type. - `getMatcherW` — the union of every handler's return type.
Both are three overloads whose order is load-bearing: Both are three overloads whose order is load-bearing:
@@ -43,9 +44,9 @@ Both are three overloads whose order is load-bearing:
#### Rejected #### Rejected
- **Four factories** (`src/primitive.ts` today: exhaustive × fallback × - **Four factories** (exhaustive and fallback each split by return handling).
strict/widened). The exhaustive/fallback axis is expressible as one pattern The exhaustive/fallback axis is expressible as one pattern type; four
type; four signatures duplicate it. signatures duplicate it.
- **Union merge** — one type `Exhaustive<R,T> | (Partial<…> & { _: … })`, - **Union merge** — one type `Exhaustive<R,T> | (Partial<…> & { _: … })`,
explicit `<T>()`. Type-safe and completable, but TypeScript reports the explicit `<T>()`. Type-safe and completable, but TypeScript reports the
near-miss union member, so a missing key reads `Property '_' is missing` near-miss union member, so a missing key reads `Property '_' is missing`
+1 -1
View File
@@ -1 +1 @@
export { strict, widened } from "./primitive.ts"; export { getMatcher, getMatcherW } from "./primitive.ts";
+42 -42
View File
@@ -10,15 +10,15 @@ import {
LspSession, LspSession,
} from "#test-utils/lsp-completion.ts"; } from "#test-utils/lsp-completion.ts";
import { strict, widened } from "./primitive.ts"; import { getMatcher, getMatcherW } from "./primitive.ts";
// ============================================================================ // ============================================================================
// API: strict — ✔️ Exhaustive / ✔️ ReturnsStrict // API: getMatcher — ✔️ Exhaustive / ✔️ ReturnsStrict
// ============================================================================ // ============================================================================
test("strict: exhaustive pattern infers one common return type", () => { test("getMatcher: exhaustive pattern infers one common return type", () => {
// Arrange // Arrange
const factory = strict<"a" | "b">(); const factory = getMatcher<"a" | "b">();
// Act // Act
const matcher = factory({ const matcher = factory({
@@ -41,9 +41,9 @@ test("strict: exhaustive pattern infers one common return type", () => {
assert.equal(matcher("b"), 2); assert.equal(matcher("b"), 2);
}); });
test("strict: dispatches on numeric literal keys", () => { test("getMatcher: dispatches on numeric literal keys", () => {
// Arrange // Arrange
const factory = strict<1 | 2>(); const factory = getMatcher<1 | 2>();
// Act // Act
const matcher = factory({ const matcher = factory({
@@ -63,9 +63,9 @@ test("strict: dispatches on numeric literal keys", () => {
assert.equal(matcher(2), 20); assert.equal(matcher(2), 20);
}); });
test("strict: dispatches on mixed string and numeric keys", () => { test("getMatcher: dispatches on mixed string and numeric keys", () => {
// Arrange // Arrange
const factory = strict<"a" | "b" | 1 | 2>(); const factory = getMatcher<"a" | "b" | 1 | 2>();
// Act // Act
const matcher = factory({ const matcher = factory({
@@ -96,12 +96,12 @@ test("strict: dispatches on mixed string and numeric keys", () => {
}); });
// ============================================================================ // ============================================================================
// API: strict — ❌ Exhaustive (fallback) / ✔️ ReturnsStrict // API: getMatcher — ❌ Exhaustive (fallback) / ✔️ ReturnsStrict
// ============================================================================ // ============================================================================
test("strict: a `_` fallback makes the universe keys optional", () => { test("getMatcher: a `_` fallback makes the universe keys optional", () => {
// Arrange // Arrange
const factory = strict<"a" | "b" | "c">(); const factory = getMatcher<"a" | "b" | "c">();
// Act // Act
const matcher = factory({ const matcher = factory({
@@ -125,9 +125,9 @@ test("strict: a `_` fallback makes the universe keys optional", () => {
assert.equal(matcher("c"), 2); assert.equal(matcher("c"), 2);
}); });
test("strict: a `_` fallback also accepts an exhaustive pattern", () => { test("getMatcher: a `_` fallback also accepts an exhaustive pattern", () => {
// Arrange // Arrange
const factory = strict<"a" | "b">(); const factory = getMatcher<"a" | "b">();
// Act // Act
const matcher = factory({ const matcher = factory({
@@ -147,9 +147,9 @@ test("strict: a `_` fallback also accepts an exhaustive pattern", () => {
assert.equal(matcher("b"), "B"); assert.equal(matcher("b"), "B");
}); });
test("strict: a `_` fallback routes mixed string and numeric gaps", () => { test("getMatcher: a `_` fallback routes mixed string and numeric gaps", () => {
// Arrange // Arrange
const factory = strict<"a" | "b" | 1 | 2>(); const factory = getMatcher<"a" | "b" | 1 | 2>();
// Act // Act
const matcher = factory({ const matcher = factory({
@@ -176,12 +176,12 @@ test("strict: a `_` fallback routes mixed string and numeric gaps", () => {
}); });
// ============================================================================ // ============================================================================
// API: widened — ✔️ Exhaustive / ❌ ReturnsStrict // API: getMatcherW — ✔️ Exhaustive / ❌ ReturnsStrict
// ============================================================================ // ============================================================================
test("widened: exhaustive pattern widens to the union of handler returns", () => { test("getMatcherW: exhaustive pattern widens to the union of handler returns", () => {
// Arrange // Arrange
const factory = widened<"x" | "y">(); const factory = getMatcherW<"x" | "y">();
// Act // Act
const matcher = factory({ const matcher = factory({
@@ -204,9 +204,9 @@ test("widened: exhaustive pattern widens to the union of handler returns", () =>
assert.equal(matcher("y"), "two"); assert.equal(matcher("y"), "two");
}); });
test("widened: dispatches on mixed string and numeric keys", () => { test("getMatcherW: dispatches on mixed string and numeric keys", () => {
// Arrange // Arrange
const factory = widened<"a" | "b" | 1 | 2>(); const factory = getMatcherW<"a" | "b" | 1 | 2>();
// Act // Act
const matcher = factory({ const matcher = factory({
@@ -239,12 +239,12 @@ test("widened: dispatches on mixed string and numeric keys", () => {
}); });
// ============================================================================ // ============================================================================
// API: widened — ❌ Exhaustive (fallback) / ❌ ReturnsStrict // API: getMatcherW — ❌ Exhaustive (fallback) / ❌ ReturnsStrict
// ============================================================================ // ============================================================================
test("widened: a `_` fallback widens gaps into the union", () => { test("getMatcherW: a `_` fallback widens gaps into the union", () => {
// Arrange // Arrange
const factory = widened<"x" | "y" | "z">(); const factory = getMatcherW<"x" | "y" | "z">();
// Act // Act
const matcher = factory({ const matcher = factory({
@@ -270,9 +270,9 @@ test("widened: a `_` fallback widens gaps into the union", () => {
assert.equal(matcher("z"), "fallback"); assert.equal(matcher("z"), "fallback");
}); });
test("widened: a `_` fallback widens mixed string and numeric returns", () => { test("getMatcherW: a `_` fallback widens mixed string and numeric returns", () => {
// Arrange // Arrange
const factory = widened<"a" | "b" | 1 | 2>(); const factory = getMatcherW<"a" | "b" | 1 | 2>();
// Act // Act
const matcher = factory({ const matcher = factory({
@@ -301,9 +301,9 @@ test("widened: a `_` fallback widens mixed string and numeric returns", () => {
assert.equal(matcher(2), "fallback"); assert.equal(matcher(2), "fallback");
}); });
test("widened: a `_` fallback also accepts an exhaustive pattern", () => { test("getMatcherW: a `_` fallback also accepts an exhaustive pattern", () => {
// Arrange // Arrange
const factory = widened<"x" | "y">(); const factory = getMatcherW<"x" | "y">();
// Act // Act
const matcher = factory({ const matcher = factory({
@@ -327,9 +327,9 @@ test("widened: a `_` fallback also accepts an exhaustive pattern", () => {
// Factory contracts — calls that must not compile // Factory contracts — calls that must not compile
// ============================================================================ // ============================================================================
test("strict factory rejects patterns outside its contract", () => { test("getMatcher factory rejects patterns outside its contract", () => {
// Arrange // Arrange
const factory = strict<"a" | "b">(); const factory = getMatcher<"a" | "b">();
// Act / Assert — the calls below must not compile. // Act / Assert — the calls below must not compile.
// `Parameters<typeof factory>[0]` resolves only the *last* overload, so it // `Parameters<typeof factory>[0]` resolves only the *last* overload, so it
@@ -345,9 +345,9 @@ test("strict factory rejects patterns outside its contract", () => {
factory({ a: () => 1 }); factory({ a: () => 1 });
}); });
test("widened factory rejects patterns outside its contract", () => { test("getMatcherW factory rejects patterns outside its contract", () => {
// Arrange // Arrange
const factory = widened<"x" | "y">(); const factory = getMatcherW<"x" | "y">();
// Act / Assert — the calls below must not compile // Act / Assert — the calls below must not compile
// @ts-expect-error `z` is not part of the universe `"x" | "y"` // @ts-expect-error `z` is not part of the universe `"x" | "y"`
@@ -376,14 +376,14 @@ const UNIVERSE = `"a" | "b" | "c"`;
// documents, project membership), so they pass in any order. // documents, project membership), so they pass in any order.
const labelsFor = ( const labelsFor = (
name: string, name: string,
factory: "strict" | "widened", factory: "getMatcher" | "getMatcherW",
body: string, body: string,
): Promise<readonly string[]> => { ): Promise<readonly string[]> => {
const session = new LspSession(REPO_ROOT); const session = new LspSession(REPO_ROOT);
const target: CompletionTarget = { const target: CompletionTarget = {
file: `src/__autocomplete_${name}.ts`, file: `src/__autocomplete_${name}.ts`,
source: [ source: [
`import { ${factory} } from "./primitive.ts";`, `import { ${factory} } from "./index.ts";`,
`const m = ${factory}<${UNIVERSE}>()({`, `const m = ${factory}<${UNIVERSE}>()({`,
body, body,
"});", "});",
@@ -398,10 +398,10 @@ const labelsFor = (
test("autocomplete: an exhaustive pattern requires the universe, `_` optional", () => { test("autocomplete: an exhaustive pattern requires the universe, `_` optional", () => {
// Arrange // Arrange
const name = "strict_fresh"; const name = "getMatcher_fresh";
// Act // Act
const labels = labelsFor(name, "strict", " /*COMPLETE*/"); const labels = labelsFor(name, "getMatcher", " /*COMPLETE*/");
// Assert // Assert
return labels.then((result) => { return labels.then((result) => {
@@ -411,12 +411,12 @@ test("autocomplete: an exhaustive pattern requires the universe, `_` optional",
test("autocomplete: handled keys drop out of the popup", () => { test("autocomplete: handled keys drop out of the popup", () => {
// Arrange // Arrange
const name = "strict_after_key"; const name = "getMatcher_after_key";
// Act // Act
const labels = labelsFor( const labels = labelsFor(
name, name,
"strict", "getMatcher",
" a: () => 1,\n /*COMPLETE*/", " a: () => 1,\n /*COMPLETE*/",
); );
@@ -428,12 +428,12 @@ test("autocomplete: handled keys drop out of the popup", () => {
test("autocomplete: `_` makes the remaining keys optional", () => { test("autocomplete: `_` makes the remaining keys optional", () => {
// Arrange // Arrange
const name = "strict_after_fallback"; const name = "getMatcher_after_fallback";
// Act // Act
const labels = labelsFor( const labels = labelsFor(
name, name,
"strict", "getMatcher",
" _: () => 0,\n /*COMPLETE*/", " _: () => 0,\n /*COMPLETE*/",
); );
@@ -443,12 +443,12 @@ test("autocomplete: `_` makes the remaining keys optional", () => {
}); });
}); });
test("autocomplete: `widened` offers the same popup as `strict`", () => { test("autocomplete: `getMatcherW` offers the same popup as `getMatcher`", () => {
// Arrange // Arrange
const name = "widened_fresh"; const name = "getMatcherW_fresh";
// Act // Act
const labels = labelsFor(name, "widened", " /*COMPLETE*/"); const labels = labelsFor(name, "getMatcherW", " /*COMPLETE*/");
// Assert // Assert
return labels.then((result) => { return labels.then((result) => {
+17 -33
View File
@@ -21,35 +21,27 @@ type ExhaustiveLoose<R, T extends string | number> = Handlers<R, T> & {
_?: UnaryFn<T, R>; _?: UnaryFn<T, R>;
}; };
// The `P`-constraints are deliberately *closed* (no index signature): that is // oxlint-disable typescript/unified-signatures
// what makes the excess-property check reject keys outside `T`/`_`.
type ExhaustiveShape<T extends string | number> = Handlers<unknown, T> & {
_?: UnaryFn<T, unknown>;
};
type FallbackShape<T extends string | number> = Partial<
Handlers<unknown, T>
> & {
_: UnaryFn<T, unknown>;
};
// Strict returns: one common `R`. Overload order is load-bearing: // Strict returns: one common `R`. Overload order is load-bearing:
// #1 ExhaustiveLoose -> autocomplete `_?, a, b` // #1 ExhaustiveLoose -> autocomplete `_?, a, b`
// #2 Fallback -> accepts a partial pattern // #2 Fallback -> accepts a partial pattern
// #3 Handlers (last) -> "Property 'b' is missing" is the reported error // #3 Handlers (last) -> "Property 'b' is missing" is the reported error
interface StrictFn<T extends string | number> { interface MatcherStrict<T extends string | number> {
<R>(pattern: ExhaustiveLoose<R, T>): UnaryFn<T, R>; <R>(pattern: ExhaustiveLoose<R, T>): UnaryFn<T, R>;
<R>(pattern: Fallback<R, T>): UnaryFn<T, R>; <R>(pattern: Fallback<R, T>): UnaryFn<T, R>;
<R>(pattern: Handlers<R, T>): UnaryFn<T, R>; <R>(pattern: Handlers<R, T>): UnaryFn<T, R>;
} }
// oxlint-enable typescript/unified-signatures
// Widened returns: the union of every handler's return type. `P` is the whole // Widened returns: the union of every handler's return type. `P` is inferred
// parameter (not an intersection), so its closed constraint both supplies the // from the whole parameter, whose closed constraint supplies the
// contextual/autocomplete type and enforces the excess-property check. // contextual/autocomplete type; the `keyof P` guard appended to each overload
interface WidenedFn<T extends string | number> { // rejects keys outside `T`/`_`.
<P extends ExhaustiveShape<T>>( interface MatcherWidening<T extends string | number> {
<P extends ExhaustiveLoose<unknown, T>>(
pattern: P & (keyof P extends T | "_" ? unknown : never), pattern: P & (keyof P extends T | "_" ? unknown : never),
): UnaryFn<T, PatternReturns<P>>; ): UnaryFn<T, PatternReturns<P>>;
<P extends FallbackShape<T>>( <P extends Fallback<unknown, T>>(
pattern: P & (keyof P extends T | "_" ? unknown : never), pattern: P & (keyof P extends T | "_" ? unknown : never),
): UnaryFn<T, PatternReturns<P>>; ): UnaryFn<T, PatternReturns<P>>;
<P extends Handlers<unknown, T>>( <P extends Handlers<unknown, T>>(
@@ -61,19 +53,11 @@ type HandlerMap = Record<string | number, UnaryFn<never, unknown> | undefined>;
const dispatch = const dispatch =
(pattern: HandlerMap) => (pattern: HandlerMap) =>
(shape: string | number): unknown => { (shape: string | number): unknown =>
const handler = pattern[shape] ?? pattern["_"]; // oxlint-disable-next-line typescript/no-non-null-assertion typescript/no-unsafe-type-assertion
return handler === undefined (pattern[shape] ?? pattern["_"]!)(shape as never);
? undefined
: Reflect.apply(handler, undefined, [shape]);
};
export const strict = export const getMatcher = <T extends string | number>(): MatcherStrict<T> =>
<T extends string | number>(): StrictFn<T> => dispatch;
(pattern: HandlerMap) => export const getMatcherW = <T extends string | number>(): MatcherWidening<T> =>
dispatch(pattern); dispatch;
export const widened =
<T extends string | number>(): WidenedFn<T> =>
(pattern: HandlerMap) =>
dispatch(pattern);