From 165bd9e3df43515dd87cbe1c49503c6ad011e95d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20M=C3=BCller?= Date: Thu, 17 Sep 2026 20:59:09 +0000 Subject: [PATCH 1/5] :recycle: Adopt the three-overload matcher strict/widened each take the exhaustive/fallback pattern shape in three overloads ordered ExhaustiveLoose -> Fallback -> Handlers. TypeScript reads the first overload for the object-literal popup (_?, a, b) and the last for the missing-key error, so autocomplete and the error message are tuned independently. The fallback is a pattern shape, not a factory, so the four getPrimitiveUnionMatcher* factories collapse to two. src/index.ts re-exports the two factories; the prototype scratch files that explored the alternatives are removed. --- src/index.ts | 7 +- src/primitive.test.ts | 510 +++++++++++++++++++++++++----------------- src/primitive.ts | 126 ++++++----- 3 files changed, 373 insertions(+), 270 deletions(-) diff --git a/src/index.ts b/src/index.ts index 096a964..1f4b91d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1 @@ -export { - getPrimitiveUnionMatcher, - getPrimitiveUnionMatcherPartial, - getPrimitiveUnionMatcherPartialW, - getPrimitiveUnionMatcherW, -} from "./primitive.ts"; +export { strict, widened } from "./primitive.ts"; diff --git a/src/primitive.test.ts b/src/primitive.test.ts index ee9f57e..6f86202 100644 --- a/src/primitive.test.ts +++ b/src/primitive.test.ts @@ -1,50 +1,49 @@ /* oxlint-disable typescript/no-floating-promises -- expectTypeOf() is a sync type-assertion library that the type-aware linter misidentifies as a promise */ import { strict as assert } from "node:assert"; +import path from "node:path"; import { test } from "node:test"; import { expectTypeOf } from "expect-type"; import { - getPrimitiveUnionMatcher, - getPrimitiveUnionMatcherPartial, - getPrimitiveUnionMatcherPartialW, - getPrimitiveUnionMatcherW, -} from "./primitive.ts"; + type CompletionTarget, + LspSession, +} from "#test-utils/lsp-completion.ts"; + +import { strict, widened } from "./primitive.ts"; // ============================================================================ -// API: getPrimitiveUnionMatcherW — ✔️ Exhaustive / ❌ ReturnsStrict +// API: strict — ✔️ Exhaustive / ✔️ ReturnsStrict // ============================================================================ -test("getPrimitiveUnionMatcherW requires every literal key", () => { +test("strict: exhaustive pattern infers one common return type", () => { // Arrange - const factory = getPrimitiveUnionMatcherW<"a" | "b">(); + const factory = strict<"a" | "b">(); // Act const matcher = factory({ - a: (s) => { + a: (s): number => { expectTypeOf(s).toEqualTypeOf<"a">(); - return 1 as const; + return 1; }, - b: (s) => { + b: (s): 1 | 2 => { expectTypeOf(s).toEqualTypeOf<"b">(); - return "two" as const; + return 2; }, }); // Assert - // ❌ ReturnsStrict: mixed handler returns widen to their union. - expectTypeOf(matcher).toEqualTypeOf<(shape: "a" | "b") => 1 | "two">(); - // A pattern missing a key must not satisfy the parameter type. - expectTypeOf<{ - a: () => number; - }>().not.toExtend[0]>(); + // ✔️ ReturnsStrict: R is the best common return type, not a widening union. + expectTypeOf(matcher).toEqualTypeOf<(shape: "a" | "b") => number>(); + // ❌ a value outside T must not be accepted by the matcher + expectTypeOf<"c">().not.toExtend[0]>(); assert.equal(matcher("a"), 1); - assert.equal(matcher("b"), "two"); + assert.equal(matcher("b"), 2); }); -test("getPrimitiveUnionMatcherW dispatches on numeric literal keys", () => { +test("strict: dispatches on numeric literal keys", () => { // Arrange - const factory = getPrimitiveUnionMatcherW<1 | 2>(); + const factory = strict<1 | 2>(); // Act const matcher = factory({ @@ -64,9 +63,150 @@ test("getPrimitiveUnionMatcherW dispatches on numeric literal keys", () => { assert.equal(matcher(2), 20); }); -test("getPrimitiveUnionMatcherW dispatches on mixed string and numeric keys", () => { +test("strict: dispatches on mixed string and numeric keys", () => { // Arrange - const factory = getPrimitiveUnionMatcherW<"a" | "b" | 1 | 2>(); + const factory = strict<"a" | "b" | 1 | 2>(); + + // Act + const matcher = factory({ + a: (s): number => { + expectTypeOf(s).toEqualTypeOf<"a">(); + return 1; + }, + b: (s): 1 | 2 => { + expectTypeOf(s).toEqualTypeOf<"b">(); + return 2; + }, + 1: (n): number => { + expectTypeOf(n).toEqualTypeOf<1>(); + return 10; + }, + 2: (n): number => { + expectTypeOf(n).toEqualTypeOf<2>(); + return 20; + }, + }); + + // Assert + expectTypeOf(matcher).toEqualTypeOf<(shape: "a" | "b" | 1 | 2) => number>(); + assert.equal(matcher("a"), 1); + assert.equal(matcher("b"), 2); + assert.equal(matcher(1), 10); + assert.equal(matcher(2), 20); +}); + +// ============================================================================ +// API: strict — ❌ Exhaustive (fallback) / ✔️ ReturnsStrict +// ============================================================================ + +test("strict: a `_` fallback makes the universe keys optional", () => { + // Arrange + const factory = strict<"a" | "b" | "c">(); + + // Act + const matcher = factory({ + a: (s): 1 | 2 => { + expectTypeOf(s).toEqualTypeOf<"a">(); + return 1; + }, + _: (s): 1 | 2 => { + // The fallback sees the whole union, not a single literal. + expectTypeOf(s).toEqualTypeOf<"a" | "b" | "c">(); + return 2; + }, + }); + + // Assert + expectTypeOf(matcher).toEqualTypeOf<(shape: "a" | "b" | "c") => 1 | 2>(); + // ❌ a value outside T must not be accepted by the matcher + expectTypeOf<"d">().not.toExtend[0]>(); + assert.equal(matcher("a"), 1); + assert.equal(matcher("b"), 2); + assert.equal(matcher("c"), 2); +}); + +test("strict: a `_` fallback also accepts an exhaustive pattern", () => { + // Arrange + const factory = strict<"a" | "b">(); + + // Act + const matcher = factory({ + a: (s) => { + expectTypeOf(s).toEqualTypeOf<"a">(); + return "A"; + }, + b: (s) => { + expectTypeOf(s).toEqualTypeOf<"b">(); + return "B"; + }, + }); + + // Assert + expectTypeOf(matcher).toEqualTypeOf<(shape: "a" | "b") => string>(); + assert.equal(matcher("a"), "A"); + assert.equal(matcher("b"), "B"); +}); + +test("strict: a `_` fallback routes mixed string and numeric gaps", () => { + // Arrange + const factory = strict<"a" | "b" | 1 | 2>(); + + // Act + const matcher = factory({ + a: (s): string => { + expectTypeOf(s).toEqualTypeOf<"a">(); + return "A"; + }, + 1: (n): string => { + expectTypeOf(n).toEqualTypeOf<1>(); + return "one"; + }, + _: (s): string => { + expectTypeOf(s).toEqualTypeOf<"a" | "b" | 1 | 2>(); + return "fallback"; + }, + }); + + // Assert + expectTypeOf(matcher).toEqualTypeOf<(shape: "a" | "b" | 1 | 2) => string>(); + assert.equal(matcher("a"), "A"); + assert.equal(matcher("b"), "fallback"); + assert.equal(matcher(1), "one"); + assert.equal(matcher(2), "fallback"); +}); + +// ============================================================================ +// API: widened — ✔️ Exhaustive / ❌ ReturnsStrict +// ============================================================================ + +test("widened: exhaustive pattern widens to the union of handler returns", () => { + // Arrange + const factory = widened<"x" | "y">(); + + // Act + const matcher = factory({ + x: (s) => { + expectTypeOf(s).toEqualTypeOf<"x">(); + return 1 as const; + }, + y: (s) => { + expectTypeOf(s).toEqualTypeOf<"y">(); + return "two" as const; + }, + }); + + // Assert + // ❌ ReturnsStrict: mixed handler returns widen to their union. + expectTypeOf(matcher).toEqualTypeOf<(shape: "x" | "y") => 1 | "two">(); + // ❌ a value outside T must not be accepted by the matcher + expectTypeOf<"z">().not.toExtend[0]>(); + assert.equal(matcher("x"), 1); + assert.equal(matcher("y"), "two"); +}); + +test("widened: dispatches on mixed string and numeric keys", () => { + // Arrange + const factory = widened<"a" | "b" | 1 | 2>(); // Act const matcher = factory({ @@ -99,181 +239,12 @@ test("getPrimitiveUnionMatcherW dispatches on mixed string and numeric keys", () }); // ============================================================================ -// API: getPrimitiveUnionMatcher — ✔️ Exhaustive / ✔️ ReturnsStrict +// API: widened — ❌ Exhaustive (fallback) / ❌ ReturnsStrict // ============================================================================ -test("getPrimitiveUnionMatcher infers a single return type shared by all handlers", () => { +test("widened: a `_` fallback widens gaps into the union", () => { // Arrange - const factory = getPrimitiveUnionMatcher<"a" | "b">(); - - // Act - const matcher = factory({ - a: (s): number => { - expectTypeOf(s).toEqualTypeOf<"a">(); - return 1; - }, - b: (s): 1 | 2 => { - expectTypeOf(s).toEqualTypeOf<"b">(); - return 2; - }, - }); - - // Assert - // ✔️ ReturnsStrict: R is the best common return type, not a widening union. - expectTypeOf(matcher).toEqualTypeOf<(shape: "a" | "b") => number>(); - assert.equal(matcher("a"), 1); - assert.equal(matcher("b"), 2); -}); - -test("getPrimitiveUnionMatcher handlers receive the matched literal", () => { - // Arrange - const factory = getPrimitiveUnionMatcher<"on" | "off">(); - - // Act - const matcher = factory({ - on: (s) => { - expectTypeOf(s).toEqualTypeOf<"on">(); - return `handler ${s}`; - }, - off: (s) => { - expectTypeOf(s).toEqualTypeOf<"off">(); - return `handler ${s}`; - }, - }); - - // Assert - expectTypeOf(matcher).toEqualTypeOf<(shape: "on" | "off") => string>(); - assert.equal(matcher("on"), "handler on"); - assert.equal(matcher("off"), "handler off"); -}); - -test("getPrimitiveUnionMatcher infers one return type across mixed string and numeric keys", () => { - // Arrange - const factory = getPrimitiveUnionMatcher<"a" | "b" | 1 | 2>(); - - // Act - const matcher = factory({ - a: (s): number => { - expectTypeOf(s).toEqualTypeOf<"a">(); - return 1; - }, - b: (s): 1 | 2 => { - expectTypeOf(s).toEqualTypeOf<"b">(); - return 2; - }, - 1: (n): number => { - expectTypeOf(n).toEqualTypeOf<1>(); - return 10; - }, - 2: (n): number => { - expectTypeOf(n).toEqualTypeOf<2>(); - return 20; - }, - }); - - // Assert - // ✔️ ReturnsStrict: R is the best common return type, not a widening union. - expectTypeOf(matcher).toEqualTypeOf<(shape: "a" | "b" | 1 | 2) => number>(); - assert.equal(matcher("a"), 1); - assert.equal(matcher("b"), 2); - assert.equal(matcher(1), 10); - assert.equal(matcher(2), 20); -}); - -// ============================================================================ -// API: getPrimitiveUnionMatcherPartial — ❌ Exhaustive / ✔️ ReturnsStrict -// ============================================================================ - -test("getPrimitiveUnionMatcherPartial routes shapes without a handler to _", () => { - // Arrange - const factory = getPrimitiveUnionMatcherPartial<"a" | "b" | "c">(); - // Two keys, so `{ a }` lacks only the `_` fallback, nothing else. - const sparseFactory = getPrimitiveUnionMatcherPartial<"a" | "b">(); - - // Act - const matcher = factory({ - a: (s): 1 | 2 => { - expectTypeOf(s).toEqualTypeOf<"a">(); - return 1; - }, - _: (s): 1 | 2 => { - // The fallback sees the whole union, not a single literal. - expectTypeOf(s).toEqualTypeOf<"a" | "b" | "c">(); - return 2; - }, - }); - - // Assert - expectTypeOf(matcher).toEqualTypeOf<(shape: "a" | "b" | "c") => 1 | 2>(); - // ❌ Exhaustive: gaps are allowed, but only with a `_` fallback. - expectTypeOf<{ - a: () => number; - }>().not.toExtend[0]>(); - assert.equal(matcher("a"), 1); - assert.equal(matcher("b"), 2); - assert.equal(matcher("c"), 2); -}); - -test("getPrimitiveUnionMatcherPartial also accepts an exhaustive pattern", () => { - // Arrange - const factory = getPrimitiveUnionMatcherPartial<"a" | "b">(); - - // Act - const matcher = factory({ - a: (s) => { - expectTypeOf(s).toEqualTypeOf<"a">(); - return "A"; - }, - b: (s) => { - expectTypeOf(s).toEqualTypeOf<"b">(); - return "B"; - }, - }); - - // Assert - expectTypeOf(matcher).toEqualTypeOf<(shape: "a" | "b") => string>(); - assert.equal(matcher("a"), "A"); - assert.equal(matcher("b"), "B"); -}); - -test("getPrimitiveUnionMatcherPartial routes mixed string and numeric keys, gaps go to _", () => { - // Arrange - const factory = getPrimitiveUnionMatcherPartial<"a" | "b" | 1 | 2>(); - - // Act - const matcher = factory({ - a: (s): string => { - expectTypeOf(s).toEqualTypeOf<"a">(); - return "A"; - }, - 1: (n): string => { - expectTypeOf(n).toEqualTypeOf<1>(); - return "one"; - }, - _: (s): string => { - // The fallback sees the whole union, not a single literal. - expectTypeOf(s).toEqualTypeOf<"a" | "b" | 1 | 2>(); - return "fallback"; - }, - }); - - // Assert - expectTypeOf(matcher).toEqualTypeOf<(shape: "a" | "b" | 1 | 2) => string>(); - assert.equal(matcher("a"), "A"); - assert.equal(matcher("b"), "fallback"); - assert.equal(matcher(1), "one"); - assert.equal(matcher(2), "fallback"); -}); - -// ============================================================================ -// API: getPrimitiveUnionMatcherPartialW — ❌ Exhaustive / ❌ ReturnsStrict -// ============================================================================ - -test("getPrimitiveUnionMatcherPartialW allows gaps and widens to the union of handler returns", () => { - // Arrange - const factory = getPrimitiveUnionMatcherPartialW<"x" | "y" | "z">(); - // Two keys, so `{ x }` lacks only the `_` fallback, nothing else. - const sparseFactory = getPrimitiveUnionMatcherPartialW<"x" | "y">(); + const factory = widened<"x" | "y" | "z">(); // Act const matcher = factory({ @@ -292,18 +263,16 @@ test("getPrimitiveUnionMatcherPartialW allows gaps and widens to the union of ha expectTypeOf(matcher).toEqualTypeOf< (shape: "x" | "y" | "z") => 1 | "fallback" >(); - // ❌ Exhaustive: gaps are allowed, but only with a `_` fallback. - expectTypeOf<{ - x: () => number; - }>().not.toExtend[0]>(); + // ❌ a value outside T must not be accepted by the matcher + expectTypeOf<"w">().not.toExtend[0]>(); assert.equal(matcher("x"), 1); assert.equal(matcher("y"), "fallback"); assert.equal(matcher("z"), "fallback"); }); -test("getPrimitiveUnionMatcherPartialW widens mixed string and numeric key returns to their union", () => { +test("widened: a `_` fallback widens mixed string and numeric returns", () => { // Arrange - const factory = getPrimitiveUnionMatcherPartialW<"a" | "b" | 1 | 2>(); + const factory = widened<"a" | "b" | 1 | 2>(); // Act const matcher = factory({ @@ -316,7 +285,6 @@ test("getPrimitiveUnionMatcherPartialW widens mixed string and numeric key retur return 10 as const; }, _: (s) => { - // The fallback sees the whole union, not a single literal. expectTypeOf(s).toEqualTypeOf<"a" | "b" | 1 | 2>(); return "fallback" as const; }, @@ -333,9 +301,9 @@ test("getPrimitiveUnionMatcherPartialW widens mixed string and numeric key retur assert.equal(matcher(2), "fallback"); }); -test("getPrimitiveUnionMatcherPartialW also accepts an exhaustive pattern", () => { +test("widened: a `_` fallback also accepts an exhaustive pattern", () => { // Arrange - const factory = getPrimitiveUnionMatcherPartialW<"x" | "y">(); + const factory = widened<"x" | "y">(); // Act const matcher = factory({ @@ -350,8 +318,140 @@ test("getPrimitiveUnionMatcherPartialW also accepts an exhaustive pattern", () = }); // Assert - // ❌ ReturnsStrict: mixed handler returns widen to their union. expectTypeOf(matcher).toEqualTypeOf<(shape: "x" | "y") => 1 | "two">(); assert.equal(matcher("x"), 1); assert.equal(matcher("y"), "two"); }); + +// ============================================================================ +// Factory contracts — calls that must not compile +// ============================================================================ + +test("strict factory rejects patterns outside its contract", () => { + // Arrange + const factory = strict<"a" | "b">(); + + // Act / Assert — the calls below must not compile. + // `Parameters[0]` resolves only the *last* overload, so it + // is not a sound "rejected" oracle (an accepted partial pattern does not + // extend it either); the call sites are the oracle instead. + factory({ + a: () => 1, + b: () => 2, + // @ts-expect-error `c` is not part of the universe `"a" | "b"` + c: () => 3, + }); + // @ts-expect-error a gap without `_` is not exhaustive + factory({ a: () => 1 }); +}); + +test("widened factory rejects patterns outside its contract", () => { + // Arrange + const factory = widened<"x" | "y">(); + + // Act / Assert — the calls below must not compile + // @ts-expect-error `z` is not part of the universe `"x" | "y"` + factory({ + x: () => 1 as const, + y: () => 2 as const, + z: () => 3 as const, + }); + // @ts-expect-error a gap without `_` is not exhaustive + factory({ x: () => 1 as const }); +}); + +// ============================================================================ +// Autocomplete — the language server is the oracle, not the type system +// ============================================================================ + +// Completion is a contextual-type property that the type system cannot observe, +// so the cases below read the popup from the repo's language server (via the +// test helper) rather than pairing `expectTypeOf` with `assert` — see +// development/testing.md § Autocomplete. They probe the *matcher's* overloads, +// which is why they live with the matcher and not with the helper. +const REPO_ROOT = path.resolve(import.meta.dirname, ".."); +const UNIVERSE = `"a" | "b" | "c"`; + +// One session per probe: the tests share no language-server state (open +// documents, project membership), so they pass in any order. +const labelsFor = ( + name: string, + factory: "strict" | "widened", + body: string, +): Promise => { + const session = new LspSession(REPO_ROOT); + const target: CompletionTarget = { + file: `src/__autocomplete_${name}.ts`, + source: [ + `import { ${factory} } from "./primitive.ts";`, + `const m = ${factory}<${UNIVERSE}>()({`, + body, + "});", + "", + ].join("\n"), + }; + return session + .completionLabelsAt(target) + .then((result) => result.labels) + .finally(() => session.close()); +}; + +test("autocomplete: an exhaustive pattern requires the universe, `_` optional", () => { + // Arrange + const name = "strict_fresh"; + + // Act + const labels = labelsFor(name, "strict", " /*COMPLETE*/"); + + // Assert + return labels.then((result) => { + assert.deepEqual([...result], ["_?", "a", "b", "c"]); + }); +}); + +test("autocomplete: handled keys drop out of the popup", () => { + // Arrange + const name = "strict_after_key"; + + // Act + const labels = labelsFor( + name, + "strict", + " a: () => 1,\n /*COMPLETE*/", + ); + + // Assert + return labels.then((result) => { + assert.deepEqual([...result], ["_?", "b", "c"]); + }); +}); + +test("autocomplete: `_` makes the remaining keys optional", () => { + // Arrange + const name = "strict_after_fallback"; + + // Act + const labels = labelsFor( + name, + "strict", + " _: () => 0,\n /*COMPLETE*/", + ); + + // Assert + return labels.then((result) => { + assert.deepEqual([...result], ["a?", "b?", "c?"]); + }); +}); + +test("autocomplete: `widened` offers the same popup as `strict`", () => { + // Arrange + const name = "widened_fresh"; + + // Act + const labels = labelsFor(name, "widened", " /*COMPLETE*/"); + + // Assert + return labels.then((result) => { + assert.deepEqual([...result], ["_?", "a", "b", "c"]); + }); +}); diff --git a/src/primitive.ts b/src/primitive.ts index 5046949..b5a8f55 100644 --- a/src/primitive.ts +++ b/src/primitive.ts @@ -1,71 +1,79 @@ -import type { Simplify, ValueOf } from "type-fest"; +import type { ValueOf } from "type-fest"; type UnaryFn = (shape: T) => R; -// ============================================================================ -// ✔️ Exhaustive -// ❌ ReturnsStrict -// ============================================================================ -type PatternPrimitiveUnion = { - [K in T]: UnaryFn; +// `Extract` drops optional handlers (`undefined`) so `PatternReturns` also works +// when `P`'s constraint has optional keys. +type PatternReturns

