diff --git a/src/primitive.test.ts b/src/primitive.test.ts index 6ec1899..7ed81df 100644 --- a/src/primitive.test.ts +++ b/src/primitive.test.ts @@ -99,22 +99,24 @@ test("getMatcher: dispatches on mixed string and numeric keys", () => { // API: getMatcher — ❌ Exhaustive (fallback) / ✔️ ReturnsStrict // ============================================================================ -test("getMatcher: a `_` fallback makes the universe keys optional", () => { +test("getMatcher: a `_` fallback receives the unhandled keys", () => { // Arrange const factory = getMatcher<"a" | "b" | "c">(); // Act - const matcher = factory({ - a: (s): 1 | 2 => { - expectTypeOf(s).toEqualTypeOf<"a">(); - return 1; + 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">(); + (s): 1 | 2 => { + // The fallback sees only the keys `a` did not handle. + expectTypeOf(s).toEqualTypeOf<"b" | "c">(); return 2; }, - }); + ); // Assert expectTypeOf(matcher).toEqualTypeOf<(shape: "a" | "b" | "c") => 1 | 2>(); @@ -125,47 +127,27 @@ test("getMatcher: a `_` fallback makes the universe keys optional", () => { assert.equal(matcher("c"), 2); }); -test("getMatcher: a `_` fallback also accepts an exhaustive pattern", () => { - // Arrange - const factory = getMatcher<"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("getMatcher: a `_` fallback routes mixed string and numeric gaps", () => { // Arrange const factory = getMatcher<"a" | "b" | 1 | 2>(); // Act - const matcher = factory({ - a: (s): string => { - expectTypeOf(s).toEqualTypeOf<"a">(); - return "A"; + const matcher = factory( + { + a: (s): string => { + expectTypeOf(s).toEqualTypeOf<"a">(); + return "A"; + }, + 1: (n): string => { + expectTypeOf(n).toEqualTypeOf<1>(); + return "one"; + }, }, - 1: (n): string => { - expectTypeOf(n).toEqualTypeOf<1>(); - return "one"; - }, - _: (s): string => { - expectTypeOf(s).toEqualTypeOf<"a" | "b" | 1 | 2>(); + (s): string => { + expectTypeOf(s).toEqualTypeOf<"b" | 2>(); return "fallback"; }, - }); + ); // Assert expectTypeOf(matcher).toEqualTypeOf<(shape: "a" | "b" | 1 | 2) => string>(); @@ -247,17 +229,19 @@ test("getMatcherW: a `_` fallback widens gaps into the union", () => { const factory = getMatcherW<"x" | "y" | "z">(); // Act - const matcher = factory({ - x: (s) => { - expectTypeOf(s).toEqualTypeOf<"x">(); - return 1 as const; + const matcher = factory( + { + x: (s) => { + expectTypeOf(s).toEqualTypeOf<"x">(); + return 1 as const; + }, }, - _: (s) => { - // The fallback sees the whole union, not a single literal. - expectTypeOf(s).toEqualTypeOf<"x" | "y" | "z">(); + (s) => { + // The fallback sees only the keys `x` did not handle. + expectTypeOf(s).toEqualTypeOf<"y" | "z">(); return "fallback" as const; }, - }); + ); // Assert expectTypeOf(matcher).toEqualTypeOf< @@ -275,20 +259,22 @@ test("getMatcherW: a `_` fallback widens mixed string and numeric returns", () = const factory = getMatcherW<"a" | "b" | 1 | 2>(); // Act - const matcher = factory({ - a: (s) => { - expectTypeOf(s).toEqualTypeOf<"a">(); - return "A" as const; + const matcher = factory( + { + a: (s) => { + expectTypeOf(s).toEqualTypeOf<"a">(); + return "A" as const; + }, + 1: (n) => { + expectTypeOf(n).toEqualTypeOf<1>(); + return 10 as const; + }, }, - 1: (n) => { - expectTypeOf(n).toEqualTypeOf<1>(); - return 10 as const; - }, - _: (s) => { - expectTypeOf(s).toEqualTypeOf<"a" | "b" | 1 | 2>(); + (s) => { + expectTypeOf(s).toEqualTypeOf<"b" | 2>(); return "fallback" as const; }, - }); + ); // Assert // ❌ ReturnsStrict: mixed handler returns widen to their union. @@ -301,28 +287,6 @@ test("getMatcherW: a `_` fallback widens mixed string and numeric returns", () = assert.equal(matcher(2), "fallback"); }); -test("getMatcherW: a `_` fallback also accepts an exhaustive pattern", () => { - // Arrange - const factory = getMatcherW<"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 - 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 // ============================================================================ @@ -341,7 +305,7 @@ test("getMatcher factory rejects patterns outside its contract", () => { // @ts-expect-error `c` is not part of the universe `"a" | "b"` c: () => 3, }); - // @ts-expect-error a gap without `_` is not exhaustive + // @ts-expect-error a gap without a `_` fallback is not exhaustive factory({ a: () => 1 }); }); @@ -350,13 +314,13 @@ test("getMatcherW factory rejects patterns outside its contract", () => { 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"` factory({ x: () => 1 as const, y: () => 2 as const, + // @ts-expect-error `z` is not part of the universe `"x" | "y"` z: () => 3 as const, }); - // @ts-expect-error a gap without `_` is not exhaustive + // @ts-expect-error a gap without a `_` fallback is not exhaustive factory({ x: () => 1 as const }); }); @@ -374,11 +338,17 @@ 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: "getMatcher" | "getMatcherW", - body: string, -): Promise => { +interface LabelsProbe { + readonly name: string; + readonly factory: "getMatcher" | "getMatcherW"; + readonly body: string; +} + +const labelsFor = ({ + name, + factory, + body, +}: LabelsProbe): Promise => { const session = new LspSession(REPO_ROOT); const target: CompletionTarget = { file: `src/__autocomplete_${name}.ts`, @@ -396,16 +366,20 @@ const labelsFor = ( .finally(() => session.close()); }; -test("autocomplete: an exhaustive pattern requires the universe, `_` optional", () => { +test("autocomplete: an exhaustive pattern requires the universe", () => { // Arrange const name = "getMatcher_fresh"; // Act - const labels = labelsFor(name, "getMatcher", " /*COMPLETE*/"); + const labels = labelsFor({ + name, + factory: "getMatcher", + body: " /*COMPLETE*/", + }); // Assert return labels.then((result) => { - assert.deepEqual([...result], ["_?", "a", "b", "c"]); + assert.deepEqual([...result], ["a", "b", "c"]); }); }); @@ -414,32 +388,15 @@ test("autocomplete: handled keys drop out of the popup", () => { const name = "getMatcher_after_key"; // Act - const labels = labelsFor( + const labels = labelsFor({ name, - "getMatcher", - " a: () => 1,\n /*COMPLETE*/", - ); - - // Assert - return labels.then((result) => { - assert.deepEqual([...result], ["_?", "b", "c"]); + factory: "getMatcher", + body: " a: () => 1,\n /*COMPLETE*/", }); -}); - -test("autocomplete: `_` makes the remaining keys optional", () => { - // Arrange - const name = "getMatcher_after_fallback"; - - // Act - const labels = labelsFor( - name, - "getMatcher", - " _: () => 0,\n /*COMPLETE*/", - ); // Assert return labels.then((result) => { - assert.deepEqual([...result], ["a?", "b?", "c?"]); + assert.deepEqual([...result], ["b", "c"]); }); }); @@ -448,10 +405,14 @@ test("autocomplete: `getMatcherW` offers the same popup as `getMatcher`", () => const name = "getMatcherW_fresh"; // Act - const labels = labelsFor(name, "getMatcherW", " /*COMPLETE*/"); + const labels = labelsFor({ + name, + factory: "getMatcherW", + body: " /*COMPLETE*/", + }); // Assert return labels.then((result) => { - assert.deepEqual([...result], ["_?", "a", "b", "c"]); + assert.deepEqual([...result], ["a", "b", "c"]); }); }); diff --git a/src/primitive.ts b/src/primitive.ts index 7c13a32..63bfd67 100644 --- a/src/primitive.ts +++ b/src/primitive.ts @@ -1,4 +1,4 @@ -import type { ValueOf } from "type-fest"; +import type { Exact, ValueOf } from "type-fest"; type UnaryFn = (shape: T) => R; @@ -8,54 +8,66 @@ type PatternReturns

= ReturnType< Extract, (...args: never[]) => unknown> >; -type Handlers = { [K in T]: UnaryFn }; +type Handlers = { [K in T]: UnaryFn }; -// Fallback form: `_` is a required key, the T-keys are optional. -type Fallback = Partial> & { - _: UnaryFn; -}; +// The fallback is a *second argument*, not a property of the handler map, +// because its parameter is the remainder `Exclude` and TypeScript +// fixes a property's contextual type before it infers its sibling keys. A later +// argument, by contrast, is contextually typed from inference on an earlier +// one, so the split is what makes the remainder expressible at all. +// See development/library.md. +type Fallback = UnaryFn< + Exclude, + R +>; -// 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; -}; +// TypeScript does not apply the excess-property check to a generic constraint, +// so `Exact` restores it for the generic forms: a handler map can otherwise +// carry keys outside `T`. // 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 +// #1 Handlers (first) -> the exhaustive form and the autocomplete popup +// #2 Fallback (last) -> accepts a partial handler map plus a fallback interface MatcherStrict { - (pattern: ExhaustiveLoose): UnaryFn; - (pattern: Fallback): UnaryFn; - (pattern: Handlers): UnaryFn; + (handlers: Handlers): UnaryFn; + >, Handled>>( + handlers: Handled, + fallback: Fallback, + ): UnaryFn; } // oxlint-enable typescript/unified-signatures // 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`/`_`. +// from the whole handler map, whose closed constraint supplies the +// contextual/autocomplete type. +// oxlint-disable typescript/unified-signatures interface MatcherWidening { -

>( - 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), +

, P>>( + handlers: P, ): UnaryFn>; + >, Handled>>( + handlers: Handled, + fallback: Fallback, + ): UnaryFn | R>; } +// oxlint-enable typescript/unified-signatures type HandlerMap = Record | undefined>; const dispatch = - (pattern: HandlerMap) => + (handlers: HandlerMap, fallback?: UnaryFn) => (shape: string | number): unknown => - // oxlint-disable-next-line typescript/no-non-null-assertion typescript/no-unsafe-type-assertion - (pattern[shape] ?? pattern["_"]!)(shape as never); + ( + handlers[shape] ?? + fallback ?? + (() => { + throw new Error(`Unhandled shape: ${shape}`); + }) + )( + // oxlint-disable-next-line typescript/no-unsafe-type-assertion + shape as never, + ); export const getMatcher = (): MatcherStrict => dispatch;