♻️ 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.
This commit is contained in:
tmu committed 2026-09-17 20:59:09 +00:00
1 parent 02634f41de
commit 165bd9e3df
3 files changed
+373 -270

No files matched your search

+305 -205
View File
@@ -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<Parameters<typeof factory>[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<Parameters<typeof matcher>[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<Parameters<typeof matcher>[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<Parameters<typeof matcher>[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<Parameters<typeof sparseFactory>[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<Parameters<typeof sparseFactory>[0]>();
// ❌ a value outside T must not be accepted by the matcher
expectTypeOf<"w">().not.toExtend<Parameters<typeof matcher>[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<typeof factory>[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<readonly string[]> => {
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"]);
});
});