= ReturnType< + Extract, (...args: never[]) => unknown> +>; + +type Handlers = { [K in T]: UnaryFn }; + +// Fallback form: `_` is a required key, the T-keys are optional. +type Fallback = Partial> & { + _: UnaryFn; }; -type PatternReturns< - P extends Record>, -> = ReturnType>; +// Completion form: T-keys required, `_` optional. Overload #1, because +// TypeScript takes the *first* overload as the contextual type for the popup. +type ExhaustiveLoose = Handlers & { + _?: UnaryFn; +}; -export const getPrimitiveUnionMatcherW: () => < - P extends PatternPrimitiveUnion, ->( - pattern: Simplify

, -) => UnaryFn> = () => (pattern) => (shape) => - // Rewrite not to use any is possible, was evaluated and solutions were - // more complex than the current solution. +// 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-next-line typescript/no-explicit-any typescript/no-unsafe-type-assertion - (pattern[shape] as any)(shape); +// 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 { + (pattern: ExhaustiveLoose): UnaryFn; + (pattern: Fallback): UnaryFn; + (pattern: Handlers): UnaryFn; +} -// ============================================================================ -// ✔️ Exhaustive -// ✔️ ReturnsStrict -// ============================================================================ -export const getPrimitiveUnionMatcher: () => ( - pattern: Simplify>, -) => UnaryFn = getPrimitiveUnionMatcherW; +// 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 { +

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

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

