From 800c11139d4e0f0a21f6117566f11bbce60df3ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20M=C3=BCller?= Date: Thu, 17 Sep 2026 21:48:54 +0000 Subject: [PATCH] :recycle: Name the matcher factories getMatcher / getMatcherW MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- backlog.tasks | 2 +- development/library.md | 15 ++++---- src/index.ts | 2 +- src/primitive.test.ts | 84 +++++++++++++++++++++--------------------- src/primitive.ts | 50 +++++++++---------------- 5 files changed, 69 insertions(+), 84 deletions(-) diff --git a/backlog.tasks b/backlog.tasks index f90afa0..4593d94 100644 --- a/backlog.tasks +++ b/backlog.tasks @@ -35,7 +35,7 @@ Testing: Matcher: ✔ 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` ☐ `_` should receive only the unhandled `T` keys, not all of `T` @medium → today `_: (shape: T) => R`; desired `_: (shape: Exclude) => R` diff --git a/development/library.md b/development/library.md index 3363103..5f89e22 100644 --- a/development/library.md +++ b/development/library.md @@ -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). 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 @@ -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: ```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 site**, by whether it carries `_` — F#'s `| _ ->`. Only the return-strictness axis remains, so there are two factories: -- `strict` — one common `R`, the best common return type of every handler; -- `widened` — the union of every handler's return type. +- `getMatcher` — one common `R`, the best common return type of every handler; +- `getMatcherW` — the union of every handler's return type. Both are three overloads whose order is load-bearing: @@ -43,9 +44,9 @@ Both are three overloads whose order is load-bearing: #### Rejected -- **Four factories** (`src/primitive.ts` today: exhaustive × fallback × - strict/widened). The exhaustive/fallback axis is expressible as one pattern - type; four signatures duplicate it. +- **Four factories** (exhaustive and fallback each split by return handling). + The exhaustive/fallback axis is expressible as one pattern type; four + signatures duplicate it. - **Union merge** — one type `Exhaustive | (Partial<…> & { _: … })`, explicit `()`. Type-safe and completable, but TypeScript reports the near-miss union member, so a missing key reads `Property '_' is missing` diff --git a/src/index.ts b/src/index.ts index 1f4b91d..413625f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1 +1 @@ -export { strict, widened } from "./primitive.ts"; +export { getMatcher, getMatcherW } from "./primitive.ts"; diff --git a/src/primitive.test.ts b/src/primitive.test.ts index 6f86202..6ec1899 100644 --- a/src/primitive.test.ts +++ b/src/primitive.test.ts @@ -10,15 +10,15 @@ import { LspSession, } 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 - const factory = strict<"a" | "b">(); + const factory = getMatcher<"a" | "b">(); // Act const matcher = factory({ @@ -41,9 +41,9 @@ test("strict: exhaustive pattern infers one common return type", () => { assert.equal(matcher("b"), 2); }); -test("strict: dispatches on numeric literal keys", () => { +test("getMatcher: dispatches on numeric literal keys", () => { // Arrange - const factory = strict<1 | 2>(); + const factory = getMatcher<1 | 2>(); // Act const matcher = factory({ @@ -63,9 +63,9 @@ test("strict: dispatches on numeric literal keys", () => { assert.equal(matcher(2), 20); }); -test("strict: dispatches on mixed string and numeric keys", () => { +test("getMatcher: dispatches on mixed string and numeric keys", () => { // Arrange - const factory = strict<"a" | "b" | 1 | 2>(); + const factory = getMatcher<"a" | "b" | 1 | 2>(); // Act 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 - const factory = strict<"a" | "b" | "c">(); + const factory = getMatcher<"a" | "b" | "c">(); // Act const matcher = factory({ @@ -125,9 +125,9 @@ test("strict: a `_` fallback makes the universe keys optional", () => { assert.equal(matcher("c"), 2); }); -test("strict: a `_` fallback also accepts an exhaustive pattern", () => { +test("getMatcher: a `_` fallback also accepts an exhaustive pattern", () => { // Arrange - const factory = strict<"a" | "b">(); + const factory = getMatcher<"a" | "b">(); // Act const matcher = factory({ @@ -147,9 +147,9 @@ test("strict: a `_` fallback also accepts an exhaustive pattern", () => { 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 - const factory = strict<"a" | "b" | 1 | 2>(); + const factory = getMatcher<"a" | "b" | 1 | 2>(); // Act 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 - const factory = widened<"x" | "y">(); + const factory = getMatcherW<"x" | "y">(); // Act const matcher = factory({ @@ -204,9 +204,9 @@ test("widened: exhaustive pattern widens to the union of handler returns", () => assert.equal(matcher("y"), "two"); }); -test("widened: dispatches on mixed string and numeric keys", () => { +test("getMatcherW: dispatches on mixed string and numeric keys", () => { // Arrange - const factory = widened<"a" | "b" | 1 | 2>(); + const factory = getMatcherW<"a" | "b" | 1 | 2>(); // Act 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 - const factory = widened<"x" | "y" | "z">(); + const factory = getMatcherW<"x" | "y" | "z">(); // Act const matcher = factory({ @@ -270,9 +270,9 @@ test("widened: a `_` fallback widens gaps into the union", () => { 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 - const factory = widened<"a" | "b" | 1 | 2>(); + const factory = getMatcherW<"a" | "b" | 1 | 2>(); // Act const matcher = factory({ @@ -301,9 +301,9 @@ test("widened: a `_` fallback widens mixed string and numeric returns", () => { assert.equal(matcher(2), "fallback"); }); -test("widened: a `_` fallback also accepts an exhaustive pattern", () => { +test("getMatcherW: a `_` fallback also accepts an exhaustive pattern", () => { // Arrange - const factory = widened<"x" | "y">(); + const factory = getMatcherW<"x" | "y">(); // Act const matcher = factory({ @@ -327,9 +327,9 @@ test("widened: a `_` fallback also accepts an exhaustive pattern", () => { // Factory contracts — calls that must not compile // ============================================================================ -test("strict factory rejects patterns outside its contract", () => { +test("getMatcher factory rejects patterns outside its contract", () => { // Arrange - const factory = strict<"a" | "b">(); + const factory = getMatcher<"a" | "b">(); // Act / Assert — the calls below must not compile. // `Parameters[0]` resolves only the *last* overload, so it @@ -345,9 +345,9 @@ test("strict factory rejects patterns outside its contract", () => { factory({ a: () => 1 }); }); -test("widened factory rejects patterns outside its contract", () => { +test("getMatcherW factory rejects patterns outside its contract", () => { // Arrange - const factory = widened<"x" | "y">(); + const factory = getMatcherW<"x" | "y">(); // Act / Assert — the calls below must not compile // @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. const labelsFor = ( name: string, - factory: "strict" | "widened", + factory: "getMatcher" | "getMatcherW", body: string, ): Promise => { const session = new LspSession(REPO_ROOT); const target: CompletionTarget = { file: `src/__autocomplete_${name}.ts`, source: [ - `import { ${factory} } from "./primitive.ts";`, + `import { ${factory} } from "./index.ts";`, `const m = ${factory}<${UNIVERSE}>()({`, body, "});", @@ -398,10 +398,10 @@ const labelsFor = ( test("autocomplete: an exhaustive pattern requires the universe, `_` optional", () => { // Arrange - const name = "strict_fresh"; + const name = "getMatcher_fresh"; // Act - const labels = labelsFor(name, "strict", " /*COMPLETE*/"); + const labels = labelsFor(name, "getMatcher", " /*COMPLETE*/"); // Assert 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", () => { // Arrange - const name = "strict_after_key"; + const name = "getMatcher_after_key"; // Act const labels = labelsFor( name, - "strict", + "getMatcher", " a: () => 1,\n /*COMPLETE*/", ); @@ -428,12 +428,12 @@ test("autocomplete: handled keys drop out of the popup", () => { test("autocomplete: `_` makes the remaining keys optional", () => { // Arrange - const name = "strict_after_fallback"; + const name = "getMatcher_after_fallback"; // Act const labels = labelsFor( name, - "strict", + "getMatcher", " _: () => 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 - const name = "widened_fresh"; + const name = "getMatcherW_fresh"; // Act - const labels = labelsFor(name, "widened", " /*COMPLETE*/"); + const labels = labelsFor(name, "getMatcherW", " /*COMPLETE*/"); // Assert return labels.then((result) => { diff --git a/src/primitive.ts b/src/primitive.ts index b5a8f55..7c13a32 100644 --- a/src/primitive.ts +++ b/src/primitive.ts @@ -21,35 +21,27 @@ type ExhaustiveLoose = Handlers & { _?: UnaryFn; }; -// The `P`-constraints are deliberately *closed* (no index signature): that is -// what makes the excess-property check reject keys outside `T`/`_`. -type ExhaustiveShape = Handlers & { - _?: UnaryFn; -}; -type FallbackShape = Partial< - Handlers -> & { - _: UnaryFn; -}; - +// oxlint-disable typescript/unified-signatures // Strict returns: one common `R`. Overload order is load-bearing: // #1 ExhaustiveLoose -> autocomplete `_?, a, b` // #2 Fallback -> accepts a partial pattern // #3 Handlers (last) -> "Property 'b' is missing" is the reported error -interface StrictFn { +interface MatcherStrict { (pattern: ExhaustiveLoose): UnaryFn; (pattern: Fallback): UnaryFn; (pattern: Handlers): UnaryFn; } +// oxlint-enable typescript/unified-signatures -// Widened returns: the union of every handler's return type. `P` is the whole -// parameter (not an intersection), so its closed constraint both supplies the -// contextual/autocomplete type and enforces the excess-property check. -interface WidenedFn { -