>( + pattern: P & (keyof P extends T | "_" ? unknown : never), + ): UnaryFn>; +} -// ============================================================================ -// ❌ Exhaustive -// ✔️ ReturnsStrict -// ============================================================================ -type PatternPrimitiveUnionPartial = - | PatternPrimitiveUnion - | (Partial> & { - _: UnaryFn; - }); +type HandlerMap = Record | undefined>; -export const getPrimitiveUnionMatcherPartial: () => < - R, ->( - pattern: Simplify>, -) => UnaryFn = () => (pattern) => (shape) => - // Rewrite not to use any is possible, was evaluated and solutions were - // more complex than the current solution. +const dispatch = + (pattern: HandlerMap) => + (shape: string | number): unknown => { + const handler = pattern[shape] ?? pattern["_"]; + return handler === undefined + ? undefined + : Reflect.apply(handler, undefined, [shape]); + }; - // oxlint-disable-next-line typescript/no-explicit-any typescript/no-unsafe-type-assertion - (pattern[shape] ?? (pattern as any)["_"])(shape); +export const strict = + (): StrictFn => + (pattern: HandlerMap) => + dispatch(pattern); -// ============================================================================ -// ❌ Exhaustive -// ❌ ReturnsStrict -// ============================================================================ -export const getPrimitiveUnionMatcherPartialW: < - T extends string | number, ->() =>

>( - // `Simplify

` is the inference hook: callers infer `P` from the argument. - // the second half pins the impl parameter's `R` to `PatternReturns

`. - // that makes the `= getPrimitiveUnionMatcherPartial` assignment type-check. - // neither half works alone. - // without the witness the union's `_` arm demands `_ ∈ keyof P`. - // without `Simplify

` the parameter types do not compare. - pattern: Simplify

& PatternPrimitiveUnionPartial, T>, -) => UnaryFn> = getPrimitiveUnionMatcherPartial; +export const widened = + (): WidenedFn => + (pattern: HandlerMap) => + dispatch(pattern); From 58d127da4e40d2082ef78cecc42517d82f5846bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20M=C3=BCller?= Date: Thu, 17 Sep 2026 20:59:19 +0000 Subject: [PATCH 2/5] :white_check_mark: Test the LSP completion helper against the server MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The helper's own contract — marker handling, requested position, label extraction and the rejection when the marker is absent — is asserted against in-memory documents whose contextual types are written inline, so the helper is tested without coupling to the library's code. --- src/util/__tests__/lsp-completion.test.ts | 116 ++++++++++++++++++++++ 1 file changed, 116 insertions(+) diff --git a/src/util/__tests__/lsp-completion.test.ts b/src/util/__tests__/lsp-completion.test.ts index fcaab51..c02f74e 100644 --- a/src/util/__tests__/lsp-completion.test.ts +++ b/src/util/__tests__/lsp-completion.test.ts @@ -1,5 +1,6 @@ /* oxlint-disable typescript/no-floating-promises -- expectTypeOf() is a sync type-assertion library that the type-aware linter misidentifies as a promise */ import { strict as assert } from "node:assert"; +import path from "node:path"; import { test } from "node:test"; import { expectTypeOf } from "expect-type"; @@ -10,6 +11,8 @@ import { LspSession, } from "#test-utils/lsp-completion.ts"; +const REPO_ROOT = path.resolve(import.meta.dirname, "../../.."); + // The `#test-utils/*` self-reference (package.json#imports) is the one route // scattered test files take to reach test helpers; this pins that it resolves // and types without starting a language server @@ -30,3 +33,116 @@ test("test-helper home: `#test-utils/…` resolves to the helper and types it", assert.equal(typeof LspSession, "function"); assert.equal(target.file, "src/util/__tests__/lsp-completion.ts"); }); + +// The helper drives a language server, so its contract is asserted against the +// server. Every document below is probed from memory and carries its +// contextual type inline, so the helper is tested without the library's code. +const probe = (source: string, marker?: string): Promise => { + const session = new LspSession(REPO_ROOT); + const target: CompletionTarget = + marker === undefined + ? { file: "src/__probe.ts", source } + : { file: "src/__probe.ts", source, marker }; + return session.completionLabelsAt(target).finally(() => session.close()); +}; + +const HANDLERS = [ + "type Handlers = { a: () => number; b: () => number; _?: () => number };", + "declare const apply: (handlers: Handlers) => Handlers;", +]; + +test("helper: a fresh object literal completes with its contextual keys", () => { + // Arrange + const source = [ + ...HANDLERS, + "const done = apply({", + " /*COMPLETE*/", + "});", + "export { done };", + ].join("\n"); + + // Act + const probed = probe(source); + + // Assert — the position is the marker's, which the helper strips + expectTypeOf(probed).toEqualTypeOf>(); + return probed.then((result) => { + assert.deepEqual(result.position, { line: 3, character: 4 }); + assert.deepEqual([...result.labels], ["_?", "a", "b"]); + }); +}); + +test("helper: a handled key drops out of the popup", () => { + // Arrange + const source = [ + ...HANDLERS, + "const done = apply({", + " b: () => 1,", + " /*COMPLETE*/", + "});", + "export { done };", + ].join("\n"); + + // Act + const probed = probe(source); + + // Assert + expectTypeOf().toEqualTypeOf< + readonly string[] + >(); + return probed.then((result) => { + assert.deepEqual([...result.labels], ["_?", "a"]); + }); +}); + +test("helper: a custom marker is located at the line start", () => { + // Arrange + const source = [ + "type Handlers = { a: () => number; _?: () => number };", + "declare const apply: (handlers: Handlers) => Handlers;", + "const done = apply({", + "@@@", + "});", + "export { done };", + ].join("\n"); + + // Act + const probed = probe(source, "@@@"); + + // Assert + expectTypeOf(probed).resolves.toEqualTypeOf(); + return probed.then((result) => { + assert.deepEqual(result.position, { line: 3, character: 0 }); + assert.deepEqual([...result.labels], ["_?", "a"]); + }); +}); + +test("helper: labels are read from the server, not from a pattern literal", () => { + // Arrange + const source = [ + 'const text = "x";', + "const upper = text./*COMPLETE*/;", + "export { upper };", + ].join("\n"); + + // Act + const probed = probe(source); + + // Assert + expectTypeOf(probed).resolves.toHaveProperty("labels"); + return probed.then((result) => { + assert.ok(result.labels.includes("toUpperCase")); + }); +}); + +test("helper: a source without the marker rejects", () => { + // Arrange + const source = "const text = 1;\nexport { text };\n"; + + // Act + const probed = probe(source); + + // Assert + expectTypeOf(probed).resolves.toEqualTypeOf(); + return assert.rejects(probed, /marker not found/); +}); From 05fad0fcd6957848df5f1dbc5e136d0ba39c61f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20M=C3=BCller?= Date: Thu, 17 Sep 2026 20:59:28 +0000 Subject: [PATCH 3/5] :memo: Record the autocomplete and matcher-design decisions The matcher is now two three-overload factories; library.md documents why the union merge, inferred universe, conditional RequireKeys and cases-first paths were rejected, and lists the two open issues (the fallback sees all of T; a redundant _ is still accepted). testing.md records the language server as the autocomplete oracle, and CONTRIBUTING points the exception at the matcher's own test file. The backlog marks the design-doc and autocomplete groundwork done alongside the adoption. --- CONTRIBUTING.md | 5 +++ backlog.tasks | 16 ++++++++ development/library.md | 88 +++++++++++++++++++++++++++++++++++++++++- development/testing.md | 63 +++++++++++++++++++++++++++--- 4 files changed, 166 insertions(+), 6 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2063fad..c9a882c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -67,6 +67,11 @@ before the implementation. The loop is **type → red → green → refactor**: `npm run verify` as the definition-of-done gate. Every test pairs an `expectTypeOf(...)` with an `assert.*`; keep them together. +The autocomplete tests (`src/util/__tests__/lsp-completion.test.ts` for the +helper, `src/primitive.test.ts` for the matcher's popup) are the +exception — +the language server, not the type system, is the oracle (see +[development/testing.md § Autocomplete](./development/testing.md#autocomplete)). Each test body follows **AAA (Arrange–Act–Assert)** with labeled blocks separated by a blank line: `// Arrange` sets up the inputs (e.g. the matcher factory), `// Act` exercises the subject once from them (not a second diff --git a/backlog.tasks b/backlog.tasks index 448a052..f90afa0 100644 --- a/backlog.tasks +++ b/backlog.tasks @@ -27,6 +27,21 @@ v1.0: ☐ Achieve 100% branch coverage on `src/primitive.ts` ☐ Achieve 100% branch coverage on `src/index.ts` +Testing: +✔ Cover autocomplete with real completion test cases in the suite @medium @done + → `src/util/__tests__/lsp-completion.ts` (`#test-utils/…`) is the helper; the suite asserts its labels (see development/testing.md § Autocomplete) + → wire it into `node --test` so a test asserts the offered labels + → note: `Parameters[0]` resolves only the *last* overload; use `@ts-expect-error` call sites for factory negatives, not `not.toExtend>` + +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) + → 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` +☐ An exhaustive pattern that also carries `_` must be a compile error @medium + → `{ a, b, _ }` for `T = "a" | "b"` is accepted today; the redundant `_` should be rejected + Bugs: Enhancements: @@ -41,6 +56,7 @@ Documentation: ☐ Add comparison section vs. other TS pattern-matching libs in Readme.md ☐ Write migration guide for users coming from discriminated unions ☐ Create backlog tasks for implementation +✔ Document the matcher design paths in `development/` — union vs overload merge, inferred universe (`NoInfer`), conditional `RequireKeys`, cases-first — and why each was abandoned @done ☐ Validate code fences in Markdown (start with README.md) — compile the TypeScript examples against `src/` so the docs cannot drift from the API Workflow: diff --git a/development/library.md b/development/library.md index 280e467..3363103 100644 --- a/development/library.md +++ b/development/library.md @@ -3,4 +3,90 @@ The type-level design of the public API and the limitations it carries. The user-facing reference is [README § API](../README.md#api). -Currently the library is placeholder code. +The matcher below is implemented in `src/primitive.ts` and re-exported from +`src/index.ts`; the rest of the library is placeholder code. + +## Matcher shape + +#### Decision (2026-09) + +A matcher is built by a factory and applied to a pattern: + +```ts +const matcher = strict<"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. + +Both are three overloads whose order is load-bearing: + +1. `ExhaustiveLoose` = `{ [K in T]: UnaryFn } & { _?: UnaryFn }` +2. `Fallback` = `Partial<…> & { _: UnaryFn }` +3. `Handlers` — the pure exhaustive shape + +#### Why + +- **Autocomplete reads the first overload, the error reads the last.** + TypeScript takes the first overload signature as the contextual type for the + object-literal popup, and the last for `No overload matches this call`. So + `ExhaustiveLoose` first yields the popup `_?, a, b` (T-keys required, `_` + optional) while `Handlers` last yields `Property 'b' is missing`. The two can be + tuned independently. +- Two factories, not four: the fallback is a pattern _shape_, not a separate API. +- The widened return union is derived from the pattern's handler types, so it + needs no fourth signature. + +#### 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. +- **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` + instead of naming the key. Arm order does not change the report; the overload + split does. +- **Overload merge with only the exhaustive arm last.** Fixes the missing-key + message, but a wrong `_` parameter is then reported against the exhaustive + arm, and `Parameters` sees only one arm. +- **Inferred universe** — `match(pattern)` with `T` taken from the keys + (exhaustive) or from `_`'s annotated parameter (fallback), via `NoInfer` and + `_?: never`, split by overloads (a plain union merges inference; measured + `T = "_" | "a"`). No explicit ``, and pipe-friendly. Rejected because: with + no declared universe the exhaustive popup offers only `_`; an unannotated `_` + widens `T` to `string | number`; and `NoInfer` leaks into the emitted `.d.ts`, + raising the consumer floor to TypeScript 5.4 (README promises `>= 5.0`). +- **Conditional `RequireKeys`** — parameter + `P & ("_" extends keyof P ? unknown : Handlers)`. Gives the good + missing-key message, but `keyof P` counts _optional_ keys: a widened value + whose declared type has `_?:` bypasses the completeness check. Demanding a + required `_` instead rejects that case but breaks `P` inference — `P` falls back + to its constraint and partial literals then demand every key. Typos also need a + `NoExtra` guard, whose message degrades to `not assignable to never`. +- **Cases-first curried** — `match(["a", "b"])({ a: …, b: … })`. Completion works + for exhaustive patterns, and the array is a single source of truth for the + runtime list and the union. Rejected as not pipe-friendly; it needs a runtime + array; and the single-call form `match(cases, pattern)` cannot infer `R` (the + mapped key type `K[number]` stays deferred, so `R` widens to `unknown`). + +#### Known issue + +- The `_` handler receives **all** of `T`, not the unhandled subset + (`Exclude`). +- An exhaustive pattern that also carries `_` is accepted; the redundant `_` + should be a compile error. +- The widened overloads carry a completeness guard + `keyof P extends T | "_" ? unknown : never`, because TypeScript does not apply + the excess-property check to a generic constraint: a generic parameter accepts + extra keys, a parameter typed as a concrete object type does not. + `PatternReturns` must be + `ReturnType, (...args: never[]) => unknown>>` so it survives + the closed, partly-optional `P` constraints. +- `Parameters[0]` resolves only the **last** overload, so it is + not a sound "rejected" oracle for a factory. Factory-negative tests use + `@ts-expect-error` call sites (the test file only — the general ban stands). diff --git a/development/testing.md b/development/testing.md index 7b6629e..ca85c48 100644 --- a/development/testing.md +++ b/development/testing.md @@ -30,7 +30,8 @@ before the implementation. - Testing the type only: it would not catch handler dispatch or the `_` fallback (see `src/primitive.test.ts`). -The runner is `node --test --strip-types "src/**/*.test.ts"` and the tiers are in +The runner is `node --test --strip-types` over `src/**/*.test.ts` and +`scripts/**/*.test.ts`; the tiers are in [CONTRIBUTING.md § Development commands](../CONTRIBUTING.md#development-commands). `c8` uses V8 coverage, so the `--strip-types` source is instrumented without a build step, and the runner relies on the `.ts` import-extension convention (see @@ -96,6 +97,10 @@ import { LspSession } from "#test-utils/lsp-completion.ts"; - The helper uses `node:` builtins (it drives a language server), so `import/no-nodejs-modules` is off for `src/util/__tests__/**` in `.oxlintrc.json` — the same exception the old `scripts/**` scope carried. +- Helpers carry no library coupling: their tests probe in-memory documents + whose contextual types are written inline, so only the helper's own contract + (marker handling, position, label extraction) is under test. A test that + asserts a _matcher's_ popup belongs with the matcher. #### Rejected @@ -111,11 +116,59 @@ import { LspSession } from "#test-utils/lsp-completion.ts"; still guards non-test helpers importing each other, and the exemption is only needed for the one specifier the mapping already solves cleanly. +## Autocomplete + +#### Decision (2026-09) + +Completion is verified by driving the repo's own language server +(`tsc --lsp --stdio`, the same server pi's LSP extension talks to) through +the test helper `#test-utils/lsp-completion.ts` +(`src/util/__tests__/lsp-completion.ts`), not through the type system: + +```sh +node --strip-types src/util/__tests__/lsp-completion.ts [] +``` + +The script prints the labels the server offers at a `/*COMPLETE*/` marker inside +`` (the marker is stripped before the document is sent). Its `LspSession` +is imported by `src/util/__tests__/lsp-completion.test.ts` — which tests the +helper itself against inline documents, never the library's code — and by +`src/primitive.test.ts`, where the same probe asserts the matcher's popup; +the CLI is for manual inspection. + +#### Why + +- Completion is a contextual-type property: it depends on which overload + signature TypeScript picks for the object literal, and no type-level assertion + observes that. +- `Parameters[0]` resolves only the _last_ overload, so it is + not the popup's contextual type either — see + [library.md § Matcher shape](./library.md#matcher-shape). +- The server is the only ground truth; the script reproduces what the editor + shows. + +#### Rejected + +- **`expect-type` would not work**: there is no operator for “the popup offers + these labels”. `toExtend` / `toEqualTypeOf` test assignability and cannot say + which overload supplied the contextual type. +- **Checking by hand in the editor**: not reproducible in review or by an agent. +- **`@ts-expect-error` at a completion position**: it asserts the absence of a + compile error, not the presence of specific labels. + +#### Known issue + +- Each test spawns its own `tsc` server so the tests share no state and pass in + any order; the file is an integration test (~1.6 s) that needs `node_modules`. + `didOpen` is handled in order before the completion request, so no settle + delay is needed. +- The server answers some requests with a string id (`client/registerCapability`); + the client must tolerate `string | number` ids or the server stalls. + ## Known issues - The type-aware linter misidentifies `expectTypeOf()` as a floating promise, so - test files that use it (`src/primitive.test.ts`) carry a - file-level `oxlint-disable -typescript/no-floating-promises` with an explanatory comment. It is a known - false positive, not a rule worth disabling project-wide (see + test files that use it (`src/primitive.test.ts`) carry a file-level + `oxlint-disable typescript/no-floating-promises` with an explanatory comment. + It is a known false positive, not a rule worth disabling project-wide (see [tooling.md § oxlint-disable directives live next to the code](./tooling.md#oxlint-disable-directives-live-next-to-the-code)). 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 4/5] :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; From 88159296d4513773a26fd28225b870aaf5fd0a8c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20M=C3=BCller?= Date: Thu, 17 Sep 2026 21:55:41 +0000 Subject: [PATCH 5/5] :memo: Drop the stale scripts test glob from testing.md No tests live under scripts/, and every test script (test, test:unit, test:ci, watch:test) globs only src/**/*.test.ts, so state that. The scripts/** oxlint scope it was confused with still exists and carries the same import/no-nodejs-modules exception as the test-helper scope. --- development/testing.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/development/testing.md b/development/testing.md index ca85c48..edcd407 100644 --- a/development/testing.md +++ b/development/testing.md @@ -30,8 +30,8 @@ before the implementation. - Testing the type only: it would not catch handler dispatch or the `_` fallback (see `src/primitive.test.ts`). -The runner is `node --test --strip-types` over `src/**/*.test.ts` and -`scripts/**/*.test.ts`; the tiers are in +The runner is `node --test --strip-types "src/**/*.test.ts"` and the tiers are +in [CONTRIBUTING.md § Development commands](../CONTRIBUTING.md#development-commands). `c8` uses V8 coverage, so the `--strip-types` source is instrumented without a build step, and the runner relies on the `.ts` import-extension convention (see @@ -96,7 +96,7 @@ import { LspSession } from "#test-utils/lsp-completion.ts"; loader needed. - The helper uses `node:` builtins (it drives a language server), so `import/no-nodejs-modules` is off for `src/util/__tests__/**` in - `.oxlintrc.json` — the same exception the old `scripts/**` scope carried. + `.oxlintrc.json` — the same exception the `scripts/**` scope carries. - Helpers carry no library coupling: their tests probe in-memory documents whose contextual types are written inline, so only the helper's own contract (marker handling, position, label extraction) is under test. A test that