>( +// Widened returns: the union of every handler's return type. `P` is inferred +// from the whole parameter, whose closed constraint supplies the +// contextual/autocomplete type; the `keyof P` guard appended to each overload +// rejects keys outside `T`/`_`. +interface MatcherWidening { +

>( pattern: P & (keyof P extends T | "_" ? unknown : never), ): UnaryFn>; -

>( +

>( pattern: P & (keyof P extends T | "_" ? unknown : never), ): UnaryFn>;

>( @@ -61,19 +53,11 @@ type HandlerMap = Record | undefined>; const dispatch = (pattern: HandlerMap) => - (shape: string | number): unknown => { - const handler = pattern[shape] ?? pattern["_"]; - return handler === undefined - ? undefined - : Reflect.apply(handler, undefined, [shape]); - }; + (shape: string | number): unknown => + // oxlint-disable-next-line typescript/no-non-null-assertion typescript/no-unsafe-type-assertion + (pattern[shape] ?? pattern["_"]!)(shape as never); -export const strict = - (): StrictFn => - (pattern: HandlerMap) => - dispatch(pattern); - -export const widened = - (): WidenedFn => - (pattern: HandlerMap) => - dispatch(pattern); +export const getMatcher = (): MatcherStrict => + dispatch; +export const getMatcherW = (): MatcherWidening => + dispatch;